diff --git a/je_editor/git_client/file_staging.py b/je_editor/git_client/file_staging.py index 76aeb454..500240de 100644 --- a/je_editor/git_client/file_staging.py +++ b/je_editor/git_client/file_staging.py @@ -94,3 +94,85 @@ def stage_content(file_path: str | Path, content: str) -> bool: jeditor_logger.error(f"file_staging: could not stage {file_path}: {error!r}") return False return True + + +def unstage_file(file_path: str | Path) -> bool: + """ + 把一個檔案的暫存內容還原成 HEAD 的版本 + Put a file's staged content back to the way HEAD has it. + + 這是「取消暫存」:索引回到已提交的內容,工作區的檔案完全不動,因此使用者正在 + 編輯的東西不會受影響。暫存了不該暫存的一段之後,這是唯一能收回的方法。 + This is unstaging: the index goes back to the committed content while the file + on disk is left exactly as it is, so nothing the user is editing is touched. + After staging a hunk that should not have been staged, this is the only way + back. + + 尚未提交過的新檔案沒有 HEAD 版本可回去,改為從索引移除。 + A new file has no committed version to return to, so it is removed from the + index instead. + + :param file_path: 檔案路徑 / the file to unstage + :return: 索引有變時為 ``True`` / ``True`` when the index changed + """ + repo = open_repository(file_path) + if repo is None: + return False + with repo: + relative = _relative_to(repo, file_path) + if relative is None: + return False + try: + blob = repo.head.commit.tree / relative + except (KeyError, ValueError, TypeError, AttributeError, GitError): + return _remove_from_index(repo, relative, file_path) + try: + repo.index.add([IndexEntry.from_blob( + Blob(repo, blob.binsha, _FILE_MODE, relative))]) + repo.index.write() + except (ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not unstage {file_path}: {error!r}") + return False + return True + + +def commit_index(file_path: str | Path, message: str) -> bool: + """ + 把索引目前的內容提交出去 + Commit whatever the index currently holds. + + 提交的是索引而不是磁碟上的檔案,因此逐段暫存之後可以只提交挑好的那幾段,還沒 + 暫存的修改留在工作區。 + What is committed is the index rather than the files on disk, so after staging + hunk by hunk only the chosen parts go in and the rest stays in the working + tree. + + :param file_path: 儲存庫中的任一個檔案 / any file in the repository + :param message: 提交訊息 / the commit message + :return: 有提交時為 ``True`` / ``True`` when a commit was made + """ + if not message.strip(): + return False + repo = open_repository(file_path) + if repo is None: + return False + with repo: + try: + repo.index.commit(message.strip()) + except (ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not commit: {error!r}") + return False + return True + + +def _remove_from_index(repo, relative: str, file_path: str | Path) -> bool: + """把一個還沒提交過的檔案從索引移除 / Drop a never-committed file from the index.""" + try: + if (relative, 0) not in repo.index.entries: + return False + repo.index.remove([relative]) + repo.index.write() + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not unstage {file_path}: {error!r}") + return False + return True diff --git a/je_editor/git_client/git_action.py b/je_editor/git_client/git_action.py index 4c85788c..5d897e55 100644 --- a/je_editor/git_client/git_action.py +++ b/je_editor/git_client/git_action.py @@ -148,6 +148,102 @@ def remotes(self) -> list[str]: self._ensure_repo() return [r.name for r in self.repo.remotes] + def stash_save(self, message: str = "") -> str: + """ + 把目前的修改收進 stash + Put the current changes away in a stash. + + 要暫時放下手邊的東西去改別的地方時用這個;內容留在 stash 裡,之後可以取回。 + This is for putting work down to deal with something else; the changes + stay in the stash and can be taken back afterwards. + + :param message: 這次 stash 的說明 / a note describing the stash + :return: git 的輸出 / what git printed + """ + self._ensure_repo() + arguments = ["push"] + (["-m", message] if message.strip() else []) + try: + result = self.repo.git.stash(*arguments) + audit_log(self.repo_path, "stash_save", message, True) + return result + except GitCommandError as error: + audit_log(self.repo_path, "stash_save", message, False, str(error)) + raise + + def stash_list(self) -> list[str]: + """ + 列出目前收著的 stash + The stashes currently put away. + + :return: 每個 stash 的說明 / a description of each + """ + self._ensure_repo() + output = self.repo.git.stash("list") + return [line for line in output.splitlines() if line.strip()] + + def stash_pop(self, index: int = 0) -> str: + """ + 取回一個 stash,並把它從清單移除 + Take a stash back, removing it from the list. + + :param index: 要取回的 stash 編號 / which stash to take back + :return: git 的輸出 / what git printed + """ + self._ensure_repo() + reference = f"stash@{{{max(0, index)}}}" + try: + result = self.repo.git.stash("pop", reference) + audit_log(self.repo_path, "stash_pop", reference, True) + return result + except GitCommandError as error: + audit_log(self.repo_path, "stash_pop", reference, False, str(error)) + raise + + def conflicted_files(self) -> list[str]: + """ + 列出目前處於衝突狀態的檔案 + The files currently left in conflict. + + 合併或 pull 之後,這些檔案裡有需要人來決定的部分。 + After a merge or a pull, these hold the parts someone has to decide about. + + :return: 相對於儲存庫根目錄的路徑 / paths relative to the repository root + """ + self._ensure_repo() + # 索引裡同一個路徑有多個 stage 就代表衝突:2 是我們的版本,3 是對方的 + # A path with more than one stage in the index is in conflict: 2 is ours + # and 3 is theirs + return sorted({ + path for path, stage in self.repo.index.entries.keys() if stage != 0 + }) + + def resolve_conflict(self, file_path: str, keep: str = "ours") -> bool: + """ + 以某一邊的內容解決一個檔案的衝突 + Resolve a file's conflict by keeping one side of it. + + :param file_path: 相對於儲存庫根目錄的路徑 / the path, relative to the root + :param keep: ``ours`` 保留自己這邊,``theirs`` 保留對方 / which side to keep + :return: 有解決時為 ``True`` / ``True`` when it was resolved + """ + self._ensure_repo() + if keep not in ("ours", "theirs"): + return False + # 沒有衝突的檔案也吃得下 ``checkout --ours``,但那只會把它默默加進索引—— + # 呼叫端要的是解決衝突,不是暫存一個沒事的檔案 + # ``checkout --ours`` is accepted for a file with no conflict too, but all + # that does is quietly stage it, which is not what resolving asks for + if file_path not in self.conflicted_files(): + return False + try: + self.repo.git.checkout(f"--{keep}", "--", file_path) + self.repo.git.add(file_path) + audit_log(self.repo_path, "resolve_conflict", f"{file_path} {keep}", True) + except GitCommandError as error: + audit_log(self.repo_path, "resolve_conflict", file_path, False, str(error)) + return False + return True + def _ensure_repo(self) -> None: if self.repo is None: raise RuntimeError("Repository not opened.") diff --git a/je_editor/pyside_ui/code/folding/folding_manager.py b/je_editor/pyside_ui/code/folding/folding_manager.py index 181fb31f..a59916bd 100644 --- a/je_editor/pyside_ui/code/folding/folding_manager.py +++ b/je_editor/pyside_ui/code/folding/folding_manager.py @@ -9,11 +9,11 @@ """ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING -from je_editor.utils.code_folding.fold_regions import ( - FoldRegion, compute_fold_regions, region_at_line -) +from je_editor.utils.code_folding.brace_regions import fold_regions_for +from je_editor.utils.code_folding.fold_regions import FoldRegion, region_at_line from je_editor.utils.logging.loggin_instance import jeditor_logger if TYPE_CHECKING: @@ -44,10 +44,19 @@ def compute_regions(self) -> list[FoldRegion]: 依目前文字計算所有可折疊區塊 Compute every foldable region for the current text. + 折疊方式依語言而定:以大括號劃分區塊的語言看括號配對,其餘看縮排。 + How the regions are found depends on the language: brace-delimited ones + follow their pairs, and everything else follows indentation. + :return: 可折疊區塊清單 / The list of foldable regions """ text = self._editor.toPlainText() - return compute_fold_regions(text.split("\n")) + return fold_regions_for(text.split("\n"), self._suffix()) + + def _suffix(self) -> str: + """目前檔案的副檔名 / The current file's suffix.""" + current = getattr(self._editor, "current_file", None) + return Path(str(current)).suffix if current else "" def foldable_header_lines(self) -> set[int]: """ diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index d18b9b51..fc29407b 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -1,12 +1,12 @@ """ -以 stdio 與語言伺服器溝通 -Talk to a language server over stdio. +一個檔案這一端的語言伺服器連線 +One file's end of a language server connection. -用 QProcess 而不是自己開執行緒:QProcess 本來就是非同步的,讀到資料會發訊號, -所以不需要為了等待輸出而佔住一條執行緒。 -This uses QProcess rather than a thread of its own: QProcess is already -asynchronous and signals when data arrives, so no thread has to sit waiting for -output. +伺服器程序本身由 ``lsp_session`` 保管並共用;這裡負責的是「這個檔案」的部分: +版本號、送出的請求、以及把回覆變成編輯器聽得懂的訊號。 +The process itself is held and shared by ``lsp_session``; what lives here is the +part belonging to one file — its version number, the requests it sent, and +turning replies into signals the editor understands. 伺服器沒安裝、啟動失敗或中途結束時都只是「沒有補全」,不會影響編輯。 A server that is missing, fails to start, or dies mid-session simply means no @@ -16,26 +16,25 @@ from pathlib import Path -from PySide6.QtCore import QObject, QProcess, Signal +from PySide6.QtCore import QObject, Signal -from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.pyside_ui.code.lsp.lsp_session import LspSession, session_registry from je_editor.utils.lsp.language_servers import language_id, server_command from je_editor.utils.lsp.lsp_protocol import ( - MessageReader, + code_action_titles, completion_labels, definition_location, diagnostic_entries, - encode_message, + document_symbols, file_uri, hover_text, notification, + reference_locations, request, + signature_text, text_edits, ) -# 等待伺服器結束的時間(毫秒)/ How long to wait for the server to exit -_SHUTDOWN_WAIT_MS = 2000 - class LspClient(QObject): """ @@ -48,71 +47,86 @@ class LspClient(QObject): definition_ready = Signal(dict) # {"path": str, "line": int, "column": int} hover_ready = Signal(str) edits_ready = Signal(list) # list[dict] of text edits to apply + signature_ready = Signal(str) + references_ready = Signal(list) # list[dict] of {"path", "line", "column"} + code_actions_ready = Signal(list) # list[dict] of {"title", "edits"} + symbols_ready = Signal(list) # list[dict] of {"name", "kind", "line", "depth"} def __init__(self, parent: QObject | None = None) -> None: """ :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) - self._process: QProcess | None = None - self._reader = MessageReader() - self._next_id = 1 + self._session: LspSession | None = None self._file_path: str | None = None self._version = 0 self._pending_completion_id: int | None = None self._pending_definition_id: int | None = None self._pending_hover_id: int | None = None self._pending_edit_id: int | None = None + self._pending_signature_id: int | None = None + self._pending_references_id: int | None = None + self._pending_action_id: int | None = None + self._pending_symbol_id: int | None = None + # 伺服器最後一次回報的診斷,未經轉換 / The server's last diagnostics, unconverted + self._raw_diagnostics: list = [] + + def diagnostics_on_line(self, line: int) -> list: + """ + 取得某一行的診斷,維持伺服器給的原始格式 + The diagnostics on one line, exactly as the server reported them. + + :param line: 以 0 起算的行號,與 LSP 相同 / the 0-based line, as LSP counts them + :return: 該行的診斷 / the diagnostics there + """ + return [ + item for item in self._raw_diagnostics + if ((item.get("range") or {}).get("start") or {}).get("line") == line + ] @property def running(self) -> bool: """伺服器是否正在執行 / Whether the server is running.""" - return self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning + return self._session is not None and self._session.running def start_for(self, file_path: str, servers: dict | None = None) -> bool: """ - 為某個檔案啟動對應的語言伺服器 - Start the language server that handles a file. + 接上負責這個檔案的語言伺服器 + Attach to the language server that handles a file. + + 同一個指令與專案根目錄底下的檔案共用一個伺服器程序,因此開第二個同語言的 + 檔案不會再啟動一個。 + Files under the same command and project root share one process, so + opening a second file of that language does not start another. :param file_path: 檔案路徑 / the file to serve :param servers: 伺服器對照表 / the server mapping to consult - :return: 有啟動時為 ``True`` / ``True`` when a server was started + :return: 有接上時為 ``True`` / ``True`` when a server was attached """ command = server_command(Path(file_path).suffix, servers) if command is None: return False self.stop() - process = QProcess(self) - process.setProgram(command[0]) - process.setArguments(command[1:]) - process.readyReadStandardOutput.connect(self._read_output) - process.start() - if not process.waitForStarted(_SHUTDOWN_WAIT_MS): - jeditor_logger.debug(f"lsp_client: {command[0]} did not start") - process.deleteLater() + root = str(Path(file_path).parent) + session = session_registry.session_for(command, root, file_uri(root)) + if session is None: return False - self._process = process + self._session = session self._file_path = file_path - self._send(request(self._take_id(), "initialize", { - "processId": None, - "rootUri": file_uri(str(Path(file_path).parent)), - "capabilities": {}, - })) - self._send(notification("initialized", {})) + session.register_document(file_uri(file_path), self) return True - def _take_id(self) -> int: - """取得下一個請求編號 / Take the next request id.""" - request_id = self._next_id - self._next_id += 1 - return request_id - def _send(self, payload: dict) -> bool: """把訊息寫給伺服器 / Write a message to the server.""" - if not self.running: - return False - self._process.write(encode_message(payload)) - return True + return self._session.send(payload) if self._session is not None else False + + def _send_request(self, payload: dict) -> bool: + """送出請求,回覆會回到這個客戶端 / Send a request, so its reply comes back here.""" + return self._session.send_request(self, payload) if self._session is not None else False + + def _take_id(self) -> int: + """取得下一個請求編號 / Take the next request id.""" + return self._session.take_id() if self._session is not None else 0 def did_open(self, text: str) -> bool: """ @@ -163,7 +177,7 @@ def request_completion(self, line: int, column: int) -> bool: return False request_id = self._take_id() self._pending_completion_id = request_id - return self._send(request(request_id, "textDocument/completion", { + return self._send_request(request(request_id, "textDocument/completion", { "textDocument": {"uri": file_uri(self._file_path)}, "position": {"line": line, "character": column}, })) @@ -194,7 +208,7 @@ def request_rename(self, line: int, column: int, new_name: str) -> bool: return False request_id = self._take_id() self._pending_edit_id = request_id - return self._send(request(request_id, "textDocument/rename", { + return self._send_request(request(request_id, "textDocument/rename", { "textDocument": {"uri": file_uri(self._file_path)}, "position": {"line": line, "character": column}, "newName": new_name, @@ -212,11 +226,84 @@ def request_formatting(self, tab_size: int = 4) -> bool: return False request_id = self._take_id() self._pending_edit_id = request_id - return self._send(request(request_id, "textDocument/formatting", { + return self._send_request(request(request_id, "textDocument/formatting", { "textDocument": {"uri": file_uri(self._file_path)}, "options": {"tabSize": tab_size, "insertSpaces": True}, })) + def request_signature_help(self, line: int, column: int) -> bool: + """ + 要求目前正在輸入的呼叫的簽章 + Ask for the signature of the call being typed. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + return self._position_request( + "textDocument/signatureHelp", line, column, "_pending_signature_id") + + def request_references(self, line: int, column: int) -> bool: + """ + 要求游標所在符號的所有參照 + Ask where the symbol at a position is referred to. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_references_id = request_id + return self._send_request(request(request_id, "textDocument/references", { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": True}, + })) + + def request_code_actions(self, line: int, column: int, diagnostics: list | None = None) -> bool: + """ + 要求某個位置可以套用的修正 + Ask what can be done about a position. + + 把該處的診斷一併送去,伺服器才知道要提出哪些修正——沒有診斷時通常只會回 + 重構類的動作。 + The diagnostics there go along with it, since that is what tells the + server which fixes to offer; without them the reply is usually only + refactorings. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :param diagnostics: 該處的診斷 / the diagnostics reported there + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_action_id = request_id + position = {"line": line, "character": column} + return self._send_request(request(request_id, "textDocument/codeAction", { + "textDocument": {"uri": file_uri(self._file_path)}, + "range": {"start": position, "end": position}, + "context": {"diagnostics": diagnostics or []}, + })) + + def request_document_symbols(self) -> bool: + """ + 要求這個檔案裡的所有符號 + Ask for every symbol in this file. + + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_symbol_id = request_id + return self._send_request(request(request_id, "textDocument/documentSymbol", { + "textDocument": {"uri": file_uri(self._file_path)}, + })) + def request_definition(self, line: int, column: int) -> bool: """ 要求某個位置的定義位置 @@ -235,7 +322,7 @@ def _position_request( return False request_id = self._take_id() setattr(self, pending_attribute, request_id) - return self._send(request(request_id, method, { + return self._send_request(request(request_id, method, { "textDocument": {"uri": file_uri(self._file_path)}, "position": {"line": line, "character": column}, })) @@ -248,60 +335,104 @@ def handle_message(self, message: dict) -> None: :param message: 已解析的訊息 / the parsed message """ if message.get("method") == "textDocument/publishDiagnostics": - entries = diagnostic_entries(message.get("params")) - self.diagnostics_ready.emit(entries) + self._handle_diagnostics(message.get("params")) return - if "result" not in message: - return - if message.get("id") == self._pending_completion_id: - self._pending_completion_id = None - self.completions_ready.emit(completion_labels(message.get("result"))) - return - if message.get("id") == self._pending_definition_id: - self._pending_definition_id = None - location = definition_location(message.get("result")) - if location is not None: - self.definition_ready.emit(location) - return - if message.get("id") == self._pending_hover_id: - self._pending_hover_id = None - text = hover_text(message.get("result")) - if text: - self.hover_ready.emit(text) - return - if message.get("id") == self._pending_edit_id: - self._pending_edit_id = None - uri = file_uri(self._file_path) if self._file_path else "" - edits = text_edits(message.get("result"), uri) - if edits: - self.edits_ready.emit(edits) - - def _read_output(self) -> None: - """讀取伺服器輸出並逐則處理 / Read the server's output and handle each message.""" - if self._process is None: + if "result" in message: + self._handle_reply(message) + + def _handle_diagnostics(self, params: object) -> None: + """ + 處理伺服器主動送來的診斷 + Handle the diagnostics the server pushes. + + 原樣留一份:要求修正時得把伺服器自己給的診斷送回去,而不是編輯器轉換過的 + 版本——連行號起算方式都不同。 + Keep them as they came: asking for a fix means handing the server back its + own diagnostics, not the editor's converted form, which does not even + count lines the same way. + + :param params: 通知的參數 / the notification's params + """ + raw = (params or {}).get("diagnostics") if isinstance(params, dict) else None + self._raw_diagnostics = list(raw) if isinstance(raw, list) else [] + self.diagnostics_ready.emit(diagnostic_entries(params)) + + def _handle_reply(self, message: dict) -> None: + """ + 把回覆交給發問的那一個請求 + Give a reply back to the request that asked for it. + + 每一種回覆都是同一個形狀——編號對得上就解析結果並發出訊號——因此用一張表 + 處理,不必為每一種各寫一段幾乎相同的判斷。 + Every reply has the same shape, a matching id means parse the result and + emit, so one table handles them rather than a near-identical branch each. + + :param message: 已解析的訊息 / the parsed message + """ + for attribute, parse, signal in self._reply_handlers(): + if message.get("id") != getattr(self, attribute): + continue + setattr(self, attribute, None) + parsed = parse(message.get("result")) + if parsed: + signal.emit(parsed) return - data = bytes(self._process.readAllStandardOutput()) - for message in self._reader.feed(data): - self.handle_message(message) + + def _reply_handlers(self) -> tuple: + """每種回覆的解析方式與去處 / How each kind of reply is parsed, and where it goes.""" + return ( + ("_pending_completion_id", completion_labels, self.completions_ready), + ("_pending_definition_id", definition_location, self.definition_ready), + ("_pending_hover_id", hover_text, self.hover_ready), + ("_pending_edit_id", self._edits_of, self.edits_ready), + ("_pending_signature_id", signature_text, self.signature_ready), + ("_pending_references_id", reference_locations, self.references_ready), + ("_pending_action_id", code_action_titles, self.code_actions_ready), + ("_pending_symbol_id", document_symbols, self.symbols_ready), + ) + + def _edits_of(self, result: object) -> list: + """取出要套用到這個檔案的編輯 / The edits to apply to this file.""" + return text_edits(result, file_uri(self._file_path) if self._file_path else "") def stop(self) -> None: """ - 結束伺服器 - Shut the server down. + 放掉這個檔案的連線 + Let go of this file's connection. - 先以 ``shutdown``/``exit`` 請它自己結束,逾時才強制終止。 - It is asked to finish with ``shutdown``/``exit`` first, and only killed - if it does not. + 伺服器是共用的,所以這裡只通知它這個檔案關了;等到沒有任何編輯器還用著同 + 一個伺服器,連線表才會把程序關掉。 + The server is shared, so this only tells it the file is closed; the + process is shut down once no editor is using that server any more. """ - process, self._process = self._process, None - self._reader = MessageReader() + session, self._session = self._session, None self._pending_completion_id = None - if process is None: + self._pending_definition_id = None + self._pending_hover_id = None + self._pending_edit_id = None + if session is None: return - if process.state() != QProcess.ProcessState.NotRunning: - process.write(encode_message(request(self._take_id(), "shutdown"))) - process.write(encode_message(notification("exit"))) - if not process.waitForFinished(_SHUTDOWN_WAIT_MS): - process.kill() - process.waitForFinished(_SHUTDOWN_WAIT_MS) - process.deleteLater() + if self._file_path is not None: + uri = file_uri(self._file_path) + session.send(notification("textDocument/didClose", {"textDocument": {"uri": uri}})) + session.forget_document(uri) + session_registry.release(session) + + def did_save(self, text: str) -> bool: + """ + 通知伺服器檔案已存檔 + Tell the server the file was saved. + + 有些伺服器只在存檔後才重跑比較慢的檢查,收不到這個通知就永遠不會跑。 + Some servers only run their slower checks on save, and never run them at + all without this. + + :param text: 存下去的內容 / the content that was saved + :return: 有送出時為 ``True`` / ``True`` when the notification was sent + """ + if self._file_path is None: + return False + return self._send(notification("textDocument/didSave", { + "textDocument": {"uri": file_uri(self._file_path)}, + "text": text, + })) diff --git a/je_editor/pyside_ui/code/lsp/lsp_session.py b/je_editor/pyside_ui/code/lsp/lsp_session.py new file mode 100644 index 00000000..832362b8 --- /dev/null +++ b/je_editor/pyside_ui/code/lsp/lsp_session.py @@ -0,0 +1,242 @@ +""" +一個語言伺服器程序,供開著同種檔案的編輯器共用 +One language server process, shared by every editor holding that kind of file. + +原本是「一個編輯器一個伺服器」,開五個 ``.ts`` 就有五個伺服器程序,各自把整個 +專案索引一遍——記憶體與啟動時間都乘以五,而且它們對同一份專案的認知還各自獨立。 +It used to be one server per editor, so five open ``.ts`` files meant five +processes each indexing the whole project: five times the memory and the startup +cost, with five separate ideas of the same project. + +同一個指令與同一個專案根目錄只會有一個連線,用參考計數決定什麼時候關掉。回覆依 +請求編號送回發問的那個客戶端,診斷則依 URI 送給對應的檔案。 +One connection exists per command and project root, closed when the last user +lets go. A reply goes back to whichever client asked, and a diagnostic goes to +the client holding that URI. +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +from PySide6.QtCore import QObject, QProcess + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.lsp.lsp_protocol import ( + MessageReader, encode_message, notification, request +) + +# 等待伺服器啟動或結束的時間(毫秒)/ How long to wait for the server to start or finish +_WAIT_MS = 2000 + + +class LspSession(QObject): + """ + 一個語言伺服器連線 + One connection to a language server. + """ + + def __init__(self, command: List[str], root: str, parent: QObject | None = None) -> None: + """ + :param command: 啟動伺服器的指令 / the command that starts the server + :param root: 專案根目錄 / the project root it serves + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._command = list(command) + self._root = root + self._process: QProcess | None = None + self._reader = MessageReader() + self._next_id = 1 + # 每個請求編號屬於哪個客戶端 / Which client each request id belongs to + self._waiting: Dict[int, object] = {} + # 目前開著的檔案,URI 對應客戶端 / The open files, by URI + self._documents: Dict[str, object] = {} + + @property + def running(self) -> bool: + """伺服器是否正在執行 / Whether the server is running.""" + return (self._process is not None + and self._process.state() != QProcess.ProcessState.NotRunning) + + @property + def key(self) -> Tuple[Tuple[str, ...], str]: + """這個連線的識別 / What identifies this connection.""" + return tuple(self._command), self._root + + def start(self, root_uri: str) -> bool: + """ + 啟動伺服器 + Start the server. + + :param root_uri: 專案根目錄的 URI / the project root's URI + :return: 有啟動時為 ``True`` / ``True`` when it started + """ + if self.running: + return True + process = QProcess(self) + process.setProgram(self._command[0]) + process.setArguments(self._command[1:]) + process.readyReadStandardOutput.connect(self._read_output) + process.start() + if not process.waitForStarted(_WAIT_MS): + jeditor_logger.debug(f"lsp_session: {self._command[0]} did not start") + process.deleteLater() + return False + self._process = process + self.send(request(self.take_id(), "initialize", { + "processId": None, + "rootUri": root_uri, + "capabilities": {}, + })) + self.send(notification("initialized", {})) + return True + + def take_id(self) -> int: + """取得下一個請求編號 / Take the next request id.""" + request_id = self._next_id + self._next_id += 1 + return request_id + + def send(self, payload: dict) -> bool: + """把訊息寫給伺服器 / Write a message to the server.""" + if not self.running: + return False + self._process.write(encode_message(payload)) + return True + + def send_request(self, client: object, payload: dict) -> bool: + """ + 代表某個客戶端送出請求,回覆會送回給它 + Send a request on a client's behalf, so the reply comes back to it. + + :param client: 發問的客戶端 / the client asking + :param payload: 請求內容 / the request + :return: 有送出時為 ``True`` / ``True`` when it was sent + """ + if not self.send(payload): + return False + self._waiting[payload.get("id")] = client + return True + + def register_document(self, uri: str, client: object) -> None: + """記下某個檔案由哪個客戶端負責 / Note which client holds a file.""" + self._documents[uri] = client + + def forget_document(self, uri: str) -> None: + """放掉一個檔案 / Let go of a file.""" + self._documents.pop(uri, None) + + @property + def user_count(self) -> int: + """目前有幾個客戶端在用 / How many clients are using this.""" + return len(self._documents) + + def _read_output(self) -> None: + """讀取伺服器輸出並逐則轉交 / Read the server's output and pass each message on.""" + if self._process is None: + return + data = bytes(self._process.readAllStandardOutput()) + for message in self._reader.feed(data): + self.route(message) + + def route(self, message: dict) -> Optional[object]: + """ + 把一則訊息交給該收的客戶端 + Hand one message to the client it belongs to. + + 診斷帶著 URI,交給持有該檔案的客戶端;其餘依請求編號交回發問的那一個。 + A diagnostic carries a URI and goes to whoever holds that file; anything + else goes back to whichever client asked for it. + + :param message: 已解析的訊息 / the parsed message + :return: 收下這則訊息的客戶端,沒有時為 ``None`` / the client, or ``None`` + """ + if message.get("method") == "textDocument/publishDiagnostics": + uri = (message.get("params") or {}).get("uri", "") + client = self._documents.get(uri) + else: + client = self._waiting.pop(message.get("id"), None) + if client is not None: + client.handle_message(message) + return client + + def shutdown(self) -> None: + """ + 結束伺服器 + Shut the server down. + + 先以 ``shutdown``/``exit`` 請它自己結束,逾時才強制終止。 + It is asked to finish with ``shutdown``/``exit`` first, and only killed + if it does not. + """ + process, self._process = self._process, None + self._reader = MessageReader() + self._waiting = {} + self._documents = {} + if process is None: + return + if process.state() != QProcess.ProcessState.NotRunning: + process.write(encode_message(request(self.take_id(), "shutdown"))) + process.write(encode_message(notification("exit"))) + if not process.waitForFinished(_WAIT_MS): + process.kill() + process.waitForFinished(_WAIT_MS) + process.deleteLater() + + +class LspSessionRegistry: + """ + 保管目前開著的語言伺服器連線 + Keep the language server connections that are currently open. + """ + + def __init__(self) -> None: + self._sessions: Dict[Tuple[Tuple[str, ...], str], LspSession] = {} + + def session_for(self, command: List[str], root: str, root_uri: str) -> Optional[LspSession]: + """ + 取得對應的連線,沒有就開一個 + The connection for a command and root, starting one when there is none. + + :param command: 啟動伺服器的指令 / the command that starts the server + :param root: 專案根目錄 / the project root + :param root_uri: 專案根目錄的 URI / that root as a URI + :return: 連線,啟動失敗時為 ``None`` / the session, or ``None`` when it failed + """ + key = (tuple(command), root) + session = self._sessions.get(key) + if session is not None and session.running: + return session + session = LspSession(command, root) + if not session.start(root_uri): + return None + self._sessions[key] = session + return session + + def release(self, session: LspSession) -> bool: + """ + 放掉一個連線;沒有人在用就關掉伺服器 + Let go of a connection, shutting the server down once nobody holds it. + + :param session: 要放掉的連線 / the session being let go + :return: 是否關掉了伺服器 / whether the server was shut down + """ + if session.user_count > 0: + return False + self._sessions.pop(session.key, None) + session.shutdown() + return True + + def sessions(self) -> List[LspSession]: + """目前開著的連線 / The connections currently open.""" + return list(self._sessions.values()) + + def shutdown_all(self) -> None: + """關掉每一個連線 / Shut every connection down.""" + sessions, self._sessions = self._sessions, {} + for session in sessions.values(): + session.shutdown() + + +# 整個應用程式共用的連線表 / The connections the whole application shares +session_registry = LspSessionRegistry() diff --git a/je_editor/pyside_ui/code/minimap/minimap_widget.py b/je_editor/pyside_ui/code/minimap/minimap_widget.py index 24ce54aa..38f689ba 100644 --- a/je_editor/pyside_ui/code/minimap/minimap_widget.py +++ b/je_editor/pyside_ui/code/minimap/minimap_widget.py @@ -82,24 +82,40 @@ def marker_lines(self) -> dict[str, list[int]]: 取得要在縮圖上標記的行 The lines the minimap should mark. - 資料全部來自編輯器已經算好的東西——git 變更、診斷、游標所在字詞的其他 - 出現處——所以標記不需要自己再掃一次檔案。 - Everything comes from what the editor has already worked out: git - changes, diagnostics, and the other occurrences of the word under the - caret. The markers never rescan the file themselves. + 診斷與 git 變更來自編輯器已經算好的結果。中間那一排標的是「使用者正在找的 + 東西」:搜尋框有輸入時就是它的命中行,沒有時才退回游標所在字詞的其他出現處。 + Diagnostics and git changes come from what the editor has already worked + out. The middle column marks what the user is looking for: the search + box's hits while something is being searched for, and otherwise the other + occurrences of the word under the caret. :return: 標記種類對應行號(0 起算)/ marker kind -> 0-based line numbers """ editor = self._code_edit diagnostics = sorted({item.line - 1 for item in editor.lint_manager.diagnostics()}) changes = sorted(editor.diff_marker_manager.statuses()) + return { + "diagnostic": diagnostics, + "change": changes, + "occurrence": self._lines_being_looked_for(), + } + + def _lines_being_looked_for(self) -> list[int]: + """ + 取得使用者正在找的行 + The lines holding whatever the user is looking for. + + :return: 行號(0 起算)/ the 0-based line numbers + """ + editor = self._code_edit + if editor.search_term(): + return editor.search_hit_lines() document = editor.document() - occurrences = sorted({ + return sorted({ document.findBlock(position).blockNumber() for position in editor.word_occurrences_under_cursor( editor.toPlainText(), editor.textCursor().position()) }) - return {"diagnostic": diagnostics, "change": changes, "occurrence": occurrences} def _paint_markers(self, painter: QPainter, step: int) -> None: """在縮圖兩側畫出診斷、變更與出現處的標記 / Mark diagnostics, changes and hits.""" diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py index b193d873..569e6eb1 100644 --- a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -12,7 +12,8 @@ from PySide6.QtGui import QTextCursor from je_editor.utils.multi_cursor.cursor_positions import ( - add_position, clamp_positions, column_caret_columns, column_span, toggle_position + add_position, clamp_positions, column_caret_columns, column_span, + positions_after_replacing, toggle_position ) @@ -28,6 +29,8 @@ def __init__(self, code_edit) -> None: """ self._code_edit = code_edit self._positions: list[int] = [] + # 有選取範圍的游標:位置對應它的錨點 / A caret with a selection, mapped to its anchor + self._anchors: dict[int, int] = {} @property def active(self) -> bool: @@ -48,6 +51,7 @@ def clear(self) -> bool: if not self._positions: return False self._positions = [] + self._anchors = {} self._code_edit.viewport().update() return True @@ -99,54 +103,90 @@ def _edit_targets(self) -> list[int]: """ return sorted({*self._positions, self._code_edit.textCursor().position()}) - def _apply_at_targets( - self, edit, shift_per_target: int, shift_others: int | None = None) -> None: - """ - 由後往前在每個位置套用一次編輯,再把游標移到新位置 - Apply one edit at each position from the end backwards, then move the - carets to where they ended up. - - 由後往前做,先做的編輯就不會影響還沒處理的位置;做完之後,第 n 個位置前面 - 共發生了 n 次編輯,加上自己那一次,所以位移量是 ``(n + 1) * 每次的位移``。 - Working backwards keeps an edit from moving a position not yet handled. - Afterwards, the n-th position has n edits before it plus its own, so it - moves by ``(n + 1) * shift_per_target``. - - :param edit: 對單一位置執行的編輯 / the edit to run at one position - :param shift_per_target: 這個位置自己編輯後要移動多少(插入為正,Backspace - 為負,Delete 為 0,因為刪的是游標後面的字元) - how far this position itself moves after its own edit: positive to - insert, negative for Backspace, zero for Delete, which removes the - character after the caret - :param shift_others: 每次編輯讓「後面的位置」移動多少;省略時與 - *shift_per_target* 相同 - how far each edit moves the positions after it; defaults to - *shift_per_target* - """ - following = shift_per_target if shift_others is None else shift_others - targets = self._edit_targets() - main_position = self._code_edit.textCursor().position() + def selections(self) -> list[tuple[int, int]]: + """ + 取得每個額外游標的選取範圍 + Each extra caret's selection. + + :return: ``(起, 訖)`` 的清單,只含真的有選取的游標 + / the ``(start, end)`` pairs, only for carets that have a selection + """ + return sorted( + (min(anchor, position), max(anchor, position)) + for position, anchor in self._anchors.items() + if anchor != position and position in self._positions + ) + + def has_selections(self) -> bool: + """是否有任何游標帶著選取範圍 / Whether any caret holds a selection.""" + return bool(self.selections()) or self._code_edit.textCursor().hasSelection() + + def _range_for(self, position: int) -> tuple[int, int]: + """ + 取得一個游標涵蓋的範圍 + The range one caret covers. + + 有選取範圍就是那一段,否則是游標本身(長度為零)。 + A caret with a selection covers it, and one without covers just itself. + + :param position: 游標位置 / the caret's position + :return: ``(起, 訖)``,訖不含 / ``(start, end)``, end exclusive + """ + if position == self._code_edit.textCursor().position(): + cursor = self._code_edit.textCursor() + if cursor.hasSelection(): + return cursor.selectionStart(), cursor.selectionEnd() + anchor = self._anchors.get(position, position) + return min(anchor, position), max(anchor, position) + + def _replace_ranges(self, ranges: list[tuple[int, int]], text: str) -> None: + """ + 把每個範圍換成同一段文字,並把游標移到各自的新位置 + Replace every range with the same text, then move each caret after its own. + + 由後往前改寫,先做的改寫就不會挪動還沒處理的範圍。 + The rewrite runs back to front, so an earlier one cannot move a range that + has not been handled yet. + + :param ranges: 要改寫的範圍 / the ranges to rewrite + :param text: 換上去的文字 / the text to put in their place + """ + ordered = sorted(ranges) + main_range = self._range_for(self._code_edit.textCursor().position()) cursor = self._code_edit.textCursor() cursor.beginEditBlock() try: - for position in reversed(targets): - edit(cursor, position) + for start, end in reversed(ordered): + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(text) finally: cursor.endEditBlock() - moved = { - position: position + index * following + shift_per_target - for index, position in enumerate(targets) - } + moved = dict(zip(ordered, positions_after_replacing(ordered, len(text)))) + limit = self._document_limit() + self._anchors = {} + self._positions = clamp_positions( + [moved[item] for item in ordered if item != main_range], limit) main = self._code_edit.textCursor() - main.setPosition(min( - max(0, moved.get(main_position, main_position)), self._document_limit())) + main.setPosition(min(max(0, moved.get(main_range, main_range[0])), limit)) self._code_edit.setTextCursor(main) - self._positions = clamp_positions( - [moved[position] for position in self._positions if position in moved], - self._document_limit()) self._code_edit.viewport().update() + def _all_ranges(self, fallback) -> list[tuple[int, int]]: + """ + 取得每個游標要編輯的範圍 + The range each caret edits. + + :param fallback: 沒有選取範圍時要用的範圍 / what to use for a caret without a selection + :return: 每個游標的範圍 / one range per caret + """ + ranges = [] + for position in self._edit_targets(): + start, end = self._range_for(position) + ranges.append((start, end) if end > start else fallback(position)) + return ranges + def insert_text(self, text: str) -> bool: """ 在每個游標插入文字 @@ -157,12 +197,11 @@ def insert_text(self, text: str) -> bool: """ if not self._positions or not text: return False - - def insert(cursor: QTextCursor, position: int) -> None: - cursor.setPosition(position) - cursor.insertText(text) - - self._apply_at_targets(insert, len(text)) + # 有選取範圍的游標是「取代」,沒有的是「插入」;兩者都是把範圍換成這段文字 + # A caret with a selection replaces it and one without inserts; both are + # the same operation on a range + self._replace_ranges( + self._all_ranges(lambda position: (position, position)), text) return True def delete_before(self) -> bool: @@ -178,14 +217,12 @@ def delete_before(self) -> bool: :return: 有刪除時為 ``True`` / ``True`` when anything was deleted """ - if not self._positions or any(position <= 0 for position in self._edit_targets()): + if not self._positions: return False - - def delete(cursor: QTextCursor, position: int) -> None: - cursor.setPosition(position) - cursor.deletePreviousChar() - - self._apply_at_targets(delete, -1) + ranges = self._all_ranges(lambda position: (position - 1, position)) + if any(start < 0 for start, _end in ranges): + return False + self._replace_ranges(ranges, "") return True def delete_after(self) -> bool: @@ -200,17 +237,12 @@ def delete_after(self) -> bool: :return: 有刪除時為 ``True`` / ``True`` when anything was deleted """ limit = self._document_limit() - if not self._positions or any(position >= limit for position in self._edit_targets()): + if not self._positions: return False - - def delete(cursor: QTextCursor, position: int) -> None: - cursor.setPosition(position) - cursor.deleteChar() - - # 刪除的是游標之後的字元,游標自己不動,只有其後的位置要往前 - # The character after the caret goes, so the caret stays where it is and - # only what follows shifts back - self._apply_at_targets(delete, 0, shift_others=-1) + ranges = self._all_ranges(lambda position: (position, position + 1)) + if any(end > limit for _start, end in ranges): + return False + self._replace_ranges(ranges, "") return True def insert_newline(self) -> bool: @@ -224,19 +256,173 @@ def insert_newline(self) -> bool: def move_all(self, offset: int) -> bool: """ - 把每個額外游標左右移動 - Move every extra caret left or right. + 把每個游標左右移動 + Move every caret left or right. + + 主游標也要一起走,否則按一次方向鍵就會讓它落在別的游標後面。 + The primary caret moves too, or one press of an arrow key would leave it + behind the others. :param offset: 移動量(負數往左)/ how far to move, negative for left :return: 有移動時為 ``True`` / ``True`` when the carets moved """ if not self._positions: return False + limit = self._document_limit() + self._anchors = {} + self._positions = clamp_positions( + [position + offset for position in self._positions], limit) + self._move_primary_to(self._code_edit.textCursor().position() + offset) + return True + + def move_all_vertically(self, direction: int) -> bool: + """ + 把每個游標上下移動一行,保持各自的欄位 + Move every caret one line up or down, each keeping its column. + + 比目標行長度更靠右的游標停在該行的行尾,與一般游標的行為一致。 + A caret further right than the target line stops at that line's end, the + same as an ordinary caret does. + + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` up, ``1`` down + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + self._anchors = {} + # 沒有那一行的游標留在原地,而不是消失 + # A caret with no line to move to stays where it is rather than vanishing self._positions = clamp_positions( - [position + offset for position in self._positions], self._document_limit()) + [self._neighbour_or_here(position, direction) for position in self._positions], + self._document_limit()) + self._move_primary_to(self._neighbour_or_here( + self._code_edit.textCursor().position(), direction)) + return True + + def extend_all(self, offset: int) -> bool: + """ + 以每個游標為基準左右擴大選取範圍 + Extend every caret's selection left or right. + + :param offset: 移動量(負數往左)/ how far to move, negative for left + :return: 有擴大時為 ``True`` / ``True`` when the selections grew + """ + return self._extend(lambda position: position + offset) + + def extend_all_vertically(self, direction: int) -> bool: + """ + 以每個游標為基準上下擴大選取範圍 + Extend every caret's selection one line up or down. + + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` up, ``1`` down + :return: 有擴大時為 ``True`` / ``True`` when the selections grew + """ + return self._extend(lambda position: self._neighbour_or_here(position, direction)) + + def extend_all_to_line_edge(self, to_end: bool) -> bool: + """ + 把每個游標的選取範圍延伸到所在行的行首或行尾 + Extend every caret's selection to the start or end of its own line. + + :param to_end: ``True`` 延伸到行尾 / ``True`` for the end of the line + :return: 有擴大時為 ``True`` / ``True`` when the selections grew + """ + return self._extend(lambda position: self._line_edge(position, to_end)) + + def _extend(self, move) -> bool: + """ + 移動每個游標但保留錨點,選取範圍因此跟著長大 + Move every caret while keeping its anchor, so the selection grows with it. + + 錨點記的是選取開始的地方;已經在選取中的游標沿用原本的錨點,其餘就以目前 + 位置作為錨點。 + The anchor is where the selection began: a caret already selecting keeps + the one it has, and any other takes its current position as the anchor. + + :param move: 算出新位置的函式 / works out the new position + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + limit = self._document_limit() + anchors: dict[int, int] = {} + moved: list[int] = [] + for position in self._positions: + anchor = self._anchors.get(position, position) + landed = min(max(0, move(position)), limit) + moved.append(landed) + anchors[landed] = anchor + self._positions = clamp_positions(moved, limit) + self._anchors = { + position: anchor for position, anchor in anchors.items() + if position in self._positions + } + main = self._code_edit.textCursor() + main.setPosition(min(max(0, move(main.position())), limit), + QTextCursor.MoveMode.KeepAnchor) + self._code_edit.setTextCursor(main) self._code_edit.viewport().update() return True + def _neighbour_or_here(self, position: int, direction: int) -> int: + """上下一行的同一欄,沒有那一行時維持原位 / The same column one line away, or where it is.""" + moved = self._line_neighbour(position, direction) + return position if moved is None else moved + + def move_all_to_line_edge(self, to_end: bool) -> bool: + """ + 把每個游標移到所在行的行首或行尾 + Move every caret to the start or the end of its own line. + + :param to_end: ``True`` 移到行尾,``False`` 移到行首 / ``True`` for the end + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + self._anchors = {} + self._positions = clamp_positions( + [self._line_edge(position, to_end) for position in self._positions], + self._document_limit()) + self._move_primary_to( + self._line_edge(self._code_edit.textCursor().position(), to_end)) + return True + + def _line_neighbour(self, position: int, direction: int) -> int | None: + """ + 取得同一欄在上下一行的位置 + The position one line up or down, at the same column. + + :param position: 目前位置 / where the caret is + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` up, ``1`` down + :return: 新位置,沒有那一行時為 ``None`` / the new position, or ``None`` + """ + document = self._code_edit.document() + block = document.findBlock(position) + target = document.findBlockByNumber(block.blockNumber() + direction) + if not target.isValid(): + return None + column = position - block.position() + return target.position() + min(column, len(target.text())) + + def _line_edge(self, position: int, to_end: bool) -> int: + """ + 取得所在行的行首或行尾位置 + The position at the start or the end of a caret's line. + + :param position: 目前位置 / where the caret is + :param to_end: ``True`` 取行尾 / ``True`` for the end of the line + :return: 新位置 / the new position + """ + block = self._code_edit.document().findBlock(position) + return block.position() + (len(block.text()) if to_end else 0) + + def _move_primary_to(self, position: int) -> None: + """把主游標移到指定位置並重畫 / Move the primary caret there and repaint.""" + main = self._code_edit.textCursor() + main.setPosition(min(max(0, position), self._document_limit())) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + def select_column(self, anchor_position: int, current_position: int) -> int: """ 以矩形範圍在每一行放一個游標 diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 492a192e..f2372804 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -34,23 +34,26 @@ from je_editor.utils.indentation.indent_guides import ( guide_columns, trailing_whitespace_start ) -from je_editor.git_client.file_staging import stage_content, staged_text -from je_editor.utils.debugger.pdb_commands import breakpoint_commands, step_command +from je_editor.git_client.file_staging import ( + commit_index, stage_content, staged_text, unstage_file +) +from je_editor.utils.debugger.pdb_commands import ( + breakpoint_commands, step_command, toggle_command +) from je_editor.utils.file_diff.line_status import apply_hunk from je_editor.utils.file_diff.unified import unified_diff_text from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.macro.keystroke_macro import KeystrokeMacro from je_editor.utils.selection.surround import SURROUND_PAIRS, surround -from je_editor.utils.shortcuts.shortcut_registry import ( - WINDOW_SHORTCUTS, ShortcutRegistry -) +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind, shortcut_for +from je_editor.utils.shortcuts.shortcut_registry import WINDOW_SHORTCUTS, ShortcutRegistry from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) from je_editor.utils.navigation.location_history import LocationHistory from je_editor.utils.number_ops.number_ops import adjust_number_at, to_base from je_editor.utils.occurrence.word_occurrences import ( - find_occurrences, replace_whole_word, word_at + find_occurrences, lines_containing, replace_whole_word, word_at ) from je_editor.utils.text_cleanup.text_cleanup import trim_trailing_whitespace from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for @@ -116,6 +119,13 @@ def run(self) -> None: # markers because it spawns a subprocess _LINT_REFRESH_DELAY_MS = 900 +# 額外游標選取範圍的底色透明度;夠淡才不會蓋掉底下的文字 +# How solid an extra caret's selection wash is; faint enough to read through +_EXTRA_SELECTION_ALPHA = 70 +# 選取範圍剛好落在行尾時仍畫出的最小寬度 +# The width still drawn when a selection ends exactly at a line's end +_EMPTY_SELECTION_WIDTH = 2 + # 多重游標啟用時,這些按鍵各自對應一個整批動作 # With extra carets active, each of these keys drives one batched action _MULTI_CURSOR_KEYS = { @@ -126,6 +136,21 @@ def run(self) -> None: Qt.Key.Key_Enter: lambda manager: manager.insert_newline(), Qt.Key.Key_Left: lambda manager: manager.move_all(-1), Qt.Key.Key_Right: lambda manager: manager.move_all(1), + Qt.Key.Key_Up: lambda manager: manager.move_all_vertically(-1), + Qt.Key.Key_Down: lambda manager: manager.move_all_vertically(1), + Qt.Key.Key_Home: lambda manager: manager.move_all_to_line_edge(False), + Qt.Key.Key_End: lambda manager: manager.move_all_to_line_edge(True), +} + +# 按住 Shift 時,同樣的按鍵改為擴大每個游標的選取範圍 +# With Shift held, the same keys extend each caret's selection instead +_MULTI_CURSOR_SHIFT_KEYS = { + Qt.Key.Key_Left: lambda manager: manager.extend_all(-1), + Qt.Key.Key_Right: lambda manager: manager.extend_all(1), + Qt.Key.Key_Up: lambda manager: manager.extend_all_vertically(-1), + Qt.Key.Key_Down: lambda manager: manager.extend_all_vertically(1), + Qt.Key.Key_Home: lambda manager: manager.extend_all_to_line_edge(False), + Qt.Key.Key_End: lambda manager: manager.extend_all_to_line_edge(True), } @@ -232,6 +257,9 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # 搜尋框 (延遲建立) self.search_box = None + # 目前搜尋框在找的字串;縮圖用它標出命中的行 + # What the search box is looking for; the minimap marks the lines it hits + self._search_term = "" # 行號區域 (LineNumber 是另一個自訂類別) self.line_number: LineNumber = LineNumber(self) @@ -263,12 +291,10 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # 搜尋 (Ctrl+F)、搜尋與取代 (Ctrl+H)、跳到指定行 (Ctrl+G) # Search (Ctrl+F), search & replace (Ctrl+H) and go to line (Ctrl+G) - self.search_action = self._add_shortcut_action( - "Ctrl+F", "search", self.start_search_dialog) + self.search_action = self._add_shortcut_action("search", self.start_search_dialog) self.search_replace_action = self._add_shortcut_action( - "Ctrl+H", "search_and_replace", self.open_search_replace_dialog) - self.goto_line_action = self._add_shortcut_action( - "Ctrl+G", "go_to_line", self.go_to_line) + "search_and_replace", self.open_search_replace_dialog) + self.goto_line_action = self._add_shortcut_action("go_to_line", self.go_to_line) # 自動補全初始化 self.completer: Union[None, QCompleter] = None @@ -365,6 +391,9 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.lsp_client.hover_ready.connect(self.show_hover_text) self.lsp_client.edits_ready.connect(self.apply_server_edits) self.lsp_client.definition_ready.connect(self.go_to_definition_location) + self.lsp_client.signature_ready.connect(self.show_signature_text) + self.lsp_client.references_ready.connect(self.show_references) + self.lsp_client.code_actions_ready.connect(self.show_quick_fixes) self.start_language_server() def reset_highlighter(self) -> None: @@ -536,6 +565,7 @@ def find_next_text(self) -> None: jeditor_logger.info("CodeEditor find_next_text") if self.search_box.isVisible(): text = self.search_box.search_input.text() + self.set_search_term(text) self.find(text) def find_back_text(self) -> None: @@ -546,29 +576,91 @@ def find_back_text(self) -> None: jeditor_logger.info("CodeEditor find_back_text") if self.search_box.isVisible(): text = self.search_box.search_input.text() + self.set_search_term(text) self.find(text, QTextDocument.FindFlag.FindBackward) + def set_search_term(self, term: str) -> bool: + """ + 記下目前正在搜尋的字串 + Remember what is currently being searched for. + + 縮圖用它來標出命中的行;搜尋框關掉或字串清空時就恢復標記游標所在的字詞。 + The minimap marks the lines it hits; clearing it, or closing the search, + goes back to marking the word under the caret. + + :param term: 搜尋字串 / the string being searched for + :return: 字串有變時為 ``True`` / ``True`` when it changed + """ + term = term or "" + if term == self._search_term: + return False + self._search_term = term + minimap = getattr(self.main_window, "minimap", None) + if minimap is not None: + minimap.update() + return True + + def search_term(self) -> str: + """ + 取得目前的搜尋字串 + The string currently being searched for. + + 搜尋框關掉之後就不算還在搜尋了,因此標記會跟著消失。 + A closed search box means nothing is being searched for any more, so the + marks go with it. + + :return: 搜尋字串,沒在搜尋時為空字串 / the term, or an empty string + """ + box = self.search_box + if box is not None and not box.isVisible(): + return "" + return self._search_term + + def search_hit_lines(self) -> list[int]: + """ + 取得含有目前搜尋字串的行 + The lines holding the current search term. + + :return: 行號(0 起算)/ the 0-based line numbers + """ + return lines_containing(self.toPlainText(), self._search_term) + # ── 快捷鍵註冊 / Shortcut registration ──────────────────────── - def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: + def _add_shortcut_action(self, command: str, handler) -> QAction: """ 建立一個綁定快捷鍵的動作,並登記到本編輯器的快捷鍵表 Create an action bound to a key sequence and record it in this editor's table. + 按鍵來自共用的快捷鍵表,因此使用者改過的設定會直接生效,這裡不再各自寫死。 + The sequence comes from the shared table, so a setting the user changed + takes effect here without any of these being written down twice. + 兩個動作共用同一組按鍵時,Qt 不會挑一個執行而是兩個都不執行,因此每組按鍵 都要先登記;重複指派會留下警告紀錄,測試也會直接失敗。 Two actions sharing a sequence make Qt run neither of them, so every sequence is recorded here first: a clash is logged, and a test fails on it outright. - :param sequence: 按鍵組合 / the key sequence - :param command: 指令名稱,用於衝突訊息 / the command name, used in clash messages + 內容脈絡設為 ``WidgetWithChildrenShortcut``,動作只在這個編輯器有焦點時作用。 + Qt 比對快捷鍵時會略過看不見的 widget,所以分頁之間本來就不會互相干擾;但分割 + 檢視是同一個視窗裡兩個**同時可見**的編輯器,用視窗層級的脈絡就會讓每一組編輯器 + 快捷鍵各有兩個擁有者,於是兩個都不執行。 + The context is ``WidgetWithChildrenShortcut`` so an action only fires while + this editor has focus. Qt already skips invisible widgets when matching, so + tabs never collide; but a split view puts two editors on screen **at once**, + and with the window-level context every editor shortcut would have two + owners and none of them would fire. + + :param command: 指令名稱 / the command's name :param handler: 觸發時呼叫的函式 / what to call when it fires :return: 建立好的動作 / the action that was created """ + sequence = shortcut_for(command) action = QAction(self) action.setObjectName(command) - action.setShortcut(sequence) + bind(action, command) + action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) action.triggered.connect(handler) owner = self.shortcut_registry.register(sequence, command) if owner is not None: @@ -582,23 +674,23 @@ def _add_shortcut_actions(self, bindings) -> None: 一次註冊多組快捷鍵 Register several shortcuts at once. - :param bindings: ``(按鍵, 指令名稱, 處理函式)`` 的序列 - / a sequence of ``(key sequence, command name, handler)`` + :param bindings: ``(指令名稱, 處理函式)`` 的序列 + / a sequence of ``(command name, handler)`` """ - for sequence, command, handler in bindings: - self._add_shortcut_action(sequence, command, handler) + for command, handler in bindings: + self._add_shortcut_action(command, handler) # ── 程式碼折疊與書籤 / Code folding and bookmarks ────────────── def _register_fold_bookmark_actions(self) -> None: """註冊折疊與書籤的快捷鍵 / Register folding and bookmark shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Shift+[", "toggle_fold", self.toggle_fold_at_cursor), - ("Ctrl+Alt+[", "fold_all", self.fold_all), - ("Ctrl+Alt+]", "unfold_all", self.unfold_all), - ("Ctrl+Alt+K", "toggle_bookmark", self.toggle_bookmark), - ("Ctrl+Alt+L", "next_bookmark", self.next_bookmark), - ("Ctrl+Alt+J", "previous_bookmark", self.previous_bookmark), + ("toggle_fold", self.toggle_fold_at_cursor), + ("fold_all", self.fold_all), + ("unfold_all", self.unfold_all), + ("toggle_bookmark", self.toggle_bookmark), + ("next_bookmark", self.next_bookmark), + ("previous_bookmark", self.previous_bookmark), )) def _on_text_changed_for_features(self) -> None: @@ -644,10 +736,10 @@ def _register_diff_marker_actions(self) -> None: decrement the number under the caret. """ self._add_shortcut_actions(( - ("F7", "next_change", self.next_change), - ("Shift+F7", "previous_change", self.previous_change), - ("Ctrl+Alt+Z", "revert_change", self.revert_change_at_cursor), - ("Ctrl+Alt+B", "toggle_blame", self.toggle_blame), + ("next_change", self.next_change), + ("previous_change", self.previous_change), + ("revert_change", self.revert_change_at_cursor), + ("toggle_blame", self.toggle_blame), )) def next_change(self) -> bool: @@ -793,6 +885,42 @@ def stage_change_at_cursor(self) -> bool: staged = apply_hunk(baseline, self.toPlainText(), hunk) return stage_content(str(self.current_file), staged) + def unstage_current_file(self) -> bool: + """ + 取消這個檔案的暫存內容 + Unstage this file. + + 索引回到已提交的版本,編輯中的內容完全不動——暫存錯一段之後只能這樣收回。 + The index returns to the committed version and what is being edited is + untouched, which is the only way back from staging the wrong hunk. + + :return: 索引有變時為 ``True`` / ``True`` when the index changed + """ + if self.current_file is None: + return False + return unstage_file(str(self.current_file)) + + def commit_current_file(self) -> bool: + """ + 把已暫存的內容提交出去 + Commit what is currently staged. + + 提交的是索引裡的內容,不是編輯器裡的:逐段暫存之後,這樣才能只提交挑好的 + 那幾段。 + What is committed is the index, not the buffer: after staging hunk by + hunk, that is what makes committing only the chosen parts possible. + + :return: 有提交時為 ``True`` / ``True`` when a commit was made + """ + if self.current_file is None: + return False + message, confirmed = QInputDialog.getText( + self, language_wrapper.language_word_dict.get("git_commit_title"), + language_wrapper.language_word_dict.get("git_commit_prompt")) + if not confirmed or not message.strip(): + return False + return commit_index(str(self.current_file), message) + def staged_diff_text(self) -> str: """ 取得索引版本與編輯中內容的差異 @@ -1012,8 +1140,8 @@ def previous_bookmark(self) -> None: def _register_history_actions(self) -> None: """註冊上一步/下一步快捷鍵 / Register back/forward shortcuts.""" self._add_shortcut_actions(( - ("Alt+Left", "navigate_back", self.navigate_back), - ("Alt+Right", "navigate_forward", self.navigate_forward), + ("navigate_back", self.navigate_back), + ("navigate_forward", self.navigate_forward), )) def _record_cursor_jump(self) -> None: @@ -1468,8 +1596,8 @@ def _duplicate_selection(self, cursor: QTextCursor) -> None: def _register_smart_selection_actions(self) -> None: """註冊智慧選取快捷鍵 / Register smart selection shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Alt+Right", "expand_selection", self.expand_selection), - ("Ctrl+Alt+Left", "shrink_selection", self.shrink_selection), + ("expand_selection", self.expand_selection), + ("shrink_selection", self.shrink_selection), )) def expand_selection(self) -> None: @@ -1485,9 +1613,9 @@ def shrink_selection(self) -> None: def _register_number_actions(self) -> None: """註冊數字加減快捷鍵 / Register number increment/decrement shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Alt+Up", "increment_number", lambda: self.adjust_number(1)), - ("Ctrl+Alt+Down", "decrement_number", lambda: self.adjust_number(-1)), - ("F2", "rename_occurrences", self.rename_word_under_cursor), + ("increment_number", lambda: self.adjust_number(1)), + ("decrement_number", lambda: self.adjust_number(-1)), + ("rename_occurrences", self.rename_word_under_cursor), )) def rename_word_under_cursor(self) -> bool: @@ -1549,9 +1677,9 @@ def adjust_number(self, delta: int) -> bool: def _register_line_operation_actions(self) -> None: """註冊行操作快捷鍵 / Register line-operation shortcuts.""" self._add_shortcut_actions(( - ("Ctrl+Shift+D", "delete_line", self.delete_current_line), - ("Ctrl+Shift+J", "join_lines", self.join_selected_lines), - ("Ctrl+Alt+S", "sort_lines", self.sort_selected_lines), + ("delete_line", self.delete_current_line), + ("join_lines", self.join_selected_lines), + ("sort_lines", self.sort_selected_lines), )) def _selected_block_range(self, cursor: QTextCursor) -> tuple[int, int]: @@ -2215,11 +2343,11 @@ def _register_breakpoint_actions(self) -> None: breakpoint and continuing use Ctrl+F9 and Ctrl+F5 instead. """ self._add_shortcut_actions(( - ("Ctrl+F9", "toggle_breakpoint", self.toggle_breakpoint), - ("Ctrl+F5", "debug_continue", lambda: self.send_debugger_command("continue")), - ("F10", "debug_step_over", lambda: self.send_debugger_command("over")), - ("F11", "debug_step_into", lambda: self.send_debugger_command("into")), - ("Shift+F11", "debug_step_out", lambda: self.send_debugger_command("out")), + ("toggle_breakpoint", self.toggle_breakpoint), + ("debug_continue", lambda: self.send_debugger_command("continue")), + ("debug_step_over", lambda: self.send_debugger_command("over")), + ("debug_step_into", lambda: self.send_debugger_command("into")), + ("debug_step_out", lambda: self.send_debugger_command("out")), )) def toggle_breakpoint(self) -> bool: @@ -2227,12 +2355,39 @@ def toggle_breakpoint(self) -> bool: 切換游標所在行的中斷點 Add or remove a breakpoint on the caret's line. + 除錯器正在跑的話,這次改動也會送過去,中斷點因此在除錯途中也能加減,而不是 + 只能在啟動前決定好。 + With the debugger running the change is sent across as well, so breakpoints + can be added and removed part-way through a session rather than only being + settled before it starts. + :return: 切換後是否有中斷點 / whether the line now has one """ - result = self.breakpoint_manager.toggle(self.textCursor().blockNumber()) + line = self.textCursor().blockNumber() + result = self.breakpoint_manager.toggle(line) self.line_number.update() + self.send_breakpoint_change(line, result) return result + def send_breakpoint_change(self, line: int, is_set: bool) -> bool: + """ + 把一行的中斷點改動送給正在執行的除錯器 + Send one line's breakpoint change to the running debugger. + + pdb 只在提示符時讀指令,所以程式還在跑的話這個指令會等到下次停下來才生效 + ——那正是它該生效的時機。 + pdb only reads commands at its prompt, so while the program is running the + command waits for the next stop, which is exactly when it should apply. + + :param line: 以 0 起算的行號 / the 0-based line number + :param is_set: 該行現在是否有中斷點 / whether the line now has one + :return: 有送出時為 ``True`` / ``True`` when the command was sent + """ + if self.current_file is None: + return False + return self._write_debugger_line( + toggle_command(str(self.current_file), line + 1, is_set)) + def _debugger_process(self): """取得正在執行的除錯器程序 / The debugger process that is running, if any.""" manager = getattr(self.main_window, "exec_python_debugger", None) @@ -2295,9 +2450,9 @@ def _register_macro_actions(self) -> None: Playback uses Ctrl+Shift+G because Ctrl+Shift+P installs packages with pip. """ self._add_shortcut_actions(( - ("Ctrl+Shift+R", "record_macro", self.toggle_macro_recording), - ("Ctrl+Shift+G", "play_macro", self.play_macro), - ("Ctrl+Alt+E", "recent_locations", self.show_recent_locations), + ("record_macro", self.toggle_macro_recording), + ("play_macro", self.play_macro), + ("recent_locations", self.show_recent_locations), )) def recent_location_labels(self) -> list[str]: @@ -2403,11 +2558,11 @@ def _register_multi_cursor_actions(self) -> None: always duplicated the current line. """ self._add_shortcut_actions(( - ("Ctrl+Shift+L", "cursors_on_selected_lines", self.add_cursors_to_selected_lines), - ("Ctrl+Shift+Escape", "clear_extra_cursors", self.clear_extra_cursors), - ("Ctrl+Alt+Shift+Up", "cursor_above", self.add_cursor_above), - ("Ctrl+Alt+Shift+Down", "cursor_below", self.add_cursor_below), - ("Ctrl+Alt+N", "cursor_at_next_occurrence", self.add_cursor_at_next_occurrence), + ("cursors_on_selected_lines", self.add_cursors_to_selected_lines), + ("clear_extra_cursors", self.clear_extra_cursors), + ("cursor_above", self.add_cursor_above), + ("cursor_below", self.add_cursor_below), + ("cursor_at_next_occurrence", self.add_cursor_at_next_occurrence), )) def add_cursors_to_selected_lines(self) -> int: @@ -2502,9 +2657,11 @@ def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: super().mouseReleaseEvent(event) def _paint_extra_cursors(self) -> None: - """畫出每個額外游標 / Draw each extra caret.""" + """畫出每個額外游標與它選取的範圍 / Draw each extra caret and what it selects.""" painter = QPainter(self.viewport()) - painter.setPen(actually_color_dict.get("extra_cursor_color")) + colour = actually_color_dict.get("extra_cursor_color") + self._paint_extra_selections(painter, colour) + painter.setPen(colour) cursor = QTextCursor(self.document()) for position in self.multi_cursor_manager.positions(): cursor.setPosition(min(position, self.document().characterCount() - 1)) @@ -2512,6 +2669,48 @@ def _paint_extra_cursors(self) -> None: painter.drawLine(rect.left(), rect.top(), rect.left(), rect.bottom()) painter.end() + def _paint_extra_selections(self, painter: QPainter, colour) -> None: + """ + 把每個額外游標選取的範圍畫成淡色底 + Wash each extra caret's selection in a faint version of the caret colour. + + 逐行畫:一段選取可能跨好幾行,每一行的矩形都要各自算。 + Line by line: a selection can span several, and each needs its own rect. + """ + tint = QColor(colour) + tint.setAlpha(_EXTRA_SELECTION_ALPHA) + cursor = QTextCursor(self.document()) + for start, end in self.multi_cursor_manager.selections(): + for line_start, line_end in self._selection_rows(start, end): + cursor.setPosition(line_start) + left = self.cursorRect(cursor) + cursor.setPosition(line_end) + right = self.cursorRect(cursor) + painter.fillRect( + left.left(), left.top(), + max(right.left() - left.left(), _EMPTY_SELECTION_WIDTH), + left.height(), tint) + + def _selection_rows(self, start: int, end: int) -> list[tuple[int, int]]: + """ + 把一段選取切成每一行各自的範圍 + Split a selection into one range per line it covers. + + :param start: 選取的起點 / where the selection starts + :param end: 選取的終點 / where it ends + :return: 每一行的 ``(起, 訖)`` / the ``(start, end)`` of each line's part + """ + document = self.document() + rows: list[tuple[int, int]] = [] + block = document.findBlock(start) + while block.isValid() and block.position() < end: + row_start = max(start, block.position()) + row_end = min(end, block.position() + len(block.text())) + if row_end >= row_start: + rows.append((row_start, row_end)) + block = block.next() + return rows + def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: """ 把輸入與退格套用到每個額外游標 @@ -2523,6 +2722,10 @@ def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: if not self.multi_cursor_manager.active: return False key = event.key() + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + extend = _MULTI_CURSOR_SHIFT_KEYS.get(key) + if extend is not None: + return extend(self.multi_cursor_manager) handler = _MULTI_CURSOR_KEYS.get(key) if handler is not None: return handler(self.multi_cursor_manager) @@ -2662,11 +2865,19 @@ def build_context_menu(self) -> QMenu: ("context_menu_go_to_definition", self.go_to_definition, self.lsp_client.running), ("context_menu_hover", self.request_hover, self.lsp_client.running), + ("context_menu_find_references", self.find_references, + self.lsp_client.running), + ("context_menu_quick_fix", self.request_quick_fixes, + self.lsp_client.running), ("context_menu_rename_symbol", self.rename_symbol, True), ("context_menu_format_document", self.format_with_language_server, self.lsp_client.running), ("context_menu_stage_hunk", self.stage_change_at_cursor, self.diff_marker_manager.has_baseline), + ("context_menu_unstage_file", self.unstage_current_file, + self.current_file is not None), + ("context_menu_commit_file", self.commit_current_file, + self.current_file is not None), ("context_menu_revert_hunk", self.revert_change_at_cursor, self.diff_marker_manager.has_baseline), ): @@ -2801,6 +3012,94 @@ def go_to_definition(self) -> bool: return self.lsp_client.request_definition( cursor.blockNumber(), cursor.positionInBlock()) + def request_signature_help(self) -> bool: + """ + 請語言伺服器說明正在輸入的呼叫 + Ask the language server about the call being typed. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_signature_help( + cursor.blockNumber(), cursor.positionInBlock()) + + def show_signature_text(self, text: str) -> None: + """ + 顯示簽章說明 + Show the signature the server described. + + :param text: 簽章文字 / the signature + """ + self.setToolTip(text) + + def find_references(self) -> bool: + """ + 請語言伺服器找出游標所在符號的所有參照 + Ask the language server where the symbol under the caret is referred to. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_references( + cursor.blockNumber(), cursor.positionInBlock()) + + def show_references(self, locations: list) -> bool: + """ + 列出參照位置,選一個就跳過去 + List the references and jump to the chosen one. + + :param locations: 參照位置 / the locations found + :return: 有跳轉時為 ``True`` / ``True`` when something was opened + """ + if not locations: + return False + # 位置的行號已經是 1 起算的,與跳轉用的一致 + # The locations already count lines from one, as the jump does + labels = [f"{Path(item['path']).name}:{item['line']}" for item in locations] + chosen, confirmed = QInputDialog.getItem( + self, language_wrapper.language_word_dict.get("lsp_references_title"), + language_wrapper.language_word_dict.get("lsp_references_prompt"), + labels, 0, False) + if not confirmed or not chosen: + return False + return self.go_to_definition_location(locations[labels.index(chosen)]) + + def request_quick_fixes(self) -> bool: + """ + 請語言伺服器提出游標所在位置的修正 + Ask the language server what can be fixed at the caret. + + 把該行的診斷一併送過去,伺服器才知道要針對哪個問題提修正。 + The line's diagnostics go with it, since that is what tells the server + which problem to offer a fix for. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + line = cursor.blockNumber() + return self.lsp_client.request_code_actions( + line, cursor.positionInBlock(), self.lsp_client.diagnostics_on_line(line)) + + def show_quick_fixes(self, actions: list) -> bool: + """ + 列出可套用的修正,選一個就套用 + List the available fixes and apply the chosen one. + + :param actions: 伺服器提供的修正 / the fixes the server offered + :return: 有套用時為 ``True`` / ``True`` when a fix was applied + """ + if not actions: + return False + titles = [action["title"] for action in actions] + chosen, confirmed = QInputDialog.getItem( + self, language_wrapper.language_word_dict.get("lsp_quick_fix_title"), + language_wrapper.language_word_dict.get("lsp_quick_fix_prompt"), + titles, 0, False) + if not confirmed or not chosen: + return False + self.apply_server_edits(actions[titles.index(chosen)]["edits"]) + return True + def closeEvent(self, event: QtGui.QCloseEvent) -> None: """ 關閉前停掉所有背景工作 diff --git a/je_editor/pyside_ui/code/snippets/snippet_manager.py b/je_editor/pyside_ui/code/snippets/snippet_manager.py index b636c2b2..3c5e4ef8 100644 --- a/je_editor/pyside_ui/code/snippets/snippet_manager.py +++ b/je_editor/pyside_ui/code/snippets/snippet_manager.py @@ -12,10 +12,13 @@ import json from pathlib import Path +from PySide6.QtCore import QObject from PySide6.QtGui import QTextCursor from je_editor.utils.logging.loggin_instance import jeditor_logger -from je_editor.utils.snippets.snippet_expand import SnippetStop, expand_snippet, merge_snippets +from je_editor.utils.snippets.snippet_expand import ( + SnippetStop, expand_snippet, merge_snippets, positions_after_mirroring, shift_mirrors +) # 使用者片段檔名(放在設定資料夾內)/ The user snippet file, kept beside the settings SNIPPET_FILE_NAME = "snippets.json" @@ -75,20 +78,39 @@ def load_snippets(path: Path | None = None, suffix: str = "") -> dict[str, str]: return merge_snippets(stored, suffix) -class SnippetManager: +class SnippetManager(QObject): """ 管理一個編輯器的片段展開狀態 Track one editor's snippet expansion. + + 是編輯器的 QObject 子物件:要監看文件的變更訊號,接收者必須是 QObject,編輯器 + 被銷毀時 Qt 才會自動斷開。 + A QObject child of the editor: watching the document's change signal needs a + receiver Qt can disconnect when the editor goes away. """ def __init__(self, code_edit) -> None: """ :param code_edit: 要展開片段的編輯器 / the editor snippets expand into """ + super().__init__(code_edit) self._code_edit = code_edit self._snippets = load_snippets(suffix=self._suffix()) # 尚未走訪的定位點(文件中的絕對位置)/ Stops not yet visited, as document positions self._pending: list[SnippetStop] = [] + # 目前定位點與它的複本;記成位置與長度而不是 QTextCursor,因為整段內容被 + # 取代時游標的選取範圍會塌掉,而使用者輸入第一個字就正是在取代整段預設值 + # The current stop and its mirrors, kept as positions and a length rather + # than cursors: a cursor's selection collapses when its whole content is + # replaced, which is exactly what the first keystroke over a default does + self._stop_start = 0 + self._stop_length = 0 + self._mirror_starts: list[int] = [] + # 每個複本目前的長度;改寫後才會變 / Each mirror's current length + self._mirror_length = 0 + # 自己在改複本時不要再處理一次變更 / Ignore the changes this makes itself + self._mirroring = False + code_edit.document().contentsChange.connect(self._on_contents_change) def snippets(self) -> dict[str, str]: """取得目前可用的片段 / The snippets currently available.""" @@ -148,8 +170,13 @@ def expand_at_cursor(self) -> bool: start = cursor.selectionStart() cursor.insertText(text) cursor.endEditBlock() + # 展開得到的是片段內的相對位置,這裡換算成文件中的絕對位置 + # Expansion gives offsets inside the snippet; these are document positions self._pending = [ - SnippetStop(position=start + stop.position, length=stop.length) + SnippetStop( + position=start + stop.position, + length=stop.length, + mirrors=tuple(start + mirror for mirror in stop.mirrors)) for stop in stops ] self.next_stop() @@ -166,15 +193,88 @@ def next_stop(self) -> bool: return False stop = self._pending.pop(0) cursor = self._code_edit.textCursor() - cursor.setPosition(min(stop.position, self._code_edit.document().characterCount() - 1)) + cursor.setPosition(self._clamp(stop.position)) if stop.length: cursor.setPosition( - min(stop.position + stop.length, - self._code_edit.document().characterCount() - 1), + self._clamp(stop.position + stop.length), QTextCursor.MoveMode.KeepAnchor) self._code_edit.setTextCursor(cursor) + self._track_mirrors(stop) return True + def _clamp(self, position: int) -> int: + """把位置限制在文件範圍內 / Keep a position inside the document.""" + return max(0, min(position, self._code_edit.document().characterCount() - 1)) + + def _track_mirrors(self, stop: SnippetStop) -> None: + """ + 記住這個定位點與它的複本,準備讓它們一起改 + Remember this stop and its mirrors so they can be kept in step. + + :param stop: 剛跳到的定位點 / the stop just moved to + """ + self._stop_start = stop.position + self._stop_length = stop.length + self._mirror_length = stop.length + self._mirror_starts = sorted(stop.mirrors) + + def _on_contents_change(self, position: int, removed: int, added: int) -> None: + """ + 文件變更時把目前定位點的內容複製到每個複本 + Copy the current stop's text into each mirror whenever the document changes. + + 同一個編號在片段裡出現好幾次時,使用者只編輯第一個,其餘要跟著變;否則 + ``${1:name}`` 用兩次就得手動改兩遍。 + Where a number appears several times in a snippet the user only edits the + first, and the rest have to follow; otherwise using ``${1:name}`` twice + means typing the same thing twice. + """ + if self._mirroring or not self._mirror_starts: + return + if not self._stop_start <= position <= self._stop_start + self._stop_length: + # 在別處輸入代表使用者已經離開這個定位點,複本不該再跟著動 + # Typing elsewhere means the user has moved on, and the mirrors with them + return + delta = added - removed + self._stop_length = max(0, self._stop_length + delta) + self._mirror_starts = shift_mirrors(self._mirror_starts, position, delta) + self._write_mirrors() + + def _write_mirrors(self) -> None: + """ + 把定位點目前的內容寫進每個複本 + Write the stop's current text into every mirror. + + 由後往前改寫,前面的位置才不會因為後面的改寫而失效;改完再算出各自的新位置。 + The rewrite runs back to front so an earlier position is not invalidated by + a later one, and the new positions are worked out afterwards. + """ + text = self._text_between(self._stop_start, self._stop_start + self._stop_length) + old_length = self._mirror_length + cursor = QTextCursor(self._code_edit.document()) + self._mirroring = True + try: + cursor.beginEditBlock() + for start in sorted(self._mirror_starts, reverse=True): + cursor.setPosition(self._clamp(start)) + cursor.setPosition(self._clamp(start + old_length), + QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(text) + cursor.endEditBlock() + finally: + self._mirroring = False + self._stop_start, self._mirror_starts = positions_after_mirroring( + self._stop_start, self._mirror_starts, len(text) - old_length) + self._mirror_length = len(text) + + def _text_between(self, start: int, end: int) -> str: + """取得文件中一段範圍的文字 / The document text between two positions.""" + cursor = QTextCursor(self._code_edit.document()) + cursor.setPosition(self._clamp(start)) + cursor.setPosition(self._clamp(end), QTextCursor.MoveMode.KeepAnchor) + return cursor.selectedText() + def clear_stops(self) -> None: """放棄剩下的定位點 / Give up on the remaining stops.""" self._pending = [] + self._mirror_starts = [] diff --git a/je_editor/pyside_ui/code/syntax/generic_syntax.py b/je_editor/pyside_ui/code/syntax/generic_syntax.py index d9af1192..62d4f415 100644 --- a/je_editor/pyside_ui/code/syntax/generic_syntax.py +++ b/je_editor/pyside_ui/code/syntax/generic_syntax.py @@ -22,6 +22,29 @@ _INSIDE_BLOCK_COMMENT = 1 +def keyword_pattern(keywords: tuple[str, ...]) -> str: + """ + 把所有關鍵字組成一個交替式樣 + Build one alternation covering every keyword. + + 每個關鍵字各一條規則的話,每一行都要重跑幾十次比對;併成一條之後只掃一次, + 大檔案上色因此快上一個數量級。 + A pattern per keyword means running dozens of matches over every line; one + alternation scans it once, which is an order of magnitude off the cost of + highlighting a large file. + + 長的關鍵字排在前面,交替式才不會先被短的吃掉;字界本身也擋得住,但排序讓這件 + 事不必依賴細節。 + Longer keywords come first so a short one cannot claim the match. The word + boundaries already prevent that, but the ordering means not relying on it. + + :param keywords: 該語言的關鍵字 / the language's keywords + :return: 一條正規表示式 / a single regular expression + """ + ordered = sorted(set(keywords), key=lambda word: (-len(word), word)) + return r"\b(?:" + "|".join(re.escape(word) for word in ordered) + r")\b" + + def _format_for(colour_key: str) -> QTextCharFormat: """建立指定顏色的格式 / Build a format in the given colour.""" text_format = QTextCharFormat() @@ -43,10 +66,10 @@ def __init__(self, document: QTextDocument, rules: LanguageRules) -> None: super().__init__(document) self._rules = rules self._patterns: list[tuple[QRegularExpression, QTextCharFormat]] = [] - keyword_format = _format_for("syntax_keyword_color") - for keyword in rules.keywords: + if rules.keywords: self._patterns.append( - (QRegularExpression(rf"\b{re.escape(keyword)}\b"), keyword_format)) + (QRegularExpression(keyword_pattern(rules.keywords)), + _format_for("syntax_keyword_color"))) number_format = _format_for("syntax_number_color") self._patterns.append((QRegularExpression(r"\b\d+(\.\d+)?\b"), number_format)) string_format = _format_for("syntax_string_color") diff --git a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py index 15fe6dfe..0006e80c 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py @@ -120,6 +120,10 @@ def choose_file_get_save_file_path(parent_qt_instance: EditorMain) -> bool: widget.code_save_thread.file = file_path widget.code_save_thread.editor = widget.code_edit + # 告訴語言伺服器檔案存了:有些檢查只在存檔後才跑 + # Tell the language server the file was saved: some checks only run then + widget.code_edit.lsp_client.did_save(widget.code_edit.toPlainText()) + # 更新分頁標題並清除未儲存標記 / Update tab title and clear unsaved marker widget.rename_self_tab() widget.mark_saved() diff --git a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py index 2329bde1..0d954eaf 100644 --- a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py +++ b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py @@ -240,6 +240,8 @@ def _search_in_current_file(self, pattern: str) -> None: """在目前檔案中搜尋 / Search in current file""" self.result_tree.clear() editor = self.editor_widget.code_edit + # 讓縮圖標出命中的行 / So the minimap marks the lines it hits + editor.set_search_term(pattern) text = editor.toPlainText() case = self.chk_case.isChecked() use_regex = self.chk_regex.isChecked() @@ -586,8 +588,11 @@ def _get_project_root(self) -> str: return os.getcwd() def closeEvent(self, event: QCloseEvent) -> None: - """關閉對話框時停止搜尋執行緒 / Stop search worker on close""" + """關閉對話框時停止搜尋執行緒並清掉縮圖標記 / Stop the worker and clear the marks""" if self._worker and self._worker.isRunning(): self._worker.stop() self._worker.wait() + editor = getattr(self.editor_widget, "code_edit", None) + if editor is not None: + editor.set_search_term("") super().closeEvent(event) diff --git a/je_editor/pyside_ui/dialog/shortcut_dialog/__init__.py b/je_editor/pyside_ui/dialog/shortcut_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py b/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py new file mode 100644 index 00000000..b093a70b --- /dev/null +++ b/je_editor/pyside_ui/dialog/shortcut_dialog/shortcut_settings_dialog.py @@ -0,0 +1,179 @@ +""" +編輯快捷鍵的對話框 +A dialog for changing the keyboard shortcuts. + +快捷鍵原本散落在各個選單與編輯器裡各自寫死,衝突只能等到按下去沒反應才發現。 +現在全部集中在一份表裡,這個對話框就是那份表的編輯介面:改完立刻檢查有沒有兩個 +指令搶同一組按鍵,存檔後新開的分頁就照新設定走。 +The shortcuts used to be written down separately in each menu and in the editor, +so a clash only showed up as a key that did nothing. They now live in one table, +and this is its editor: a change is checked for two commands claiming the same +keys, and a save applies to newly opened tabs. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QKeySequenceEdit, QLabel, QPushButton, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget +) + +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import reload_bound_shortcuts +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper +from je_editor.utils.shortcuts.shortcut_registry import ( + DEFAULT_SHORTCUTS, EDITOR_SHORTCUTS, clean_overrides, effective_shortcuts, + find_conflicts +) + +# 樹狀清單的欄位 / The columns in the tree +COLUMN_COMMAND = 0 +COLUMN_SEQUENCE = 1 +# 指令欄的預設寬度 / Default width of the command column +COMMAND_COLUMN_WIDTH = 260 + + +def command_label(command: str) -> str: + """ + 取得指令的顯示名稱 + The name a command is shown under. + + 有翻譯就用翻譯,沒有的話把指令名稱本身整理成看得懂的樣子,這樣新增指令時不會 + 因為忘了加翻譯就變成一列空白。 + A translation is used when there is one, and otherwise the command's own name + is tidied into something readable, so a newly added command never shows up as + an empty row because its translation was forgotten. + + :param command: 指令名稱 / the command's name + :return: 顯示名稱 / the label to show + """ + translated = language_wrapper.language_word_dict.get(f"shortcut_{command}") + return translated or command.replace("_", " ").capitalize() + + +class ShortcutSettingsDialog(QDialog): + """ + 列出所有指令並讓使用者改按鍵 + List every command and let the user change its keys. + """ + + def __init__(self, main_window=None, parent: QWidget | None = None) -> None: + """ + :param main_window: 儲存後要通知的主視窗 / the window told about a save + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + word = language_wrapper.language_word_dict + self._main_window = main_window + self.setWindowTitle(word.get("shortcut_settings_title")) + self._editors: dict[str, QKeySequenceEdit] = {} + + self.tree = QTreeWidget() + self.tree.setColumnCount(2) + self.tree.setHeaderLabels([ + word.get("shortcut_settings_col_command"), + word.get("shortcut_settings_col_keys"), + ]) + self.tree.setColumnWidth(COLUMN_COMMAND, COMMAND_COLUMN_WIDTH) + self.tree.setRootIsDecorated(False) + + self.status_label = QLabel("") + self.save_button = QPushButton(word.get("shortcut_settings_save")) + self.save_button.clicked.connect(self.save) + self.reset_button = QPushButton(word.get("shortcut_settings_reset")) + self.reset_button.clicked.connect(self.reset_to_defaults) + + buttons = QHBoxLayout() + buttons.addWidget(self.save_button) + buttons.addWidget(self.reset_button) + buttons.addWidget(self.status_label) + buttons.addStretch() + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(word.get("shortcut_settings_hint"))) + layout.addWidget(self.tree) + layout.addLayout(buttons) + self.setLayout(layout) + + self._fill(effective_shortcuts(user_setting_dict.get("shortcuts"))) + + def _fill(self, shortcuts: dict[str, str]) -> None: + """依目前的設定重建清單 / Rebuild the list from the given shortcuts.""" + self.tree.clear() + self._editors = {} + # 編輯器的指令排在後面,選單與工具列在前,與使用者尋找的順序一致 + # Menu and toolbar commands come first and the editor's after, which is + # the order someone looks for them in + for command in sorted(DEFAULT_SHORTCUTS, key=lambda name: (name in EDITOR_SHORTCUTS, name)): + row = QTreeWidgetItem([command_label(command), ""]) + row.setData(COLUMN_COMMAND, Qt.ItemDataRole.UserRole, command) + self.tree.addTopLevelItem(row) + editor = QKeySequenceEdit(QKeySequence(shortcuts.get(command, ""))) + editor.keySequenceChanged.connect(self._check_conflicts) + self.tree.setItemWidget(row, COLUMN_SEQUENCE, editor) + self._editors[command] = editor + self._check_conflicts() + + def current_shortcuts(self) -> dict[str, str]: + """ + 取得畫面上目前的按鍵設定 + The shortcuts as the dialog currently shows them. + + :return: 指令對應按鍵 / command mapped to sequence + """ + return { + command: editor.keySequence().toString() + for command, editor in self._editors.items() + } + + def conflicts(self) -> list[tuple[str, list[str]]]: + """ + 取得目前設定中重複的按鍵 + The sequences currently claimed by more than one command. + + :return: ``(按鍵, 指令清單)`` / ``(sequence, commands)`` + """ + return find_conflicts(self.current_shortcuts()) + + def _check_conflicts(self) -> None: + """把衝突狀況顯示出來 / Say whether anything currently clashes.""" + word = language_wrapper.language_word_dict + clashes = self.conflicts() + if not clashes: + self.status_label.setText("") + self.save_button.setEnabled(True) + return + sequence, commands = clashes[0] + self.status_label.setText(word.get("shortcut_settings_conflict").format( + keys=sequence, commands=", ".join(command_label(name) for name in commands))) + # 有衝突就存不了:存下去等於讓兩個功能一起失效 + # A clash cannot be saved: doing so would disable both features at once + self.save_button.setEnabled(False) + + def reset_to_defaults(self) -> None: + """把所有按鍵改回預設值 / Put every shortcut back to its default.""" + self._fill(dict(DEFAULT_SHORTCUTS)) + + def save(self) -> bool: + """ + 儲存設定 + Store the shortcuts. + + 只記下與預設不同的項目,設定檔因此不會被幾十個沒改過的值塞滿,日後改動預設 + 值時使用者也能跟著更新。 + Only what differs from a default is recorded, so the settings file does not + fill with dozens of untouched values and a later change to a default still + reaches the user. + + :return: 有存起來時為 ``True``;有衝突時為 ``False`` + ``True`` when stored, and ``False`` while something clashes + """ + if self.conflicts(): + return False + user_setting_dict["shortcuts"] = clean_overrides(self.current_shortcuts()) + # 立刻套用到已經建好的選單、工具列與分頁 + # Reach the menus, toolbar and tabs that are already built + reload_bound_shortcuts() + self.accept() + return True diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 1cc8023b..1e6b0c2b 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -25,9 +25,9 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.set_menu_bar import set_menu_bar from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + apply_theme_colors, write_user_color_setting, read_user_color_setting, - update_actually_color_dict, actually_color_dict ) from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import ( @@ -38,6 +38,9 @@ from je_editor.pyside_ui.main_ui.system_tray.extend_system_tray import ExtendSystemTray from je_editor.utils.file.open.open_file import read_file from je_editor.utils.session.editor_state import editor_state, restore_editor_state +from je_editor.utils.status.status_text import ( + PLAIN_TEXT, cursor_position, encoding_name, language_name, line_ending_display +) from je_editor.utils.session.open_files_session import ( SESSION_SETTING_KEY, SESSION_STATE_KEY, @@ -169,11 +172,13 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar build_toolbar(self) - # 設定狀態列 (行/列位置、編碼、行尾) - # Setup status bar (line/column, encoding, line ending) + # 設定狀態列 (語言、行尾、編碼、行/列位置) + # Setup status bar (language, line ending, encoding, line/column) + self._language_label = QLabel(PLAIN_TEXT) self._line_ending_label = QLabel("LF") self._encoding_label = QLabel("UTF-8") self._cursor_pos_label = QLabel("Ln 1, Col 1") + self.statusBar().addPermanentWidget(self._language_label) self.statusBar().addPermanentWidget(self._line_ending_label) self.statusBar().addPermanentWidget(self._encoding_label) self.statusBar().addPermanentWidget(self._cursor_pos_label) @@ -363,9 +368,12 @@ def startup_setting(self) -> None: self._restore_open_files_session() app = QApplication.instance() + style_name = user_setting_dict.get("ui_style", "dark_amber.xml") if app is not None: - self.apply_stylesheet(app, user_setting_dict.get("ui_style", "dark_amber.xml")) - update_actually_color_dict() + self.apply_stylesheet(app, style_name) + # 顏色跟著樣式走:淺色樣式要用淺色底調出來的那一組 + # The colours follow the style: a light one needs the light set + apply_theme_colors(style_name) def _open_file_paths(self) -> list[str]: """取得所有分頁目前開啟的檔案 / Every tab's currently open file.""" @@ -476,9 +484,7 @@ def _on_tab_changed(self, index: int) -> None: if isinstance(widget, EditorWidget): widget.code_edit.cursorPositionChanged.connect(self._update_cursor_pos) self._prev_editor_widget = widget - self._update_cursor_pos() - self._update_encoding_label() - self._update_line_ending_label(widget) + self.refresh_status_bar() def _update_cursor_pos(self) -> None: """ @@ -488,37 +494,35 @@ def _update_cursor_pos(self) -> None: widget = self.tab_widget.currentWidget() if isinstance(widget, EditorWidget): cursor = widget.code_edit.textCursor() - line = cursor.blockNumber() + 1 - col = cursor.positionInBlock() + 1 - self._cursor_pos_label.setText(f"Ln {line}, Col {col}") + self._cursor_pos_label.setText(cursor_position( + cursor.blockNumber() + 1, cursor.positionInBlock() + 1)) - def _update_encoding_label(self) -> None: - """ - 更新狀態列的編碼顯示 - Update status bar encoding display + def refresh_status_bar(self) -> None: """ - encoding = user_setting_dict.get("encoding", "utf-8").upper() - self._encoding_label.setText(encoding) - - def _update_line_ending_label(self, widget: EditorWidget) -> None: + 依目前分頁重畫狀態列 + Redraw the status bar from the current tab. + + 顯示的是這個分頁記憶中的狀態,而不是全域設定或磁碟上的內容:使用者從選單 + 改過編碼或行尾之後,狀態列要跟著改,而不是繼續顯示檔案原本的樣子。改完 + 設定的地方要呼叫這個方法。 + What it shows is the tab's own state, not the global setting or what is on + disk: after the user changes the encoding or line ending from the menu the + status bar has to follow, rather than keep describing the file as it was + opened. Whatever changes those settings calls this. """ - 偵測並更新行尾格式 (CRLF/LF/CR) - Detect and update line ending format - """ - if widget.current_file is not None: - try: - with open(widget.current_file, "rb") as f: - raw = f.read(8192) - if b"\r\n" in raw: - self._line_ending_label.setText("CRLF") - elif b"\r" in raw: - self._line_ending_label.setText("CR") - else: - self._line_ending_label.setText("LF") - except (OSError, TypeError): - self._line_ending_label.setText("LF") - else: - self._line_ending_label.setText("LF") + widget = self.tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + self._language_label.setText(PLAIN_TEXT) + self._line_ending_label.setText(line_ending_display(None)) + self._encoding_label.setText(encoding_name(None)) + self._cursor_pos_label.setText(cursor_position(1, 1)) + return + self._language_label.setText(language_name(widget.current_file)) + self._line_ending_label.setText( + line_ending_display(getattr(widget, "line_ending", None))) + self._encoding_label.setText( + encoding_name(getattr(widget, "file_encoding", None))) + self._update_cursor_pos() def _periodic_save_settings(self) -> None: """ diff --git a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py index ea1fc95f..d1ce07a2 100644 --- a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py @@ -3,13 +3,14 @@ from typing import TYPE_CHECKING # 僅在型別檢查時使用,避免循環匯入 / Used only for type checking to avoid circular imports from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget # 編輯器分頁元件 / Editor tab widget +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger # 專案內的日誌紀錄器 / Project logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 多語系支援 / Multi-language wrapper if TYPE_CHECKING: from je_editor.pyside_ui.main_ui.main_editor import EditorMain # 僅在型別檢查時匯入 / Import only for type checking -from PySide6.QtGui import QAction, QKeySequence # Qt 動作與快捷鍵 / Qt actions and shortcuts +from PySide6.QtGui import QAction # Qt 動作與快捷鍵 / Qt actions and shortcuts from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 使用者設定 / User settings from je_editor.utils.format_code.yapf_format import format_python_source # 格式化邏輯 / Formatting logic @@ -37,8 +38,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 1. Yapf Python 程式碼格式化 / Yapf Python code reformat === ui_we_want_to_set.check_menu.yapf_check_python_action = QAction( language_wrapper.language_word_dict.get("yapf_reformat_label")) - ui_we_want_to_set.check_menu.yapf_check_python_action.setShortcut( - QKeySequence("Ctrl+Shift+Y")) # 設定快捷鍵 / Set shortcut + bind(ui_we_want_to_set.check_menu.yapf_check_python_action, "yapf_reformat") # 設定快捷鍵 / Set shortcut ui_we_want_to_set.check_menu.yapf_check_python_action.triggered.connect( lambda: yapf_check_python_code(ui_we_want_to_set) ) @@ -47,7 +47,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 2. JSON 重新格式化 / Reformat JSON === ui_we_want_to_set.check_menu.reformat_json_action = QAction( language_wrapper.language_word_dict.get("reformat_json_label")) - ui_we_want_to_set.check_menu.reformat_json_action.setShortcut("Ctrl+j") + bind(ui_we_want_to_set.check_menu.reformat_json_action, "reformat_json") ui_we_want_to_set.check_menu.reformat_json_action.triggered.connect( lambda: reformat_json_text(ui_we_want_to_set) ) @@ -56,7 +56,7 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: # === 3. Python 格式檢查 / Python format check === ui_we_want_to_set.check_menu.check_python_format = QAction( language_wrapper.language_word_dict.get("python_format_checker")) - ui_we_want_to_set.check_menu.check_python_format.setShortcut("Ctrl+Alt+p") + bind(ui_we_want_to_set.check_menu.check_python_format, "check_python_format") ui_we_want_to_set.check_menu.check_python_format.triggered.connect( lambda: check_python_format(ui_we_want_to_set) ) diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index eb045160..62ed2f7d 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -8,6 +8,7 @@ # 匯入使用者設定字典,用來保存 UI 設定 # Import user settings dictionary for saving UI preferences +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入 Python 編碼清單 (例如 utf-8, gbk 等) # Import list of Python encodings (e.g., utf-8, gbk, etc.) @@ -59,7 +60,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # New File action ui_we_want_to_set.file_menu.new_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_new_file_label")) - ui_we_want_to_set.file_menu.new_file_action.setShortcut("Ctrl+n") + bind(ui_we_want_to_set.file_menu.new_file_action, "new_file") ui_we_want_to_set.file_menu.new_file_action.triggered.connect( lambda: show_create_file_dialog(ui_we_want_to_set) ) @@ -69,7 +70,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Open File action ui_we_want_to_set.file_menu.open_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_open_file_label")) - ui_we_want_to_set.file_menu.open_file_action.setShortcut("Ctrl+o") + bind(ui_we_want_to_set.file_menu.open_file_action, "open_file") ui_we_want_to_set.file_menu.open_file_action.triggered.connect( lambda: choose_file_get_open_file_path(parent_qt_instance=ui_we_want_to_set) ) @@ -79,7 +80,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Open Folder action ui_we_want_to_set.file_menu.open_folder_action = QAction( language_wrapper.language_word_dict.get("file_menu_open_folder_label")) - ui_we_want_to_set.file_menu.open_folder_action.setShortcut("Ctrl+K") + bind(ui_we_want_to_set.file_menu.open_folder_action, "open_folder") ui_we_want_to_set.file_menu.open_folder_action.triggered.connect( lambda: choose_dir_get_dir_path(parent_qt_instance=ui_we_want_to_set) ) @@ -89,7 +90,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # Save File action ui_we_want_to_set.file_menu.save_file_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_file_label")) - ui_we_want_to_set.file_menu.save_file_action.setShortcut("Ctrl+s") + bind(ui_we_want_to_set.file_menu.save_file_action, "save_file") ui_we_want_to_set.file_menu.save_file_action.triggered.connect( lambda: choose_file_get_save_file_path(parent_qt_instance=ui_we_want_to_set) ) @@ -110,10 +111,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # 儲存所有分頁 / Save every modified tab ui_we_want_to_set.file_menu.save_all_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_all_label")) - # Ctrl+Alt+S 是編輯器的「排序選取的行」,因此這裡用慣例的 Ctrl+Shift+S - # Ctrl+Alt+S sorts the selected lines in the editor, so this uses the - # conventional Ctrl+Shift+S - ui_we_want_to_set.file_menu.save_all_action.setShortcut("Ctrl+Shift+S") + bind(ui_we_want_to_set.file_menu.save_all_action, "save_all") ui_we_want_to_set.file_menu.save_all_action.triggered.connect( lambda: save_all_tabs(ui_we_want_to_set)) ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.save_all_action) diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py index 4b77a9ba..6a6c5daf 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py @@ -40,6 +40,20 @@ def _update_auto_save(widget) -> None: thread.line_ending = widget.line_ending +def _refresh_status_bar(ui_we_want_to_set) -> None: + """ + 讓狀態列跟上剛剛的改動 + Let the status bar follow the change just made. + + 狀態列顯示的是分頁記憶中的編碼與行尾,改完不通知它就會停在舊值上。 + The status bar shows the tab's remembered encoding and line ending, and would + otherwise sit on the old values. + """ + refresh = getattr(ui_we_want_to_set, "refresh_status_bar", None) + if callable(refresh): + refresh() + + def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: """ 以指定編碼重新解讀目前檔案,並用它存檔 @@ -60,6 +74,7 @@ def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: return False widget.file_encoding = encoding _update_auto_save(widget) + _refresh_status_bar(ui_we_want_to_set) if widget.current_file is None or getattr(widget, "_is_modified", False): return True try: @@ -76,6 +91,7 @@ def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: widget.file_encoding = used_encoding widget.line_ending = line_ending _update_auto_save(widget) + _refresh_status_bar(ui_we_want_to_set) return True @@ -157,4 +173,5 @@ def apply_line_ending_choice(ui_we_want_to_set, line_ending: str = LINE_ENDING_L return False widget.line_ending = line_ending _update_auto_save(widget) + _refresh_status_bar(ui_we_want_to_set) return True diff --git a/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py b/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py index deed3b4e..759e5a81 100644 --- a/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py @@ -17,6 +17,7 @@ from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入日誌紀錄器 # Import logger instance +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger # 僅在型別檢查時匯入 EditorMain,避免循環依賴 @@ -29,7 +30,7 @@ # 匯入 Qt 動作、快捷鍵、文字格式、訊息框、輸入框、檔案對話框 # Import QAction, QKeySequence, QTextCharFormat, QMessageBox, QInputDialog, QFileDialog -from PySide6.QtGui import QAction, QKeySequence, QTextCharFormat +from PySide6.QtGui import QAction, QTextCharFormat from PySide6.QtWidgets import QMessageBox, QInputDialog, QFileDialog # 匯入 ShellManager,用於執行系統命令 (建立 venv、pip install 等) @@ -52,7 +53,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # Create virtual environment ui_we_want_to_set.venv_menu.change_language_menu = QAction( language_wrapper.language_word_dict.get("python_env_menu_create_venv_label")) - ui_we_want_to_set.venv_menu.change_language_menu.setShortcut(QKeySequence("Ctrl+Shift+V")) + bind(ui_we_want_to_set.venv_menu.change_language_menu, "change_language") ui_we_want_to_set.venv_menu.change_language_menu.triggered.connect( lambda: create_venv(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.change_language_menu) @@ -61,7 +62,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # pip upgrade package ui_we_want_to_set.venv_menu.pip_upgrade_action = QAction( language_wrapper.language_word_dict.get("python_env_menu_pip_upgrade_label")) - ui_we_want_to_set.venv_menu.pip_upgrade_action.setShortcut(QKeySequence("Ctrl+Shift+U")) + bind(ui_we_want_to_set.venv_menu.pip_upgrade_action, "pip_upgrade") ui_we_want_to_set.venv_menu.pip_upgrade_action.triggered.connect( lambda: pip_install_package_update(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.pip_upgrade_action) @@ -70,7 +71,7 @@ def set_venv_menu(ui_we_want_to_set: EditorMain) -> None: # pip install package ui_we_want_to_set.venv_menu.pip_action = QAction( language_wrapper.language_word_dict.get("python_env_menu_pip_label")) - ui_we_want_to_set.venv_menu.pip_action.setShortcut(QKeySequence("Ctrl+Shift+P")) + bind(ui_we_want_to_set.venv_menu.pip_action, "pip_install") ui_we_want_to_set.venv_menu.pip_action.triggered.connect( lambda: pip_install_package(ui_we_want_to_set)) ui_we_want_to_set.venv_menu.addAction(ui_we_want_to_set.venv_menu.pip_action) diff --git a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py index 7759c467..5c5d20b9 100644 --- a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py @@ -4,11 +4,13 @@ # 匯入 Qt 動作 # Import QAction +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QApplication # 匯入使用者設定字典,用來保存 UI 樣式設定 # Import user settings dictionary for saving UI style preferences +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import apply_theme_colors from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入日誌紀錄器 # Import logger instance @@ -61,6 +63,16 @@ def set_style_menu(ui_we_want_to_set: EditorMain) -> None: ui_we_want_to_set.menu.style_menu.addSeparator() add_overlay_toggles(ui_we_want_to_set) + # 快捷鍵設定 / The keyboard shortcut settings + ui_we_want_to_set.menu.style_menu.addSeparator() + ui_we_want_to_set.menu.style_menu.shortcut_settings_action = QAction( + language_wrapper.language_word_dict.get("shortcut_settings_menu_label"), + parent=ui_we_want_to_set.menu.style_menu) + ui_we_want_to_set.menu.style_menu.shortcut_settings_action.triggered.connect( + lambda: open_shortcut_settings(ui_we_want_to_set)) + ui_we_want_to_set.menu.style_menu.addAction( + ui_we_want_to_set.menu.style_menu.shortcut_settings_action) + def add_overlay_toggles(ui_we_want_to_set: EditorMain) -> None: """ @@ -121,3 +133,38 @@ def set_style(ui_we_want_to_set: EditorMain, action: QAction) -> None: # 更新使用者設定,保存目前選擇的樣式 # Update user settings dictionary to persist the chosen style user_setting_dict.update({"ui_style": action.text()}) + + # 編輯器自己畫的顏色也要跟著換,否則淺色樣式配深色底調出來的標記會看不清楚 + # The colours the editor paints itself follow along, or a light style would + # be left with markers tuned for a dark background + apply_theme_colors(action.text()) + _repaint_editors(ui_we_want_to_set) + + +def open_shortcut_settings(ui_we_want_to_set: EditorMain) -> None: + """ + 開啟快捷鍵設定對話框 + Open the keyboard shortcut settings. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + """ + from je_editor.pyside_ui.dialog.shortcut_dialog.shortcut_settings_dialog import ( + ShortcutSettingsDialog + ) + dialog = ShortcutSettingsDialog(ui_we_want_to_set, parent=ui_we_want_to_set) + dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + dialog.show() + + +def _repaint_editors(ui_we_want_to_set: EditorMain) -> None: + """讓每個編輯分頁用新顏色重畫 / Repaint every editor tab in the new colours.""" + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if isinstance(widget, EditorWidget): + widget.code_edit.highlight_current_line() + widget.code_edit.viewport().update() + widget.code_edit.line_number.update() diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index d767b9b1..a64627eb 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -9,6 +9,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import set_tab_git_menu from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_tools_menu import set_tab_tools_menu +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger if TYPE_CHECKING: @@ -63,7 +64,7 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: # === Split view of the same file === ui_we_want_to_set.tab_menu.toggle_split_view_action = QAction( language_wrapper.language_word_dict.get("tab_menu_split_view_label")) - ui_we_want_to_set.tab_menu.toggle_split_view_action.setShortcut("Ctrl+Alt+\\") + bind(ui_we_want_to_set.tab_menu.toggle_split_view_action, "toggle_split_view") ui_we_want_to_set.tab_menu.toggle_split_view_action.triggered.connect( lambda: toggle_split_view(ui_we_want_to_set) ) @@ -73,7 +74,7 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: # === Minimap === ui_we_want_to_set.tab_menu.toggle_minimap_action = QAction( language_wrapper.language_word_dict.get("tab_menu_minimap_label")) - ui_we_want_to_set.tab_menu.toggle_minimap_action.setShortcut("Ctrl+Alt+M") + bind(ui_we_want_to_set.tab_menu.toggle_minimap_action, "toggle_minimap") ui_we_want_to_set.tab_menu.toggle_minimap_action.triggered.connect( lambda: toggle_minimap(ui_we_want_to_set) ) diff --git a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py index 60d33bf2..fe24f8ad 100644 --- a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py @@ -7,6 +7,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.text_stats.text_statistics import TextStatistics, text_statistics @@ -81,7 +82,7 @@ def set_text_menu(ui_we_want_to_set: EditorMain) -> None: language_wrapper.language_word_dict.get("text_menu_word_wrap"), ui_we_want_to_set) word_wrap_action.setCheckable(True) word_wrap_action.setChecked(False) - word_wrap_action.setShortcut("Alt+w") + bind(word_wrap_action, "word_wrap") word_wrap_action.triggered.connect( lambda checked: toggle_word_wrap(ui_we_want_to_set, checked)) ui_we_want_to_set.text_menu.addAction(word_wrap_action) diff --git a/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py b/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py new file mode 100644 index 00000000..b9906daf --- /dev/null +++ b/je_editor/pyside_ui/main_ui/save_settings/shortcut_setting.py @@ -0,0 +1,76 @@ +""" +取得指令目前生效的快捷鍵 +Look up the key sequence a command currently answers to. + +選單、工具列與編輯器都經由這裡取得按鍵,因此使用者在設定裡改過的組合會一致地 +反映到每一處,不會有某個選單還記著舊的寫死值。 +The menus, the toolbar and the editor all ask here, so a sequence the user +changed reaches every one of them and no menu is left holding an old hard-coded +value. +""" +from __future__ import annotations + +from PySide6.QtGui import QAction + +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.utils.shortcuts.shortcut_registry import sequence_for + +# 已經綁上按鍵的動作,設定改變時要一起更新 +# The actions that have been given keys, to be updated when the setting changes +_bound: list[tuple[QAction, str]] = [] + + +def shortcut_for(command: str) -> str: + """ + 取得一個指令目前的按鍵 + The sequence a command currently answers to. + + :param command: 指令名稱 / the command's name + :return: 按鍵組合,沒有指派時為空字串 / the sequence, or an empty string + """ + return sequence_for(command, user_setting_dict.get("shortcuts")) + + +def bind(action: QAction, command: str) -> QAction: + """ + 把一個動作綁到某個指令的按鍵上 + Give an action the keys belonging to a command. + + 記下這個動作,之後在設定裡改按鍵時它才會跟著換——選單與工具列都是啟動時建好 + 的,不記下來就只能等下次開啟才生效。 + The action is remembered so a change made in the settings reaches it: the + menus and the toolbar are built once at startup and would otherwise not pick + a new sequence up until the next launch. + + :param action: 要綁定的動作 / the action to bind + :param command: 指令名稱 / the command's name + :return: 同一個動作,方便串接 / the same action, for chaining + """ + action.setShortcut(shortcut_for(command)) + _bound.append((action, command)) + return action + + +def reload_bound_shortcuts() -> int: + """ + 依目前設定重新套用每個動作的按鍵 + Re-apply every bound action's keys from the current setting. + + 已經被銷毀的動作會順手從清單移除;分頁關掉之後它的動作就不在了。 + An action that has already been destroyed is dropped from the list on the + way past, which is what happens to a closed tab's actions. + + :return: 更新了幾個動作 / how many actions were updated + """ + updated = 0 + alive: list[tuple[QAction, str]] = [] + for action, command in _bound: + try: + action.setShortcut(shortcut_for(command)) + except RuntimeError: + # 這個動作已經隨著它的元件被刪掉了 / It went away with its widget + continue + alive.append((action, command)) + updated += 1 + _bound[:] = alive + return updated diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 9fea4d8c..204340e7 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -7,6 +7,7 @@ from je_editor.pyside_ui.main_ui.save_settings.setting_utils import write_setting from je_editor.utils.json.json_file import read_json from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.theme.theme_colors import DARK_COLORS, retheme def _to_qcolor(key: str, fallback: list) -> QColor: @@ -59,34 +60,10 @@ def update_actually_color_dict() -> None: ) -# 使用者設定的顏色字典 (以 RGB list 表示) -# User-defined color dictionary (stored as RGB lists) +# 使用者設定的顏色字典 (以 RGB list 表示),預設為深色底的那一組 +# User-defined color dictionary (stored as RGB lists), starting from the dark set user_setting_color_dict: Dict[str, list] = { - "line_number_color": [255, 255, 255], - "line_number_background_color": [179, 179, 179], - "current_line_color": [148, 148, 184], - "normal_output_color": [255, 255, 255], - "error_output_color": [255, 0, 0], - "warning_output_color": [204, 204, 0], - "bookmark_marker_color": [66, 165, 245], - "fold_marker_color": [120, 120, 120], - "occurrence_highlight_color": [80, 90, 60], - "diff_added_marker_color": [76, 175, 80], - "diff_modified_marker_color": [255, 167, 38], - "diff_removed_marker_color": [229, 57, 53], - "lint_underline_color": [255, 138, 101], - "blame_annotation_color": [130, 130, 130], - "indent_guide_color": [90, 90, 110], - "minimap_background_color": [40, 40, 48], - "minimap_line_color": [130, 130, 150], - "minimap_viewport_color": [80, 80, 110], - "extra_cursor_color": [255, 215, 64], - "breakpoint_marker_color": [229, 57, 53], - "syntax_keyword_color": [86, 156, 214], - "syntax_string_color": [206, 145, 120], - "syntax_comment_color": [106, 153, 85], - "syntax_number_color": [181, 206, 168], - "trailing_whitespace_color": [120, 70, 70] + key: list(value) for key, value in DARK_COLORS.items() } # 實際使用的顏色字典 (以 QColor 表示) @@ -98,6 +75,22 @@ def update_actually_color_dict() -> None: update_actually_color_dict() +def apply_theme_colors(style_name: str) -> None: + """ + 依視窗樣式換上對應的編輯器顏色 + Move the editor's own colours to the ones that suit the window style. + + 使用者親自挑過的顏色不會被蓋掉;只有還是預設值的那些會跟著主題走。 + A colour the user picked is left alone; only those still at a default follow + the theme. + + :param style_name: qt-material 的樣式檔名 / the qt-material style file name + """ + jeditor_logger.info(f"user_color_setting_file.py apply_theme_colors {style_name}") + user_setting_color_dict.update(retheme(user_setting_color_dict, style_name)) + update_actually_color_dict() + + def write_user_color_setting() -> None: """ 將使用者顏色設定寫入 JSON 檔案 diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py index 0e6756c6..10ab1c61 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py @@ -31,6 +31,8 @@ "indent_size": 4, # 縮排空格數 / Indent size (spaces) "open_files": [], # 上次關閉時開啟的分頁 / Tabs open at last shutdown "restore_session": True, # 啟動時還原分頁 / Restore tabs on startup + # 使用者改過的快捷鍵,只記與預設不同的 / Shortcuts the user changed, defaults omitted + "shortcuts": {}, } diff --git a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py index dc0a0a0c..9c112bba 100644 --- a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py @@ -17,14 +17,15 @@ from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget + QCheckBox, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, + QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.test_runner.pytest_output import ( - PytestResult, failure_for_result, parse_failures, parse_results, parse_summary + PytestResult, failure_for_result, parse_coverage, parse_failures, parse_results, + parse_summary, parse_tracebacks, traceback_for_result ) # 樹狀清單欄位索引 / Column indexes in the tree @@ -35,21 +36,32 @@ TEST_COLUMN_WIDTH = 360 # 單次測試執行的逾時(秒)/ Timeout for one test run RUN_TIMEOUT_SECONDS = 600 +# 清單與追蹤訊息的高度比例 / How the list and the traceback share the height +_RESULT_STRETCH = 3 +_TRACEBACK_STRETCH = 2 -def pytest_command(node_ids: list[str] | None = None) -> list[str]: +def pytest_command(node_ids: list[str] | None = None, + with_coverage: bool = False) -> list[str]: """ 組出執行測試的指令 Build the command that runs the tests. - ``-v`` 才會逐項列出結果,``--tb=line`` 讓每個失敗只印一行位置,剛好夠面板用。 - ``-v`` is what lists each test, and ``--tb=line`` prints one location per - failure, which is exactly what the panel needs. + ``-v`` 才會逐項列出結果;``--tb=short`` 每個失敗印出足夠閱讀的追蹤訊息,同時 + 仍然保留「檔案:行號」那一行,跳轉才有得用。 + ``-v`` is what lists each test, and ``--tb=short`` prints a traceback worth + reading while still carrying the ``file:line`` the jump needs. :param node_ids: 只跑這些測試,``None`` 表示全部 / run only these, or all when ``None`` + :param with_coverage: 是否一併量測覆蓋率 / whether to measure coverage too :return: 引數清單(不經過 shell)/ the argument list, never a shell string """ - command = [sys.executable, "-m", "pytest", "-v", "--tb=line", "-p", "no:cacheprovider"] + command = [sys.executable, "-m", "pytest", "-v", "--tb=short", "-p", "no:cacheprovider"] + if with_coverage: + # 需要目標專案裝有 pytest-cov;沒有的話 pytest 會直接說不認得這個參數 + # This needs pytest-cov in the target project; without it pytest simply + # reports that it does not recognise the argument + command.extend(["--cov=.", "--cov-report=term"]) # 節點名稱是 pytest 自己印出來的,原樣傳回去;仍然是獨立引數,不經過 shell # The node ids came from pytest itself and go straight back as separate # arguments, never through a shell @@ -65,21 +77,25 @@ class PytestRunThread(QThread): finished_output = Signal(str) - def __init__(self, working_dir: str, node_ids: list[str] | None = None, parent=None) -> None: + def __init__(self, working_dir: str, node_ids: list[str] | None = None, + with_coverage: bool = False, parent=None) -> None: """ :param working_dir: 執行測試的目錄 / the directory to run the tests in :param node_ids: 只跑這些測試 / run only these tests + :param with_coverage: 是否一併量測覆蓋率 / whether to measure coverage too :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) + self.setObjectName("PytestRunThread") self._working_dir = working_dir self._node_ids = node_ids + self._with_coverage = with_coverage def run(self) -> None: """執行測試並回報輸出 / Run the tests and report their output.""" try: completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 - pytest_command(self._node_ids), + pytest_command(self._node_ids, self._with_coverage), cwd=self._working_dir, capture_output=True, text=True, @@ -112,6 +128,7 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self._working_dir = working_dir or resolve_working_dir(main_window) self._results: list[PytestResult] = [] self._failures: list = [] + self._tracebacks: dict[str, str] = {} self._thread: PytestRunThread | None = None self.run_button = QPushButton(word.get("test_panel_run")) @@ -123,8 +140,15 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self.filter_edit = QLineEdit() self.filter_edit.setPlaceholderText(word.get("test_panel_filter_placeholder")) self.filter_edit.textChanged.connect(self._render_items) + self.coverage_check = QCheckBox(word.get("test_panel_coverage")) self.status_label = QLabel(word.get("test_panel_ready")) + # 失敗的追蹤訊息:選到某個測試就顯示它的那一段 + # The failing traceback: selecting a test shows the block belonging to it + self.traceback_view = QPlainTextEdit() + self.traceback_view.setReadOnly(True) + self.traceback_view.setPlaceholderText(word.get("test_panel_traceback_placeholder")) + self.result_tree = QTreeWidget() self.result_tree.setColumnCount(3) self.result_tree.setHeaderLabels([ @@ -135,18 +159,28 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self.result_tree.setColumnWidth(COLUMN_TEST, TEST_COLUMN_WIDTH) self.result_tree.setRootIsDecorated(False) self.result_tree.itemDoubleClicked.connect(self._open_item) + self.result_tree.itemSelectionChanged.connect(self._show_selected_traceback) controls = QHBoxLayout() controls.addWidget(self.run_button) controls.addWidget(self.run_selected_button) controls.addWidget(self.rerun_failures_button) controls.addWidget(self.filter_edit) + controls.addWidget(self.coverage_check) controls.addWidget(self.status_label) controls.addStretch() + # 清單與追蹤訊息上下並排,中間可以拖動 + # The list and the traceback sit one above the other, with a draggable split + split = QSplitter(Qt.Orientation.Vertical) + split.addWidget(self.result_tree) + split.addWidget(self.traceback_view) + split.setStretchFactor(0, _RESULT_STRETCH) + split.setStretchFactor(1, _TRACEBACK_STRETCH) + layout = QVBoxLayout(self) layout.addLayout(controls) - layout.addWidget(self.result_tree) + layout.addWidget(split) self.setLayout(layout) def results(self) -> list[PytestResult]: @@ -168,7 +202,8 @@ def start_run(self, node_ids: list[str] | None = None) -> bool: return False self.run_button.setEnabled(False) self.status_label.setText(language_wrapper.language_word_dict.get("test_panel_running")) - self._thread = PytestRunThread(self._working_dir, node_ids) + self._thread = PytestRunThread( + self._working_dir, node_ids, self.coverage_check.isChecked()) self._thread.finished_output.connect(self.apply_output) self._thread.finished.connect(self._thread.deleteLater) self._thread.start() @@ -230,15 +265,39 @@ def apply_output(self, output: str) -> None: """ self._results = parse_results(output) self._failures = parse_failures(output) + self._tracebacks = parse_tracebacks(output) summary = parse_summary(output) + coverage = parse_coverage(output) self.run_button.setEnabled(True) self._render_items() + self.traceback_view.setPlainText("") if summary: - self.status_label.setText(summary) + self.status_label.setText( + f"{summary} — {coverage}" if coverage else summary) elif not self._results: self.status_label.setText( language_wrapper.language_word_dict.get("test_panel_no_results")) + def traceback_for(self, result: PytestResult) -> str: + """ + 取得某個測試的追蹤訊息 + The traceback reported for one test. + + :param result: 測試結果 / the test result + :return: 追蹤訊息,沒有時為空字串 / the traceback, or an empty string + """ + return traceback_for_result(result, self._tracebacks) + + def _show_selected_traceback(self) -> None: + """把選取測試的追蹤訊息顯示出來 / Show the selected test's traceback.""" + items = self.result_tree.selectedItems() + if not items: + self.traceback_view.setPlainText("") + return + result = items[0].data(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole) + self.traceback_view.setPlainText( + self.traceback_for(result) if result is not None else "") + def visible_results(self) -> list[PytestResult]: """ 取得符合篩選條件的結果,失敗的排在最前面 diff --git a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py index 9d49d214..1f9e8e95 100644 --- a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py +++ b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py @@ -8,6 +8,7 @@ QToolBar, QComboBox, QStyle, QLabel, QWidget, QMessageBox ) +from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -61,21 +62,21 @@ def build_toolbar(main_window: EditorMain) -> None: act_run = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaPlay), lang("toolbar_run"), main_window) act_run.setToolTip(lang("toolbar_run")) - act_run.setShortcut("F5") + bind(act_run, "run_program") act_run.triggered.connect(lambda: _run_program(main_window)) toolbar.addAction(act_run) act_debug = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaSeekForward), lang("toolbar_debug"), main_window) act_debug.setToolTip(lang("toolbar_debug")) - act_debug.setShortcut("F9") + bind(act_debug, "run_debugger") act_debug.triggered.connect(lambda: _run_debugger(main_window)) toolbar.addAction(act_debug) act_stop = QAction(_icon(main_window, QStyle.StandardPixmap.SP_MediaStop), lang("toolbar_stop"), main_window) act_stop.setToolTip(lang("toolbar_stop")) - act_stop.setShortcut("Shift+F5") + bind(act_stop, "stop_program") act_stop.triggered.connect(lambda: _stop_program(main_window)) toolbar.addAction(act_stop) @@ -110,7 +111,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_search = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogContentsView), lang("toolbar_search"), main_window) act_search.setToolTip(lang("toolbar_search")) - act_search.setShortcut("Ctrl+Shift+F") + bind(act_search, "search_in_files") act_search.triggered.connect(lambda: _open_search(main_window)) toolbar.addAction(act_search) @@ -120,7 +121,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_palette = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogDetailedView), lang("toolbar_command_palette"), main_window) act_palette.setToolTip(lang("toolbar_command_palette")) - act_palette.setShortcut("Ctrl+Shift+A") + bind(act_palette, "command_palette") act_palette.triggered.connect(lambda: _open_command_palette(main_window)) toolbar.addAction(act_palette) main_window.command_palette_action = act_palette @@ -128,7 +129,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_quick_open = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogListView), lang("toolbar_quick_open"), main_window) act_quick_open.setToolTip(lang("toolbar_quick_open")) - act_quick_open.setShortcut("Ctrl+P") + bind(act_quick_open, "quick_open") act_quick_open.triggered.connect(lambda: _open_quick_open(main_window)) toolbar.addAction(act_quick_open) main_window.quick_open_action = act_quick_open @@ -136,7 +137,7 @@ def build_toolbar(main_window: EditorMain) -> None: act_go_to_symbol = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogInfoView), lang("toolbar_go_to_symbol"), main_window) act_go_to_symbol.setToolTip(lang("toolbar_go_to_symbol")) - act_go_to_symbol.setShortcut("Ctrl+Shift+O") + bind(act_go_to_symbol, "go_to_symbol") act_go_to_symbol.triggered.connect(lambda: _open_go_to_symbol(main_window)) toolbar.addAction(act_go_to_symbol) main_window.go_to_symbol_action = act_go_to_symbol diff --git a/je_editor/utils/code_folding/brace_regions.py b/je_editor/utils/code_folding/brace_regions.py new file mode 100644 index 00000000..f48e72d7 --- /dev/null +++ b/je_editor/utils/code_folding/brace_regions.py @@ -0,0 +1,161 @@ +""" +以大括號計算可折疊區塊(純邏輯,不含 Qt) +Compute foldable regions from brace pairs (pure logic, no Qt imports). + +縮排式折疊對 Python 剛好,但 C 家族的語言把區塊寫在 ``{`` 與 ``}`` 之間,縮排只是 +慣例——只用縮排判斷的話,把開頭的大括號放在下一行、或整段擠在一行,折疊就對不上。 +Indentation folding suits Python, but the C-family languages delimit blocks with +``{`` and ``}`` and treat indentation as a convention; judging by indentation +alone then misreads a brace placed on its own line, or a block written on one. + +字串與註解裡的括號不算,否則 ``"{"`` 之類的內容會讓整份檔案的配對錯開。 +Braces inside strings and comments do not count, or something like ``"{"`` would +throw every pair in the file out of step. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from je_editor.utils.code_folding.fold_regions import MAX_SCAN_LINES, FoldRegion, line_indent +from je_editor.utils.syntax.language_rules import rules_for + +# 用大括號表達區塊的副檔名 / The suffixes whose blocks are delimited by braces +BRACE_SUFFIXES = frozenset({ + ".js", ".ts", ".rs", ".go", ".c", ".h", ".cpp", ".hpp", ".java", ".json", +}) + + +@dataclass +class _ScanState: + """ + 掃描過程中的狀態 + What the scan is in the middle of. + + :param string_quote: 目前所在字串的引號,不在字串中則為空字串 + / the quote of the string being scanned, or empty when outside one + :param in_block_comment: 是否在區塊註解中 / whether a block comment is open + """ + + string_quote: str = "" + in_block_comment: bool = False + + +def uses_braces(suffix: str) -> bool: + """ + 判斷某個副檔名是否以大括號表達區塊 + Whether a file suffix delimits its blocks with braces. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 是的話為 ``True`` / ``True`` when it does + """ + return suffix.lower() in BRACE_SUFFIXES + + +def compute_brace_fold_regions(lines: list[str], suffix: str) -> list[FoldRegion]: + """ + 從大括號配對計算可折疊區塊 + Compute the foldable regions from brace pairs. + + 每一對括號構成一個區塊:標頭是 ``{`` 所在的行,結尾是配對的 ``}`` 所在的行。 + 兩者同一行的區塊不算,折疊它沒有任何意義。 + Each pair makes a region: the header is the line holding ``{`` and the end is + the line holding its match. A pair that opens and closes on one line is not a + region, since folding it would hide nothing. + + :param lines: 檔案的文字行 / the file's text lines + :param suffix: 副檔名,用來判斷字串與註解的寫法 / the suffix, for its comment syntax + :return: 依標頭行排序的區塊 / the regions, ordered by header line + """ + count = min(len(lines), MAX_SCAN_LINES) + rules = rules_for(suffix) + line_comment = rules.line_comment if rules is not None else "//" + block_comment = rules.block_comment if rules is not None else ("/*", "*/") + quotes = rules.string_delimiters if rules is not None else ('"', "'") + state = _ScanState() + open_lines: list[int] = [] + regions: list[FoldRegion] = [] + for number in range(count): + for symbol in _braces_in(lines[number], state, line_comment, block_comment, quotes): + if symbol == "{": + open_lines.append(number) + elif open_lines: + start = open_lines.pop() + # 至少要藏得住一行才算得上區塊;結尾的 ``}`` 留著可見 + # A region has to hide at least one line; the closing ``}`` stays visible + if number > start + 1: + regions.append(FoldRegion( + start=start, end=number - 1, indent=line_indent(lines[start]) or 0)) + return sorted(regions, key=lambda region: region.start) + + +def _braces_in(line: str, state: _ScanState, line_comment: str, + block_comment: tuple[str, str] | None, quotes: tuple[str, ...]) -> list[str]: + """ + 取出一行中真正算數的大括號 + The braces on one line that actually count. + + :param line: 一行文字 / the line of text + :param state: 跨行延續的狀態,會被就地更新 / the state carried across lines, updated in place + :param line_comment: 單行註解的開頭 / what starts a line comment + :param block_comment: 區塊註解的起訖 / the block comment delimiters + :param quotes: 字串的引號 / the quote characters + :return: 依序出現的 ``{`` 與 ``}`` / the braces in the order they appear + """ + found: list[str] = [] + index = 0 + while index < len(line): + char = line[index] + if state.in_block_comment: + index = _skip_to_block_end(line, index, block_comment, state) + continue + if state.string_quote: + index = _skip_in_string(line, index, state) + continue + if block_comment and line.startswith(block_comment[0], index): + state.in_block_comment = True + index += len(block_comment[0]) + continue + if line_comment and line.startswith(line_comment, index): + break + if char in quotes: + state.string_quote = char + index += 1 + continue + if char in "{}": + found.append(char) + index += 1 + return found + + +def _skip_to_block_end(line: str, index: int, block_comment: tuple[str, str] | None, + state: _ScanState) -> int: + """跳過區塊註解的內容 / Step past what is inside a block comment.""" + if block_comment and line.startswith(block_comment[1], index): + state.in_block_comment = False + return index + len(block_comment[1]) + return index + 1 + + +def _skip_in_string(line: str, index: int, state: _ScanState) -> int: + """跳過字串的內容,並處理跳脫字元 / Step past a string's content, honouring escapes.""" + char = line[index] + if char == "\\": + return index + 2 + if char == state.string_quote: + state.string_quote = "" + return index + 1 + + +def fold_regions_for(lines: list[str], suffix: str) -> list[FoldRegion]: + """ + 依語言選擇折疊方式 + Compute the foldable regions the way this language expresses blocks. + + :param lines: 檔案的文字行 / the file's text lines + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 可折疊區塊 / the foldable regions + """ + from je_editor.utils.code_folding.fold_regions import compute_fold_regions + if uses_braces(suffix): + return compute_brace_fold_regions(lines, suffix) + return compute_fold_regions(lines) diff --git a/je_editor/utils/debugger/pdb_commands.py b/je_editor/utils/debugger/pdb_commands.py index bf172579..09cb203a 100644 --- a/je_editor/utils/debugger/pdb_commands.py +++ b/je_editor/utils/debugger/pdb_commands.py @@ -45,7 +45,37 @@ def breakpoint_command(file_path: str | Path, line: int) -> str: :param line: 1 起算的行號 / the 1-based line number :return: pdb 指令 / the pdb command """ - return f"break {Path(file_path).as_posix()}:{max(1, line)}" + return f"break {_location(file_path, line)}" + + +def clear_command(file_path: str | Path, line: int) -> str: + """ + 組出清除一個中斷點的指令 + Build the command that clears one breakpoint. + + :param file_path: 檔案路徑 / the file the breakpoint is in + :param line: 1 起算的行號 / the 1-based line number + :return: pdb 指令 / the pdb command + """ + return f"clear {_location(file_path, line)}" + + +def toggle_command(file_path: str | Path, line: int, is_set: bool) -> str: + """ + 組出切換一個中斷點的指令 + Build the command for toggling one breakpoint. + + :param file_path: 檔案路徑 / the file the breakpoint is in + :param line: 1 起算的行號 / the 1-based line number + :param is_set: 切換後該行是否有中斷點 / whether the line now has one + :return: pdb 指令 / the pdb command + """ + return breakpoint_command(file_path, line) if is_set else clear_command(file_path, line) + + +def _location(file_path: str | Path, line: int) -> str: + """組出 pdb 認得的「檔案:行號」/ The ``file:line`` pdb understands.""" + return f"{Path(file_path).as_posix()}:{max(1, line)}" def breakpoint_commands(file_path: str | Path, lines: list[int]) -> list[str]: diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py index f6c93103..feeff2f5 100644 --- a/je_editor/utils/lsp/lsp_protocol.py +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -229,6 +229,131 @@ def hover_text(result: object) -> str: return "\n".join(texts) +def signature_text(result: object) -> str: + """ + 從 signature help 回應取出目前這個簽章 + Take the signature currently in play out of a signature help response. + + 只取伺服器指的那一個:一個函式可能有好幾個多載,全部列出來反而看不出正在打的 + 是哪一個。 + Only the one the server points at is taken: a function may have several + overloads, and listing them all hides the one being typed. + + :param result: 伺服器的回應內容 / the server's result + :return: 簽章文字,沒有內容時為空字串 / the signature, or an empty string + """ + if not isinstance(result, dict): + return "" + signatures = result.get("signatures") + if not isinstance(signatures, list) or not signatures: + return "" + index = result.get("activeSignature") + if not isinstance(index, int) or not 0 <= index < len(signatures): + index = 0 + active = signatures[index] + if not isinstance(active, dict): + return "" + label = active.get("label") + documentation = active.get("documentation") + if isinstance(documentation, dict): + documentation = documentation.get("value") + parts = [str(part).strip() for part in (label, documentation) if isinstance(part, str)] + return "\n".join(part for part in parts if part) + + +def reference_locations(result: object) -> list[dict]: + """ + 從 references 回應取出所有位置 + Take every location out of a references response. + + 行列與 :func:`definition_location` 一樣轉成 1 起算,跳轉才不必再換算一次。 + Lines and columns come back 1-based, as :func:`definition_location` gives + them, so a jump does not have to convert again. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"path", "line", "column"}`` 的清單 / the locations + """ + if not isinstance(result, list): + return [] + locations = [] + for item in result: + location = definition_location(item) + if location is not None: + locations.append(location) + return locations + + +def code_action_titles(result: object) -> list[dict]: + """ + 從 code action 回應取出可以套用的動作 + Take the applicable actions out of a code action response. + + 只保留帶著編輯內容的動作。純指令型的動作要再跟伺服器往返一次才知道要改什麼, + 列出來卻按不動比不列還糟。 + Only actions carrying edits are kept: a command-only action needs another + round trip before anything can be changed, and listing one that does nothing + when pressed is worse than not listing it. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"title", "edits"}`` 的清單 / the actions + """ + if not isinstance(result, list): + return [] + actions = [] + for item in result: + if not isinstance(item, dict): + continue + title = item.get("title") + edits = text_edits(item.get("edit")) + if isinstance(title, str) and title.strip() and edits: + actions.append({"title": title.strip(), "edits": edits}) + return actions + + +def document_symbols(result: object) -> list[dict]: + """ + 從 document symbol 回應取出符號,巢狀的一併攤平 + Take the symbols out of a document symbol response, nesting included. + + 回應可能是 ``DocumentSymbol``(有 ``children``)或 ``SymbolInformation`` + (帶 ``location``),兩種格式都要接受。 + A response may be ``DocumentSymbol`` with ``children`` or + ``SymbolInformation`` with a ``location``, and both are accepted. + + 行號轉成 1 起算,與其他位置一致。 + Lines come back 1-based, as every other position here does. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"name", "kind", "line", "depth"}`` 的清單 / the symbols + """ + return _flatten_symbols(result, depth=0) + + +def _flatten_symbols(result: object, depth: int) -> list[dict]: + """把符號樹攤成一層,並記下每個符號的深度 / Flatten the symbol tree, noting each depth.""" + if not isinstance(result, list): + return [] + symbols: list[dict] = [] + for item in result: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name.strip(): + continue + span = item.get("selectionRange") or item.get("range") + if span is None: + span = (item.get("location") or {}).get("range") + line, _column = _position(span.get("start") if isinstance(span, dict) else None) + symbols.append({ + "name": name.strip(), + "kind": item.get("kind") if isinstance(item.get("kind"), int) else 0, + "line": line, + "depth": depth, + }) + symbols.extend(_flatten_symbols(item.get("children"), depth + 1)) + return symbols + + def text_edits(result: object, file_uri_text: str = "") -> list[dict]: """ 從 rename 或 formatting 回應取出要套用的編輯 diff --git a/je_editor/utils/multi_cursor/cursor_positions.py b/je_editor/utils/multi_cursor/cursor_positions.py index c555a302..b4d6627d 100644 --- a/je_editor/utils/multi_cursor/cursor_positions.py +++ b/je_editor/utils/multi_cursor/cursor_positions.py @@ -126,6 +126,29 @@ def column_caret_columns( return [min(target, max(0, length)) for length in line_lengths] +def positions_after_replacing(ranges: list[tuple[int, int]], length: int) -> list[int]: + """ + 把每個範圍都換成等長的文字之後,各游標落在哪裡 + Where each caret lands once every range is replaced by text of one length. + + 範圍由前往後累積位移:排在越後面的範圍,前面被改寫的次數越多。游標停在自己 + 那段新文字的結尾,接著輸入就會接在後面。 + The shift accumulates front to back, since a later range has more rewrites + ahead of it. Each caret ends up after its own new text, so typing carries on + from there. + + :param ranges: 每個游標涵蓋的範圍 ``(起, 訖)``,訖不含 / each caret's ``(start, end)``, end exclusive + :param length: 換上去的文字長度 / the length of the text replacing each range + :return: 各游標的新位置(依原順序)/ the new caret positions, in the given order + """ + shift = 0 + moved: list[int] = [] + for start, end in sorted(ranges): + moved.append(start + shift + length) + shift += length - (end - start) + return moved + + def clamp_positions(positions: list[int], limit: int) -> list[int]: """ 把位置限制在文件範圍內 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 499cd610..12947a26 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -409,6 +409,28 @@ "test_panel_run_selected": "Run Selected", "test_panel_rerun_failures": "Re-run Failures", "test_panel_filter_placeholder": "Filter tests...", + "test_panel_coverage": "With coverage", + # Shortcut settings + "shortcut_settings_title": "Keyboard Shortcuts", + "shortcut_settings_menu_label": "Keyboard Shortcuts...", + "shortcut_settings_col_command": "Command", + "shortcut_settings_col_keys": "Keys", + "shortcut_settings_save": "Save", + "shortcut_settings_reset": "Restore defaults", + "shortcut_settings_hint": "Changes take effect as soon as they are saved.", + "shortcut_settings_conflict": "{keys} is claimed by {commands}", + # Language server + "lsp_references_title": "References", + "lsp_references_prompt": "Go to:", + "lsp_quick_fix_title": "Quick Fixes", + "lsp_quick_fix_prompt": "Apply:", + "context_menu_find_references": "Find References", + "context_menu_quick_fix": "Quick Fix...", + "context_menu_unstage_file": "Unstage File", + "context_menu_commit_file": "Commit Staged...", + "git_commit_title": "Commit", + "git_commit_prompt": "Message:", + "test_panel_traceback_placeholder": "Select a failing test to see why it failed", "test_panel_ready": "Ready", "test_panel_running": "Running tests...", "test_panel_no_results": "No tests were reported", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index b8211c7f..4b41fb8a 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -399,6 +399,28 @@ "test_panel_run_selected": "只跑選取的", "test_panel_rerun_failures": "重跑失敗的", "test_panel_filter_placeholder": "篩選測試...", + "test_panel_coverage": "含覆蓋率", + # 快捷鍵設定 / Shortcut settings + "shortcut_settings_title": "鍵盤快捷鍵", + "shortcut_settings_menu_label": "鍵盤快捷鍵…", + "shortcut_settings_col_command": "指令", + "shortcut_settings_col_keys": "按鍵", + "shortcut_settings_save": "儲存", + "shortcut_settings_reset": "還原預設值", + "shortcut_settings_hint": "儲存後立即生效。", + "shortcut_settings_conflict": "{keys} 同時被 {commands} 使用", + # 語言伺服器 / Language server + "lsp_references_title": "參照", + "lsp_references_prompt": "跳到:", + "lsp_quick_fix_title": "快速修正", + "lsp_quick_fix_prompt": "套用:", + "context_menu_find_references": "尋找參照", + "context_menu_quick_fix": "快速修正…", + "context_menu_unstage_file": "取消暫存", + "context_menu_commit_file": "提交已暫存的內容…", + "git_commit_title": "提交", + "git_commit_prompt": "訊息:", + "test_panel_traceback_placeholder": "選擇一個失敗的測試以查看原因", "test_panel_ready": "就緒", "test_panel_running": "測試執行中...", "test_panel_no_results": "沒有回報任何測試", diff --git a/je_editor/utils/occurrence/word_occurrences.py b/je_editor/utils/occurrence/word_occurrences.py index 089c5255..510d3500 100644 --- a/je_editor/utils/occurrence/word_occurrences.py +++ b/je_editor/utils/occurrence/word_occurrences.py @@ -85,6 +85,32 @@ def replace_whole_word(text: str, word: str, replacement: str) -> str: return pattern.sub(lambda _match: replacement, text) +def lines_containing(text: str, term: str, case_sensitive: bool = False) -> list[int]: + """ + 找出含有指定字串的行 + Find the lines that contain a piece of text. + + 這是「搜尋框正在找的東西」用的:比對的是使用者輸入的字串本身,不加字界也不 + 要求是識別字,因此 ``val`` 會在 ``value`` 裡命中——搜尋本來就該如此。 + This backs what the search box is looking for: it matches the string the user + typed, with no word boundaries and no identifier requirement, so ``val`` does + hit inside ``value`` — which is what searching is meant to do. + + :param text: 要搜尋的文字 / the text to search + :param term: 要尋找的字串 / the string to find + :param case_sensitive: 是否區分大小寫 / whether case matters + :return: 含有該字串的行號(0 起算,已排序去重) + / the 0-based line numbers holding it, sorted and unique + """ + if not term: + return [] + needle = term if case_sensitive else term.lower() + return [ + number for number, line in enumerate(text.splitlines()) + if needle in (line if case_sensitive else line.lower()) + ] + + def find_occurrences(text: str, word: str) -> list[int]: """ 找出字詞在文字中所有以完整字界出現的位置 diff --git a/je_editor/utils/shortcuts/shortcut_registry.py b/je_editor/utils/shortcuts/shortcut_registry.py index 8856dec4..a7ad7481 100644 --- a/je_editor/utils/shortcuts/shortcut_registry.py +++ b/je_editor/utils/shortcuts/shortcut_registry.py @@ -78,12 +78,12 @@ class ShortcutRegistry: def __init__(self, reserved: Optional[Dict[str, str]] = None) -> None: """ - :param reserved: 已經被外部(選單、工具列)佔用的按鍵,對應到指令名稱 - / sequences already taken elsewhere (menus, toolbar), mapped to command names + :param reserved: 已經被外部(選單、工具列)佔用的指令,對應到它們的按鍵 + / commands already spoken for elsewhere (menus, toolbar), mapped to their sequences """ self._owners: Dict[str, str] = {} self._conflicts: List[Tuple[str, str, str]] = [] - for sequence, command in (reserved or {}).items(): + for command, sequence in (reserved or {}).items(): key = normalise_sequence(sequence) if key: self._owners[key] = command @@ -155,37 +155,162 @@ def commands(self) -> Dict[str, str]: return dict(self._owners) -# 選單與工具列佔用的按鍵:編輯器的動作不能重複使用這些組合,否則兩邊都不會作用。 -# 這裡列出的是「視窗層級」的指令,編輯器有焦點時它們仍然必須能用。 -# The sequences the menus and toolbar take. Editor actions must not reuse them or +# 選單與工具列的指令:編輯器的動作不能重複使用這些組合,否則兩邊都不會作用。 +# 這些是「視窗層級」的指令,編輯器有焦點時它們仍然必須能用。 +# The menu and toolbar commands. Editor actions must not reuse their sequences or # neither side fires. These are window-level commands that have to keep working # while the editor has focus. WINDOW_SHORTCUTS: Dict[str, str] = { # 工具列 / Toolbar - "F5": "run_program", - "F9": "run_debugger", - "Shift+F5": "stop_program", - "Ctrl+Shift+F": "search_in_files", - "Ctrl+Shift+A": "command_palette", - "Ctrl+P": "quick_open", - "Ctrl+Shift+O": "go_to_symbol", + "run_program": "F5", + "run_debugger": "F9", + "stop_program": "Shift+F5", + "search_in_files": "Ctrl+Shift+F", + "command_palette": "Ctrl+Shift+A", + "quick_open": "Ctrl+P", + "go_to_symbol": "Ctrl+Shift+O", # 檔案選單 / File menu - "Ctrl+N": "new_file", - "Ctrl+O": "open_file", - "Ctrl+K": "open_folder", - "Ctrl+S": "save_file", - "Ctrl+Shift+S": "save_all", + "new_file": "Ctrl+N", + "open_file": "Ctrl+O", + "open_folder": "Ctrl+K", + "save_file": "Ctrl+S", + "save_all": "Ctrl+Shift+S", # 檢查與格式化選單 / Check and format menu - "Ctrl+Shift+Y": "yapf_reformat", - "Ctrl+J": "reformat_json", - "Ctrl+Alt+P": "check_python_format", + "yapf_reformat": "Ctrl+Shift+Y", + "reformat_json": "Ctrl+J", + "check_python_format": "Ctrl+Alt+P", # 文字選單 / Text menu - "Alt+W": "word_wrap", + "word_wrap": "Alt+W", # Python 環境選單 / Python environment menu - "Ctrl+Shift+V": "change_language", - "Ctrl+Shift+U": "pip_upgrade", - "Ctrl+Shift+P": "pip_install", + "change_language": "Ctrl+Shift+V", + "pip_upgrade": "Ctrl+Shift+U", + "pip_install": "Ctrl+Shift+P", # 分頁選單 / Tab menu - "Ctrl+Alt+\\": "toggle_split_view", - "Ctrl+Alt+M": "toggle_minimap", + "toggle_split_view": "Ctrl+Alt+\\", + "toggle_minimap": "Ctrl+Alt+M", } + +# 編輯器自己的指令 / The editor's own commands +EDITOR_SHORTCUTS: Dict[str, str] = { + # 搜尋與跳轉 / Search and navigation + "search": "Ctrl+F", + "search_and_replace": "Ctrl+H", + "go_to_line": "Ctrl+G", + "navigate_back": "Alt+Left", + "navigate_forward": "Alt+Right", + "recent_locations": "Ctrl+Alt+E", + # 折疊與書籤 / Folding and bookmarks + "toggle_fold": "Ctrl+Shift+[", + "fold_all": "Ctrl+Alt+[", + "unfold_all": "Ctrl+Alt+]", + "toggle_bookmark": "Ctrl+Alt+K", + "next_bookmark": "Ctrl+Alt+L", + "previous_bookmark": "Ctrl+Alt+J", + # 行與選取 / Lines and selection + "delete_line": "Ctrl+Shift+D", + "join_lines": "Ctrl+Shift+J", + "sort_lines": "Ctrl+Alt+S", + "expand_selection": "Ctrl+Alt+Right", + "shrink_selection": "Ctrl+Alt+Left", + "increment_number": "Ctrl+Alt+Up", + "decrement_number": "Ctrl+Alt+Down", + "rename_occurrences": "F2", + # git + "next_change": "F7", + "previous_change": "Shift+F7", + "revert_change": "Ctrl+Alt+Z", + "toggle_blame": "Ctrl+Alt+B", + # 多重游標 / Multiple carets + "cursors_on_selected_lines": "Ctrl+Shift+L", + # Qt 自己把 Escape 印成 Esc;用同一種寫法,設定介面才不會把沒改過的項目 + # 當成使用者的變更記下來 + # Qt spells Escape as Esc; matching that keeps the settings dialog from + # recording an untouched command as a change + "clear_extra_cursors": "Ctrl+Shift+Esc", + "cursor_above": "Ctrl+Alt+Shift+Up", + "cursor_below": "Ctrl+Alt+Shift+Down", + "cursor_at_next_occurrence": "Ctrl+Alt+N", + # 巨集 / Macros + "record_macro": "Ctrl+Shift+R", + "play_macro": "Ctrl+Shift+G", + # 除錯 / Debugging + "toggle_breakpoint": "Ctrl+F9", + "debug_continue": "Ctrl+F5", + "debug_step_over": "F10", + "debug_step_into": "F11", + "debug_step_out": "Shift+F11", +} + +# 全部指令的預設按鍵 / Every command's default sequence +DEFAULT_SHORTCUTS: Dict[str, str] = {**WINDOW_SHORTCUTS, **EDITOR_SHORTCUTS} + + +def sequence_for(command: str, overrides: Optional[Dict[str, str]] = None) -> str: + """ + 取得一個指令目前的按鍵 + The sequence a command currently answers to. + + 使用者設定過就用設定的,否則用預設值;設定成空字串代表刻意取消這個快捷鍵。 + A configured sequence wins over the default, and an empty one means the + shortcut was deliberately removed. + + :param command: 指令名稱 / the command's name + :param overrides: 使用者設定的按鍵 / the sequences the user configured + :return: 按鍵組合,沒有指派時為空字串 / the sequence, or an empty string + """ + if overrides is not None and command in overrides: + return str(overrides[command] or "") + return DEFAULT_SHORTCUTS.get(command, "") + + +def effective_shortcuts(overrides: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + 取得所有指令目前的按鍵 + Every command's current sequence. + + :param overrides: 使用者設定的按鍵 / the sequences the user configured + :return: 指令對應按鍵 / command mapped to sequence + """ + return { + command: sequence_for(command, overrides) + for command in DEFAULT_SHORTCUTS + } + + +def find_conflicts(shortcuts: Dict[str, str]) -> List[Tuple[str, List[str]]]: + """ + 找出被兩個以上指令共用的按鍵 + Find the sequences claimed by more than one command. + + :param shortcuts: 指令對應按鍵 / command mapped to sequence + :return: ``(按鍵, 指令清單)``,依按鍵排序 / ``(sequence, commands)``, sorted by sequence + """ + owners: Dict[str, List[str]] = {} + for command, sequence in shortcuts.items(): + key = normalise_sequence(sequence) + if key: + owners.setdefault(key, []).append(command) + return sorted( + (key, sorted(commands)) for key, commands in owners.items() if len(commands) > 1 + ) + + +def clean_overrides(overrides: Dict[str, str]) -> Dict[str, str]: + """ + 整理使用者設定:丟掉不認得的指令與跟預設相同的項目 + Tidy the configured sequences: drop unknown commands and anything still at its default. + + 設定檔可能被手動編輯,也可能來自舊版本,因此認不得的指令直接略過。 + A settings file may be hand-edited or come from an older version, so a command + that is not recognised is simply skipped. + + :param overrides: 讀進來的設定 / the sequences as loaded + :return: 只留下真正改過的項目 / only the ones that actually differ + """ + cleaned: Dict[str, str] = {} + for command, sequence in (overrides or {}).items(): + if command not in DEFAULT_SHORTCUTS or not isinstance(sequence, str): + continue + if normalise_sequence(sequence) != normalise_sequence(DEFAULT_SHORTCUTS[command]): + cleaned[command] = sequence + return cleaned diff --git a/je_editor/utils/snippets/snippet_expand.py b/je_editor/utils/snippets/snippet_expand.py index f71e8491..76aafa3b 100644 --- a/je_editor/utils/snippets/snippet_expand.py +++ b/je_editor/utils/snippets/snippet_expand.py @@ -16,6 +16,7 @@ import re from dataclasses import dataclass +from typing import Sequence # 比對 $1 或 ${1:預設值} / Matches $1 or ${1:default} _STOP_PATTERN = re.compile(r"\$(\d+)|\$\{(\d+):([^}]*)\}") @@ -32,10 +33,13 @@ class SnippetStop: :param position: 相對於片段起點的字元位置 / offset from the snippet's start :param length: 預設值的長度(沒有預設值時為 0)/ the default value's length + :param mirrors: 同編號其他出現處的位置,長度與 ``length`` 相同 + / where the same number repeats, each the same length as ``length`` """ position: int length: int + mirrors: tuple[int, ...] = () def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: @@ -43,15 +47,19 @@ def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: 展開片段內容,回傳文字與定位點 Expand a snippet body into text and its tab stops. - 定位點依編號排序,``$0`` 排在最後;同一個編號出現多次時只取第一次。 - Stops come back in numeric order with ``$0`` last, and a number used more - than once keeps only its first appearance. + 定位點依編號排序,``$0`` 排在最後。同一個編號出現多次時,第一次是使用者要編輯 + 的那一個,其餘記在 ``mirrors`` 裡跟著它一起改。 + Stops come back in numeric order with ``$0`` last. Where a number repeats, the + first appearance is the one the user edits and the rest are recorded in + ``mirrors`` so they follow along. :param body: 片段內容 / the snippet body :return: ``(展開後的文字, 定位點清單)`` / ``(expanded text, stops)`` """ pieces: list[str] = [] stops: dict[int, SnippetStop] = {} + defaults: dict[int, str] = {} + mirrors: dict[int, list[int]] = {} length = 0 last_end = 0 for match in _STOP_PATTERN.finditer(body): @@ -59,8 +67,15 @@ def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: pieces.append(literal) length += len(literal) number = int(match.group(1) or match.group(2)) - default = match.group(3) or "" - if number not in stops: + if number in stops: + # 重複出現:寫上第一次的預設值,並記下位置好跟著一起改 + # A repeat: write the first appearance's default and note where it is + # so it can follow along + default = defaults[number] + mirrors.setdefault(number, []).append(length) + else: + default = match.group(3) or "" + defaults[number] = default stops[number] = SnippetStop(position=length, length=len(default)) pieces.append(default) length += len(default) @@ -68,7 +83,46 @@ def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: pieces.append(body[last_end:]) text = "".join(pieces) ordered = sorted(stops.items(), key=lambda item: (item[0] == _FINAL_STOP, item[0])) - return text, [stop for _number, stop in ordered] + return text, [ + SnippetStop(stop.position, stop.length, tuple(mirrors.get(number, ()))) + for number, stop in ordered + ] + + +def shift_mirrors(mirrors: Sequence[int], position: int, delta: int) -> list[int]: + """ + 使用者在某處增刪文字後,複本移到哪裡 + Where the mirrors land after the user inserts or removes text somewhere. + + :param mirrors: 複本目前的位置 / where the mirrors are now + :param position: 變更發生的位置 / where the change happened + :param delta: 字元數的增減 / how many characters were gained or lost + :return: 移動後的位置 / the positions afterwards + """ + return [start + delta if start >= position else start for start in mirrors] + + +def positions_after_mirroring(start: int, mirrors: Sequence[int], + delta: int) -> tuple[int, list[int]]: + """ + 每個複本都改寫成新長度之後,定位點與複本各自落在哪裡 + Where the stop and its mirrors land once every mirror is rewritten. + + 改寫一個複本會把它後面的所有東西往後推,因此排在越後面的複本累積的位移越多, + 而定位點只受排在它前面的複本影響。 + Rewriting a mirror pushes everything after it along, so a later mirror + accumulates more of the shift, and the stop itself only moves for the mirrors + that sit ahead of it. + + :param start: 定位點的起點 / where the stop starts + :param mirrors: 複本的起點 / where the mirrors start + :param delta: 每個複本的長度變化 / how much each mirror's length changes + :return: ``(定位點起點, 複本起點)`` / ``(stop start, mirror starts)`` + """ + ordered = sorted(mirrors) + moved_start = start + delta * sum(1 for mirror in ordered if mirror < start) + moved = [mirror + delta * index for index, mirror in enumerate(ordered)] + return moved_start, moved def default_snippets() -> dict[str, str]: diff --git a/je_editor/utils/status/__init__.py b/je_editor/utils/status/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/status/status_text.py b/je_editor/utils/status/status_text.py new file mode 100644 index 00000000..e8afb1d5 --- /dev/null +++ b/je_editor/utils/status/status_text.py @@ -0,0 +1,71 @@ +""" +組出狀態列要顯示的文字 +Build the text the status bar shows. + +純邏輯,不匯入 Qt,因此可以單獨測試;狀態列本身只負責把這些字串放上去。 +Pure logic with no Qt import so it can be tested on its own; the status bar +itself only has to put these strings on screen. +""" +from __future__ import annotations + +from pathlib import Path + +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, line_ending_name +from je_editor.utils.syntax.language_rules import rules_for + +# Python 有專屬的高亮器,因此不在通用規則表中 +# Python has a highlighter of its own and so is absent from the generic rules +_PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") +# 沒有對應語言時顯示的名稱 / Shown when no language is recognised +PLAIN_TEXT = "Plain Text" + + +def language_name(file_path: str | Path | None) -> str: + """ + 取得檔案對應的語言名稱 + The language name for a file. + + :param file_path: 檔案路徑,未存檔時為 ``None`` / the file, or ``None`` when unsaved + :return: 語言名稱,認不出來時為 ``Plain Text`` / the name, or ``Plain Text`` + """ + if file_path is None: + return PLAIN_TEXT + suffix = Path(str(file_path)).suffix.lower() + if suffix in _PYTHON_SUFFIXES: + return "Python" + rules = rules_for(suffix) + return rules.name if rules is not None else PLAIN_TEXT + + +def encoding_name(encoding: str | None) -> str: + """ + 取得編碼的顯示名稱 + The display name of an encoding. + + :param encoding: 編碼名稱 / the encoding + :return: 大寫的名稱 / the name in upper case + """ + return (encoding or DEFAULT_ENCODING).upper() + + +def line_ending_display(ending: str | None) -> str: + """ + 取得行尾的顯示名稱 + The display name of a line ending. + + :param ending: 行尾字串 / the line-ending string + :return: ``CRLF``、``LF`` 或 ``CR`` / one of ``CRLF``, ``LF`` or ``CR`` + """ + return line_ending_name(ending or "\n") + + +def cursor_position(line: int, column: int) -> str: + """ + 取得游標位置的顯示文字 + The display text for a caret position. + + :param line: 以 1 起算的行號 / the 1-based line number + :param column: 以 1 起算的欄號 / the 1-based column number + :return: 顯示文字 / the text to show + """ + return f"Ln {max(1, line)}, Col {max(1, column)}" diff --git a/je_editor/utils/test_runner/pytest_output.py b/je_editor/utils/test_runner/pytest_output.py index b3834ae4..83a5aa37 100644 --- a/je_editor/utils/test_runner/pytest_output.py +++ b/je_editor/utils/test_runner/pytest_output.py @@ -24,6 +24,57 @@ # Matches the summary line, e.g. ``3 failed, 5 passed in 0.42s`` _SUMMARY_PATTERN = re.compile(r"^=+\s*(?P.*?(?:passed|failed|error|no tests ran).*?)\s*=+$") +# 區段標題的圍籬字元:pytest 用等號或連字號把標題包起來,覆蓋率報告與結尾統計都算 +# What fences a section banner: pytest wraps its titles in equals signs or +# dashes, which covers the coverage report and the closing summary as well +_BANNER_FENCES = "=-" +# 追蹤訊息標題的圍籬字元,例如 ``____ TestThing.test_case ____`` +# What fences a traceback heading, as in ``____ TestThing.test_case ____`` +_HEADER_FENCE = "_" +# 兩側各至少要有幾個圍籬字元 / How many fence characters each side needs +_MIN_FENCE = 2 +# 比對覆蓋率報告的總計行,例如 ``TOTAL 1200 84 93%`` +# Matches a coverage report's total, e.g. ``TOTAL 1200 84 93%`` +_COVERAGE_TOTAL = re.compile(r"^TOTAL\s+\d+\s+\d+\s+(?P\d+)%") + +# 帶有追蹤訊息的區段 / The sections that carry tracebacks +_FAILURE_SECTIONS = frozenset({"FAILURES", "ERRORS"}) + + +def fenced_title(line: str, fences: str) -> str | None: + """ + 取出被圍籬字元包住的標題 + Take the title out of a line fenced by repeated characters. + + 掃描而不是用正規表示式:``^(.){2,}(.*?)(\\1){2,}$`` 這種寫法在整行都是圍籬 + 字元時會退化成指數級的回溯,而 pytest 的輸出裡這種行到處都是。 + This scans rather than matching ``^(.){2,}(.*?)(\\1){2,}$``, which backtracks + exponentially on a line made entirely of fence characters — and pytest's + output is full of those. + + :param line: 一行文字 / the line to read + :param fences: 可以當圍籬的字元 / the characters that may fence it + :return: 標題(可能為空字串);不是圍籬行時為 ``None`` + the title, possibly empty, or ``None`` when the line is not fenced + """ + stripped = line.strip() + if not stripped or stripped[0] not in fences: + return None + fence = stripped[0] + start = 0 + while start < len(stripped) and stripped[start] == fence: + start += 1 + if start == len(stripped): + # 整行都是圍籬字元:這是一條分隔線,標題是空的 + # The whole line is fence characters: a plain separator, with no title + return "" if start >= _MIN_FENCE * 2 else None + end = len(stripped) + while end > start and stripped[end - 1] == fence: + end -= 1 + if start < _MIN_FENCE or len(stripped) - end < _MIN_FENCE: + return None + return stripped[start:end].strip() + # 視為失敗的結果 / Outcomes that count as a failure FAILING_OUTCOMES = frozenset({"FAILED", "ERROR"}) @@ -124,6 +175,84 @@ def parse_failures(output: str) -> list[FailureLocation]: return failures +def parse_tracebacks(output: str) -> dict[str, str]: + """ + 解析每個失敗測試的追蹤訊息 + Parse the traceback reported for each failing test. + + pytest 會在 ``FAILURES`` 區段裡,以一行底線包住測試名稱作為每一段的開頭。 + In its ``FAILURES`` section pytest starts each block with the test's name + fenced by underscores. + + :param output: pytest 的輸出(需要 ``--tb=short`` 或更詳細) + pytest's output, with ``--tb=short`` or longer + :return: 測試名稱對應追蹤訊息 / test name -> its traceback + """ + blocks: dict[str, list[str]] = {} + current: str | None = None + in_failures = False + for line in output.splitlines(): + stripped = line.rstrip() + banner = fenced_title(stripped, _BANNER_FENCES) + if banner is not None: + in_failures = banner.upper() in _FAILURE_SECTIONS + current = None + continue + if not in_failures: + continue + header = fenced_title(stripped, _HEADER_FENCE) + if header: + current = header + blocks.setdefault(current, []) + continue + if current is not None: + blocks[current].append(stripped) + return {name: "\n".join(lines).strip() for name, lines in blocks.items() if lines} + + +def traceback_name(node_id: str) -> str: + """ + 把節點名稱換成 pytest 在追蹤訊息標題用的寫法 + Turn a node id into the name pytest heads its traceback with. + + ``test/x.py::TestThing::test_case`` 的標題是 ``TestThing.test_case``。 + ``test/x.py::TestThing::test_case`` is headed ``TestThing.test_case``. + + :param node_id: pytest 的節點名稱 / pytest's node id + :return: 追蹤訊息的標題 / the traceback's heading + """ + parts = node_id.split("::") + return ".".join(parts[1:]) if len(parts) > 1 else node_id + + +def traceback_for_result(result: PytestResult, tracebacks: dict[str, str]) -> str: + """ + 取得某個測試的追蹤訊息 + The traceback belonging to one test. + + :param result: 測試結果 / the test result + :param tracebacks: :func:`parse_tracebacks` 的結果 / what :func:`parse_tracebacks` returned + :return: 追蹤訊息,沒有時為空字串 / the traceback, or an empty string + """ + return tracebacks.get(traceback_name(result.node_id), "") + + +def parse_coverage(output: str) -> str: + """ + 取得覆蓋率報告的總計 + The total from a coverage report. + + :param output: pytest 的輸出 / pytest's output + :return: 總計百分比,例如 ``87%``;沒有報告時為空字串 + the total percentage such as ``87%``, or an empty string + """ + for line in reversed(output.splitlines()): + match = _COVERAGE_TOTAL.match(line.strip()) + if match is not None: + return f"{match.group('percent')}%" + return "" + + def parse_summary(output: str) -> str: """ 取得結尾的統計文字 diff --git a/je_editor/utils/theme/__init__.py b/je_editor/utils/theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/theme/theme_colors.py b/je_editor/utils/theme/theme_colors.py new file mode 100644 index 00000000..16bb2576 --- /dev/null +++ b/je_editor/utils/theme/theme_colors.py @@ -0,0 +1,127 @@ +""" +編輯器自有顏色的深色與淺色預設值 +The editor's own colours, in a dark and a light set. + +視窗樣式來自 qt-material,但編輯器自己畫的東西——行號、縮排參考線、變更標記、 +語法高亮——用的是這裡的顏色。它們原本只有一組值,是照深色底調的,所以選了淺色 +樣式之後那些顏色會淡到看不見。 +The window style comes from qt-material, but everything the editor paints itself +— line numbers, indent guides, change markers, syntax — uses the colours here. +There used to be only one set, tuned against a dark background, so choosing a +light style washed them out. + +純資料與純邏輯,不含 Qt。 +Pure data and pure logic, with no Qt. +""" +from __future__ import annotations + +from typing import Dict, List + +Palette = Dict[str, List[int]] + +# 深色底用的顏色 / The colours for a dark background +DARK_COLORS: Palette = { + "line_number_color": [255, 255, 255], + "line_number_background_color": [179, 179, 179], + "current_line_color": [148, 148, 184], + "normal_output_color": [255, 255, 255], + "error_output_color": [255, 0, 0], + "warning_output_color": [204, 204, 0], + "bookmark_marker_color": [66, 165, 245], + "fold_marker_color": [120, 120, 120], + "occurrence_highlight_color": [80, 90, 60], + "diff_added_marker_color": [76, 175, 80], + "diff_modified_marker_color": [255, 167, 38], + "diff_removed_marker_color": [229, 57, 53], + "lint_underline_color": [255, 138, 101], + "blame_annotation_color": [130, 130, 130], + "indent_guide_color": [90, 90, 110], + "minimap_background_color": [40, 40, 48], + "minimap_line_color": [130, 130, 150], + "minimap_viewport_color": [80, 80, 110], + "extra_cursor_color": [255, 215, 64], + "breakpoint_marker_color": [229, 57, 53], + "syntax_keyword_color": [86, 156, 214], + "syntax_string_color": [206, 145, 120], + "syntax_comment_color": [106, 153, 85], + "syntax_number_color": [181, 206, 168], + "trailing_whitespace_color": [120, 70, 70], +} + +# 淺色底用的顏色 / The colours for a light background +LIGHT_COLORS: Palette = { + "line_number_color": [90, 90, 90], + "line_number_background_color": [232, 232, 232], + "current_line_color": [225, 232, 245], + "normal_output_color": [30, 30, 30], + "error_output_color": [198, 40, 40], + "warning_output_color": [150, 120, 0], + "bookmark_marker_color": [21, 101, 192], + "fold_marker_color": [130, 130, 130], + "occurrence_highlight_color": [255, 238, 160], + "diff_added_marker_color": [46, 125, 50], + "diff_modified_marker_color": [230, 126, 0], + "diff_removed_marker_color": [198, 40, 40], + "lint_underline_color": [216, 67, 21], + "blame_annotation_color": [145, 145, 145], + "indent_guide_color": [205, 205, 215], + "minimap_background_color": [245, 245, 248], + "minimap_line_color": [160, 160, 175], + "minimap_viewport_color": [200, 205, 230], + "extra_cursor_color": [216, 98, 0], + "breakpoint_marker_color": [198, 40, 40], + "syntax_keyword_color": [0, 0, 192], + "syntax_string_color": [163, 21, 21], + "syntax_comment_color": [0, 128, 0], + "syntax_number_color": [9, 134, 88], + "trailing_whitespace_color": [255, 205, 205], +} + + +def is_light_style(style_name: str | None) -> bool: + """ + 判斷 qt-material 的樣式名稱是不是淺色的 + Whether a qt-material style name is a light one. + + :param style_name: 樣式檔名,例如 ``light_blue.xml`` / a style file name + :return: 淺色時為 ``True`` / ``True`` when it is light + """ + return str(style_name or "").strip().lower().startswith("light") + + +def palette_for(style_name: str | None) -> Palette: + """ + 取得某個樣式該用的顏色組 + The colour set a style should use. + + :param style_name: 樣式檔名 / the style file name + :return: 顏色組的副本 / a copy of the colours + """ + source = LIGHT_COLORS if is_light_style(style_name) else DARK_COLORS + return {key: list(value) for key, value in source.items()} + + +def retheme(current: Palette, style_name: str | None) -> Palette: + """ + 換主題時把預設色換成新主題的,並保留使用者自己挑過的顏色 + Swap the defaults for the new theme's, keeping any colour the user picked. + + 判斷方式是「這個值還是某一組的預設值嗎」:是的話就換成新主題的預設值,不是的 + 話代表使用者動過,原樣保留。設定檔會把整份顏色寫回去,因此無法只靠「檔案裡有 + 沒有這個鍵」來判斷。 + The test is whether a value is still one of the known defaults: if so it takes + the new theme's default, and if not the user has chosen it and it stays. The + settings file writes every colour back, so "is the key in the file" cannot + tell the two apart. + + :param current: 目前生效的顏色 / the colours in effect now + :param style_name: 要套用的樣式檔名 / the style being applied + :return: 套用後的顏色 / the colours afterwards + """ + target = palette_for(style_name) + result = dict(current) + for key, value in target.items(): + existing = current.get(key) + if existing is None or existing in (DARK_COLORS.get(key), LIGHT_COLORS.get(key)): + result[key] = list(value) + return result diff --git a/test/test_brace_regions.py b/test/test_brace_regions.py new file mode 100644 index 00000000..95637443 --- /dev/null +++ b/test/test_brace_regions.py @@ -0,0 +1,104 @@ +"""Tests for folding the languages that delimit blocks with braces.""" +from __future__ import annotations + +import pytest + +from je_editor.utils.code_folding.brace_regions import ( + compute_brace_fold_regions, fold_regions_for, uses_braces +) + + +def _regions(source: str, suffix: str = ".ts"): + return compute_brace_fold_regions(source.split("\n"), suffix) + + +class TestWhichLanguagesUseBraces: + @pytest.mark.parametrize("suffix", [".js", ".ts", ".rs", ".go", ".c", ".java"]) + def test_the_c_family_does(self, suffix): + assert uses_braces(suffix) is True + + @pytest.mark.parametrize("suffix", [".py", ".yaml", ".toml", ".sh", ""]) + def test_the_indented_ones_do_not(self, suffix): + assert uses_braces(suffix) is False + + def test_the_suffix_is_matched_case_insensitively(self): + assert uses_braces(".TS") is True + + +class TestBracePairs: + def test_a_block_folds_from_its_opening_brace(self): + regions = _regions("function f() {\n body();\n}\n") + assert (regions[0].start, regions[0].end) == (0, 1) + + def test_the_closing_brace_stays_visible(self): + regions = _regions("function f() {\n a();\n b();\n}\n") + assert 3 not in regions[0].body_lines + + def test_a_brace_on_its_own_line_still_opens_a_region(self): + # Indentation folding gets this wrong: the header is no more indented + # than its body's first line. + regions = _regions("function f()\n{\n body();\n}\n") + assert (regions[0].start, regions[0].end) == (1, 2) + + def test_nested_blocks_each_fold(self): + regions = _regions("a {\n b {\n c();\n }\n}\n") + assert [(region.start, region.end) for region in regions] == [(0, 3), (1, 2)] + + def test_a_one_line_block_is_not_foldable(self): + assert _regions("function f() { body(); }\n") == [] + + def test_an_empty_block_is_not_foldable(self): + assert _regions("function f() {\n}\n") == [] + + def test_an_unclosed_brace_yields_nothing(self): + assert _regions("function f() {\n body();\n") == [] + + def test_a_stray_closing_brace_is_ignored(self): + assert _regions("}\n") == [] + + def test_the_header_indent_is_recorded(self): + regions = _regions(" if (x) {\n y();\n }\n") + assert regions[0].indent == 4 + + +class TestBracesThatDoNotCount: + """A brace inside a string or comment would throw every pair out of step.""" + + def test_a_brace_in_a_string_is_ignored(self): + assert _regions('const a = "{";\nconst b = 2;\n') == [] + + def test_a_brace_in_a_line_comment_is_ignored(self): + assert _regions("// {\nconst b = 2;\n") == [] + + def test_a_brace_in_a_block_comment_is_ignored(self): + assert _regions("/* { */\nconst b = 2;\n") == [] + + def test_a_block_comment_spanning_lines_is_ignored(self): + assert _regions("/*\n{\n*/\nconst b = 2;\n") == [] + + def test_an_escaped_quote_does_not_end_the_string(self): + assert _regions('const a = "\\"{";\nconst b = 2;\n') == [] + + def test_a_real_block_after_a_string_still_folds(self): + regions = _regions('const a = "{";\nfunction f() {\n body();\n}\n') + assert (regions[0].start, regions[0].end) == (1, 2) + + def test_a_hash_comment_language_uses_its_own_marker(self): + # Go uses //, so a # is just text; the braces on the line still count. + regions = compute_brace_fold_regions( + "func f() { // {\n body()\n}".split("\n"), ".go") + assert (regions[0].start, regions[0].end) == (0, 1) + + +class TestChoosingHowToFold: + def test_a_brace_language_folds_on_braces(self): + regions = fold_regions_for("function f()\n{\n body();\n}".split("\n"), ".ts") + assert (regions[0].start, regions[0].end) == (1, 2) + + def test_python_still_folds_on_indentation(self): + regions = fold_regions_for("def f():\n body()\n".split("\n"), ".py") + assert (regions[0].start, regions[0].end) == (0, 1) + + def test_a_file_without_a_suffix_folds_on_indentation(self): + regions = fold_regions_for("header\n body\n".split("\n"), "") + assert (regions[0].start, regions[0].end) == (0, 1) diff --git a/test/test_breakpoints.py b/test/test_breakpoints.py index 20fe3049..e883e293 100644 --- a/test/test_breakpoints.py +++ b/test/test_breakpoints.py @@ -13,7 +13,9 @@ STEP_OVER, breakpoint_command, breakpoint_commands, + clear_command, step_command, + toggle_command, ) @@ -34,6 +36,18 @@ def test_several_breakpoints_are_sorted_and_unique(self): def test_invalid_lines_are_dropped(self): assert breakpoint_commands("app.py", [0, -2]) == [] + def test_clearing_names_the_file_and_line(self): + assert clear_command("/project/app.py", 12) == "clear /project/app.py:12" + + def test_clearing_uses_forward_slashes_too(self): + assert clear_command("D:\\project\\app.py", 3) == "clear D:/project/app.py:3" + + def test_toggling_on_sets_a_breakpoint(self): + assert toggle_command("app.py", 4, True) == "break app.py:4" + + def test_toggling_off_clears_it(self): + assert toggle_command("app.py", 4, False) == "clear app.py:4" + @pytest.mark.parametrize("action,expected", [ ("into", STEP_INTO), ("over", STEP_OVER), ("out", STEP_OUT), ("continue", CONTINUE), @@ -144,6 +158,8 @@ def test_breakpoints_are_sent_for_the_current_file(self, editor): editor.setPlainText("one\ntwo\nthree\n") _place_cursor(editor, 1) editor.toggle_breakpoint() + # Toggling sends its own change; this is about the full set going over. + stdin.write.reset_mock() assert editor.send_breakpoints_to_debugger() == 1 stdin.write.assert_called_once_with(b"break app.py:2\n") @@ -163,6 +179,7 @@ def test_starting_the_debugger_sends_them(self, editor): editor.setPlainText("one\ntwo\nthree\n") _place_cursor(editor, 2) editor.toggle_breakpoint() + stdin.write.reset_mock() widget = MagicMock() widget.code_edit = editor assert send_breakpoints(widget) == 1 @@ -176,6 +193,41 @@ def test_a_tab_without_an_editor_sends_nothing(self, editor): widget.code_edit = None assert send_breakpoints(widget) == 0 + def test_toggling_during_a_run_sets_the_breakpoint(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + assert editor.toggle_breakpoint() is True + stdin.write.assert_called_once_with(b"break app.py:2\n") + + def test_toggling_it_off_during_a_run_clears_it(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + editor.toggle_breakpoint() + stdin.write.reset_mock() + assert editor.toggle_breakpoint() is False + stdin.write.assert_called_once_with(b"clear app.py:2\n") + + def test_an_unsaved_buffer_sends_nothing(self, editor): + editor.main_window.exec_python_debugger = MagicMock() + editor.current_file = None + assert editor.send_breakpoint_change(0, True) is False + + def test_toggling_without_a_debugger_still_works(self, editor): + editor.main_window.exec_python_debugger = None + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + assert editor.toggle_breakpoint() is True + assert editor.breakpoint_manager.lines() == [0] + def test_a_broken_pipe_is_reported_rather_than_raised(self, editor): stdin = MagicMock() stdin.write.side_effect = OSError("broken pipe") diff --git a/test/test_generic_syntax.py b/test/test_generic_syntax.py index 3a19ccad..15fe79d4 100644 --- a/test/test_generic_syntax.py +++ b/test/test_generic_syntax.py @@ -1,6 +1,7 @@ """Tests for highlighting languages other than Python.""" from __future__ import annotations +import re from unittest.mock import MagicMock, patch import pytest @@ -10,6 +11,50 @@ from je_editor.utils.syntax.language_rules import LANGUAGE_RULES, rules_for, supported_suffixes +class TestKeywordPattern: + """ + All the keywords go into one alternation. Running a pattern per keyword cost + four times as much per line, which a large file feels on open. + """ + + @staticmethod + def _matches(pattern: str, text: str) -> list[str]: + return re.findall(pattern, text) + + def test_a_keyword_matches(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert self._matches(keyword_pattern(("if", "else")), "if x") == ["if"] + + def test_every_keyword_is_covered(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + pattern = keyword_pattern(("if", "else", "return")) + assert self._matches(pattern, "if a else return b") == ["if", "else", "return"] + + def test_a_keyword_inside_a_word_does_not_match(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert self._matches(keyword_pattern(("in",)), "printing") == [] + + def test_a_longer_keyword_wins_over_its_prefix(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + pattern = keyword_pattern(("in", "instanceof")) + assert self._matches(pattern, "a instanceof B") == ["instanceof"] + + def test_a_regex_character_in_a_keyword_is_literal(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert self._matches(keyword_pattern(("c++",)), "c++ code") == [] + + def test_duplicates_do_not_repeat(self): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + assert keyword_pattern(("if", "if")).count("if") == 1 + + @pytest.mark.parametrize("suffix", sorted(LANGUAGE_RULES)) + def test_every_language_builds_a_usable_pattern(self, suffix): + from je_editor.pyside_ui.code.syntax.generic_syntax import keyword_pattern + rules = rules_for(suffix) + # A pattern that does not compile would break highlighting for the language. + assert re.compile(keyword_pattern(rules.keywords)) is not None + + class TestLanguageRules: @pytest.mark.parametrize("suffix", [".ts", ".js", ".rs", ".go", ".c", ".cpp", ".java"]) def test_common_languages_have_rules(self, suffix): diff --git a/test/test_git_stash_conflicts.py b/test/test_git_stash_conflicts.py new file mode 100644 index 00000000..c7bb5094 --- /dev/null +++ b/test/test_git_stash_conflicts.py @@ -0,0 +1,133 @@ +"""Tests for stashing, listing conflicts and resolving them.""" +from __future__ import annotations + +import pytest + +from je_editor.git_client.git_action import GitService + +git = pytest.importorskip("git") + + +@pytest.fixture() +def service(tmp_path): + """A throw-away repository with one committed file, opened by the service.""" + repository = git.Repo.init(tmp_path, initial_branch="main") + with repository.config_writer() as config: + config.set_value("user", "name", "Git Tester") + config.set_value("user", "email", "git@example.com") + tracked = tmp_path / "tracked.txt" + tracked.write_text("first\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("initial") + repository.close() + instance = GitService() + instance.open_repo(str(tmp_path)) + yield instance, tmp_path + instance.repo.close() + + +class TestStashing: + def test_a_change_can_be_put_away(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + assert (root / "tracked.txt").read_text(encoding="utf-8") == "first\n" + + def test_the_stash_is_listed(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + assert any("wip" in line for line in instance.stash_list()) + + def test_nothing_is_stashed_when_nothing_changed(self, service): + instance, _root = service + instance.stash_save("empty") + assert instance.stash_list() == [] + + def test_taking_it_back_restores_the_change(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + instance.stash_pop() + assert (root / "tracked.txt").read_text(encoding="utf-8") == "changed\n" + + def test_taking_it_back_removes_it_from_the_list(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save("wip") + instance.stash_pop() + assert instance.stash_list() == [] + + def test_a_stash_without_a_message_still_works(self, service): + instance, root = service + (root / "tracked.txt").write_text("changed\n", encoding="utf-8") + instance.stash_save() + assert len(instance.stash_list()) == 1 + + +def _make_conflict(instance, root) -> None: + """Merge two branches that changed the same line, leaving a conflict.""" + repository = instance.repo + repository.git.checkout("-b", "other") + (root / "tracked.txt").write_text("theirs\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("theirs") + repository.git.checkout("main") + (root / "tracked.txt").write_text("ours\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("ours") + try: + repository.git.merge("other") + except git.GitCommandError: + # The merge is meant to fail; the conflict is the point. + pass + + +class TestConflicts: + def test_a_clean_repository_has_none(self, service): + instance, _root = service + assert instance.conflicted_files() == [] + + def test_a_conflicted_file_is_listed(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.conflicted_files() == ["tracked.txt"] + + def test_keeping_our_side_resolves_it(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.resolve_conflict("tracked.txt", "ours") is True + assert (root / "tracked.txt").read_text(encoding="utf-8") == "ours\n" + + def test_keeping_their_side_resolves_it(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.resolve_conflict("tracked.txt", "theirs") is True + assert (root / "tracked.txt").read_text(encoding="utf-8") == "theirs\n" + + def test_resolving_clears_it_from_the_list(self, service): + instance, root = service + _make_conflict(instance, root) + instance.resolve_conflict("tracked.txt", "ours") + assert instance.conflicted_files() == [] + + def test_an_unknown_side_is_refused(self, service): + instance, root = service + _make_conflict(instance, root) + assert instance.resolve_conflict("tracked.txt", "either") is False + + def test_resolving_a_file_that_is_not_in_conflict_reports_failure(self, service): + instance, _root = service + assert instance.resolve_conflict("tracked.txt", "ours") is False + + +class TestWithoutARepository: + def test_stashing_needs_an_open_repository(self): + unopened = GitService() + with pytest.raises(RuntimeError): + unopened.stash_save("wip") + + def test_listing_conflicts_needs_an_open_repository(self): + unopened = GitService() + with pytest.raises(RuntimeError): + unopened.conflicted_files() diff --git a/test/test_hunk_staging.py b/test/test_hunk_staging.py index b0e0cefb..529173d4 100644 --- a/test/test_hunk_staging.py +++ b/test/test_hunk_staging.py @@ -6,7 +6,9 @@ import pytest from PySide6.QtWidgets import QApplication -from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.git_client.file_staging import ( + commit_index, stage_content, staged_text, unstage_file +) from je_editor.utils.file_diff.line_status import apply_hunk, hunk_at_line git = pytest.importorskip("git") @@ -91,6 +93,69 @@ def test_an_untracked_file_has_no_staged_text(self, repo): assert staged_text(new_file) is None +class TestUnstaging: + """The only way back from staging a hunk that should not have been staged.""" + + def test_the_index_returns_to_the_committed_content(self, repo): + tracked = repo / "tracked.py" + stage_content(tracked, "a\nSTAGED\nc\n") + assert unstage_file(tracked) is True + assert staged_text(tracked) == "a\nb\nc\n" + + def test_the_working_tree_is_left_alone(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + stage_content(tracked, "a\nSTAGED\nc\n") + unstage_file(tracked) + assert tracked.read_text(encoding="utf-8") == "a\nWORKING\nc\n" + + def test_a_never_committed_file_leaves_the_index(self, repo): + new_file = repo / "fresh.py" + new_file.write_text("x\n", encoding="utf-8") + stage_content(new_file, "x\n") + assert unstage_file(new_file) is True + assert staged_text(new_file) is None + + def test_a_file_that_was_never_staged_changes_nothing(self, repo): + new_file = repo / "never.py" + new_file.write_text("x\n", encoding="utf-8") + assert unstage_file(new_file) is False + + def test_a_file_outside_a_repository_cannot_be_unstaged(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert unstage_file(loose) is False + + +class TestCommittingTheIndex: + """Staging hunk by hunk is only useful if the index is what gets committed.""" + + def test_only_the_staged_content_is_committed(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + stage_content(tracked, "a\nSTAGED\nc\n") + assert commit_index(tracked, "stage only") is True + repository = git.Repo(repo) + committed = (repository.head.commit.tree / "tracked.py").data_stream.read() + repository.close() + assert committed.decode("utf-8") == "a\nSTAGED\nc\n" + + def test_the_working_tree_keeps_the_unstaged_edit(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + stage_content(tracked, "a\nSTAGED\nc\n") + commit_index(tracked, "stage only") + assert tracked.read_text(encoding="utf-8") == "a\nWORKING\nc\n" + + def test_an_empty_message_is_refused(self, repo): + assert commit_index(repo / "tracked.py", " ") is False + + def test_a_file_outside_a_repository_cannot_be_committed(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert commit_index(loose, "message") is False + + @pytest.fixture(scope="module") def app(): return QApplication.instance() or QApplication([]) diff --git a/test/test_lsp_more_features.py b/test/test_lsp_more_features.py new file mode 100644 index 00000000..885ec742 --- /dev/null +++ b/test/test_lsp_more_features.py @@ -0,0 +1,254 @@ +"""Tests for signature help, references, quick fixes and document symbols.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.utils.lsp.lsp_protocol import ( + code_action_titles, document_symbols, reference_locations, signature_text +) + + +class TestSignatureText: + def test_the_label_is_taken(self): + result = {"signatures": [{"label": "join(sep: str)"}]} + assert signature_text(result) == "join(sep: str)" + + def test_the_documentation_follows_the_label(self): + result = {"signatures": [{"label": "f()", "documentation": "Does a thing"}]} + assert signature_text(result) == "f()\nDoes a thing" + + def test_markup_documentation_is_unwrapped(self): + result = {"signatures": [{ + "label": "f()", "documentation": {"kind": "markdown", "value": "Docs"}}]} + assert signature_text(result) == "f()\nDocs" + + def test_the_active_overload_wins(self): + result = {"signatures": [{"label": "one"}, {"label": "two"}], "activeSignature": 1} + assert signature_text(result) == "two" + + def test_an_out_of_range_index_falls_back_to_the_first(self): + result = {"signatures": [{"label": "one"}], "activeSignature": 9} + assert signature_text(result) == "one" + + def test_no_signatures_gives_nothing(self): + assert signature_text({"signatures": []}) == "" + + def test_a_null_result_gives_nothing(self): + assert signature_text(None) == "" + + +class TestReferenceLocations: + def test_each_location_is_read(self): + result = [ + {"uri": "file:///a.ts", "range": {"start": {"line": 3, "character": 2}}}, + {"uri": "file:///b.ts", "range": {"start": {"line": 9, "character": 0}}}, + ] + assert [item["line"] for item in reference_locations(result)] == [4, 10] + + def test_the_path_comes_out_of_the_uri(self): + result = [{"uri": "file:///a.ts", "range": {"start": {"line": 0, "character": 0}}}] + assert reference_locations(result)[0]["path"].endswith("a.ts") + + def test_an_unreadable_entry_is_skipped(self): + result = [{"no": "uri"}, {"uri": "file:///a.ts", "range": {"start": {"line": 1}}}] + assert len(reference_locations(result)) == 1 + + def test_a_null_result_gives_nothing(self): + assert reference_locations(None) == [] + + +class TestCodeActionTitles: + @staticmethod + def _action(title: str) -> dict: + return { + "title": title, + "edit": {"changes": {"file:///a.ts": [ + {"range": {"start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1}}, + "newText": "x"}]}}, + } + + def test_an_action_with_edits_is_offered(self): + assert [item["title"] for item in code_action_titles([self._action("Add import")])] == \ + ["Add import"] + + def test_the_edits_come_along(self): + assert code_action_titles([self._action("Fix")])[0]["edits"] + + def test_an_action_without_edits_is_dropped(self): + # A command-only action needs another round trip, so offering it would + # give the user a fix that does nothing when pressed. + assert code_action_titles([{"title": "Run command", "command": "do.thing"}]) == [] + + def test_an_action_without_a_title_is_dropped(self): + action = self._action("") + assert code_action_titles([action]) == [] + + def test_a_null_result_gives_nothing(self): + assert code_action_titles(None) == [] + + +class TestDocumentSymbols: + def test_a_flat_symbol_is_read(self): + result = [{"name": "main", "kind": 12, + "range": {"start": {"line": 4, "character": 0}}}] + assert document_symbols(result) == [ + {"name": "main", "kind": 12, "line": 5, "depth": 0}] + + def test_children_are_flattened_with_their_depth(self): + result = [{ + "name": "Thing", "kind": 5, + "selectionRange": {"start": {"line": 1, "character": 0}}, + "children": [{"name": "method", "kind": 6, + "selectionRange": {"start": {"line": 2, "character": 4}}}], + }] + assert [(item["name"], item["depth"]) for item in document_symbols(result)] == \ + [("Thing", 0), ("method", 1)] + + def test_the_older_format_with_a_location_is_read(self): + result = [{"name": "helper", "kind": 12, + "location": {"uri": "file:///a.ts", + "range": {"start": {"line": 7, "character": 0}}}}] + assert document_symbols(result)[0]["line"] == 8 + + def test_an_entry_without_a_name_is_skipped(self): + assert document_symbols([{"kind": 12}]) == [] + + def test_a_null_result_gives_nothing(self): + assert document_symbols(None) == [] + + +@pytest.fixture() +def client(qapp): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + from je_editor.pyside_ui.code.lsp.lsp_session import LspSession + + class _FakeSession(LspSession): + def __init__(self): + super().__init__(["fake"], "/project") + self.sent: list = [] + + @property + def running(self) -> bool: + return True + + def send(self, payload: dict) -> bool: + self.sent.append(payload) + return True + + def send_request(self, requester, payload: dict) -> bool: + return self.send(payload) + + instance = LspClient() + instance._session = _FakeSession() + instance._file_path = "/project/a.ts" + return instance + + +class TestTheClientAsksForThem: + def test_signature_help_is_requested(self, client): + assert client.request_signature_help(1, 2) is True + assert client._session.sent[-1]["method"] == "textDocument/signatureHelp" + + def test_references_ask_to_include_the_declaration(self, client): + client.request_references(1, 2) + assert client._session.sent[-1]["params"]["context"]["includeDeclaration"] is True + + def test_code_actions_carry_the_diagnostics(self, client): + client.request_code_actions(1, 2, [{"message": "unused"}]) + context = client._session.sent[-1]["params"]["context"] + assert context["diagnostics"] == [{"message": "unused"}] + + def test_document_symbols_are_requested(self, client): + assert client.request_document_symbols() is True + assert client._session.sent[-1]["method"] == "textDocument/documentSymbol" + + def test_nothing_is_requested_without_a_file(self, qapp): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + assert LspClient().request_document_symbols() is False + + +class TestTheClientReportsTheReplies: + def test_a_signature_reaches_the_editor(self, client): + client.request_signature_help(0, 0) + received: list = [] + client.signature_ready.connect(received.append) + client.handle_message({ + "id": client._pending_signature_id, + "result": {"signatures": [{"label": "f()"}]}, + }) + assert received == ["f()"] + + def test_references_reach_the_editor(self, client): + client.request_references(0, 0) + received: list = [] + client.references_ready.connect(received.append) + client.handle_message({ + "id": client._pending_references_id, + "result": [{"uri": "file:///a.ts", "range": {"start": {"line": 2}}}], + }) + assert received[0][0]["line"] == 3 + + def test_a_stale_reply_is_ignored(self, client): + client.request_references(0, 0) + received: list = [] + client.references_ready.connect(received.append) + client.handle_message({"id": 999, "result": [ + {"uri": "file:///a.ts", "range": {"start": {"line": 2}}}]}) + assert received == [] + + def test_symbols_reach_the_editor(self, client): + client.request_document_symbols() + received: list = [] + client.symbols_ready.connect(received.append) + client.handle_message({ + "id": client._pending_symbol_id, + "result": [{"name": "main", "range": {"start": {"line": 0}}}], + }) + assert received[0][0]["name"] == "main" + + +@pytest.fixture() +def editor(qapp, qtbot): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + qtbot.addWidget(code_editor) + return code_editor + + +class TestTheEditorUsesThem: + def test_a_signature_is_shown(self, editor): + editor.show_signature_text("join(sep: str)") + assert editor.toolTip() == "join(sep: str)" + + def test_no_references_means_nothing_to_show(self, editor): + assert editor.show_references([]) is False + + def test_no_fixes_means_nothing_to_show(self, editor): + assert editor.show_quick_fixes([]) is False + + def test_the_diagnostics_for_a_line_are_kept_as_the_server_sent_them(self, editor): + # A fix request has to hand the server its own objects back: the + # editor's converted form does not even count lines the same way. + first = {"range": {"start": {"line": 2, "character": 0}}, "message": "unused"} + second = {"range": {"start": {"line": 5, "character": 0}}, "message": "other"} + editor.lsp_client.handle_message({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///a.ts", "diagnostics": [first, second]}, + }) + assert editor.lsp_client.diagnostics_on_line(2) == [first] + + def test_a_line_without_diagnostics_has_none(self, editor): + editor.lsp_client.handle_message({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///a.ts", "diagnostics": []}, + }) + assert editor.lsp_client.diagnostics_on_line(2) == [] diff --git a/test/test_lsp_session.py b/test/test_lsp_session.py new file mode 100644 index 00000000..c618a0bf --- /dev/null +++ b/test/test_lsp_session.py @@ -0,0 +1,202 @@ +"""Tests that one language server is shared rather than started per editor.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.pyside_ui.code.lsp.lsp_session import LspSession, LspSessionRegistry + + +class _FakeSession(LspSession): + """A session that records what it would have sent, without a process.""" + + def __init__(self, command=None, root="/project"): + super().__init__(command or ["fake-server"], root) + self.sent: list = [] + self.started = False + + def start(self, root_uri: str) -> bool: + self.started = True + return True + + @property + def running(self) -> bool: + return self.started + + def send(self, payload: dict) -> bool: + self.sent.append(payload) + return True + + +class TestRoutingMessages: + """Two files share one process, so each reply has to find its own file.""" + + def test_a_reply_goes_to_the_client_that_asked(self): + session = _FakeSession() + asker, other = MagicMock(), MagicMock() + session.send_request(asker, {"id": 1, "method": "textDocument/hover"}) + session.route({"id": 1, "result": {}}) + asker.handle_message.assert_called_once() + other.handle_message.assert_not_called() + + def test_a_reply_is_only_delivered_once(self): + session = _FakeSession() + asker = MagicMock() + session.send_request(asker, {"id": 1, "method": "textDocument/hover"}) + session.route({"id": 1, "result": {}}) + session.route({"id": 1, "result": {}}) + assert asker.handle_message.call_count == 1 + + def test_a_reply_nobody_asked_for_is_dropped(self): + session = _FakeSession() + assert session.route({"id": 99, "result": {}}) is None + + def test_diagnostics_go_to_the_file_they_name(self): + session = _FakeSession() + first, second = MagicMock(), MagicMock() + session.register_document("file:///a.ts", first) + session.register_document("file:///b.ts", second) + session.route({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///b.ts", "diagnostics": []}, + }) + second.handle_message.assert_called_once() + first.handle_message.assert_not_called() + + def test_diagnostics_for_an_unopened_file_are_dropped(self): + session = _FakeSession() + assert session.route({ + "method": "textDocument/publishDiagnostics", + "params": {"uri": "file:///gone.ts"}, + }) is None + + def test_request_ids_do_not_repeat(self): + session = _FakeSession() + first = session.take_id() + assert session.take_id() != first + + +class TestSharingASession: + @staticmethod + def _registry(): + registry = LspSessionRegistry() + return registry + + def test_two_files_of_one_language_share_a_session(self): + registry = self._registry() + with patch( + "je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession + ): + first = registry.session_for(["fake-server"], "/project", "file:///project") + second = registry.session_for(["fake-server"], "/project", "file:///project") + assert first is second + + def test_a_different_language_gets_its_own(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + first = registry.session_for(["ts-server"], "/project", "file:///project") + second = registry.session_for(["rs-server"], "/project", "file:///project") + assert first is not second + + def test_a_different_project_gets_its_own(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + first = registry.session_for(["fake-server"], "/one", "file:///one") + second = registry.session_for(["fake-server"], "/two", "file:///two") + assert first is not second + + def test_a_server_that_will_not_start_gives_nothing(self): + registry = self._registry() + + class _Dead(_FakeSession): + def start(self, root_uri: str) -> bool: + return False + + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _Dead): + assert registry.session_for(["fake"], "/p", "file:///p") is None + + def test_a_session_still_in_use_is_not_shut_down(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + session = registry.session_for(["fake-server"], "/project", "file:///project") + session.register_document("file:///a.ts", MagicMock()) + assert registry.release(session) is False + + def test_the_last_user_shuts_it_down(self): + registry = self._registry() + with patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + session = registry.session_for(["fake-server"], "/project", "file:///project") + session.register_document("file:///a.ts", MagicMock()) + session.forget_document("file:///a.ts") + assert registry.release(session) is True + assert registry.sessions() == [] + + +@pytest.fixture() +def attached_clients(qapp): + """Two clients attached to one fake server, as two open tabs would be.""" + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + registry = LspSessionRegistry() + # server_command only answers for a server installed on this machine, which a + # fake one is not. + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry), \ + patch("je_editor.pyside_ui.code.lsp.lsp_client.server_command", + return_value=["fake-server"]), \ + patch("je_editor.pyside_ui.code.lsp.lsp_session.LspSession", _FakeSession): + first, second = LspClient(), LspClient() + first.start_for("/project/a.ts") + second.start_for("/project/b.ts") + yield registry, first, second + + +class TestTheClientUsesTheSharedSession: + def test_two_tabs_start_one_server(self, attached_clients): + registry, _first, _second = attached_clients + assert len(registry.sessions()) == 1 + + def test_both_tabs_report_it_running(self, attached_clients): + _registry, first, second = attached_clients + assert first.running and second.running + + def test_closing_one_tab_leaves_the_server_up(self, attached_clients): + registry, first, second = attached_clients + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry): + first.stop() + assert len(registry.sessions()) == 1 + assert second.running + + def test_closing_the_last_tab_shuts_it_down(self, attached_clients): + registry, first, second = attached_clients + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry): + first.stop() + second.stop() + assert registry.sessions() == [] + + def test_closing_tells_the_server_the_file_is_closed(self, attached_clients): + registry, first, _second = attached_clients + session = registry.sessions()[0] + with patch("je_editor.pyside_ui.code.lsp.lsp_client.session_registry", registry): + first.stop() + methods = [message.get("method") for message in session.sent] + assert "textDocument/didClose" in methods + + def test_a_reply_reaches_only_the_tab_that_asked(self, attached_clients): + registry, first, second = attached_clients + session = registry.sessions()[0] + received: list = [] + first.completions_ready.connect(received.append) + second.completions_ready.connect(lambda _labels: received.append("wrong tab")) + first.request_completion(0, 0) + session.route({"id": first._pending_completion_id, "result": [{"label": "ok"}]}) + assert received == [["ok"]] + + def test_saving_tells_the_server(self, attached_clients): + registry, first, _second = attached_clients + session = registry.sessions()[0] + assert first.did_save("text") is True + assert session.sent[-1]["method"] == "textDocument/didSave" + + def test_saving_without_a_file_sends_nothing(self, qapp): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + assert LspClient().did_save("text") is False diff --git a/test/test_minimap.py b/test/test_minimap.py index 1a947720..51d8ef26 100644 --- a/test/test_minimap.py +++ b/test/test_minimap.py @@ -1,6 +1,7 @@ """Tests for the minimap's geometry and its wiring into the editor.""" from __future__ import annotations + from unittest.mock import MagicMock, patch import pytest @@ -164,6 +165,43 @@ def test_markers_report_occurrences_of_the_word_under_the_caret(self, editor_wid editor_widget.code_edit.setTextCursor(cursor) assert editor_widget.minimap.marker_lines()["occurrence"] == [0, 1, 2] + def test_a_search_term_replaces_the_word_occurrences(self, editor_widget): + # What the user is looking for is the search, not the word they last + # happened to leave the caret on. + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("total = 1\nx = total\nnothing here\n") + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + editor.set_search_term("here") + assert editor_widget.minimap.marker_lines()["occurrence"] == [2] + + def test_a_search_hit_inside_a_longer_word_counts(self, editor_widget): + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("value\nother\n") + editor.set_search_term("val") + assert editor_widget.minimap.marker_lines()["occurrence"] == [0] + + def test_the_search_ignores_case(self, editor_widget): + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("Total\nnothing\n") + editor.set_search_term("total") + assert editor_widget.minimap.marker_lines()["occurrence"] == [0] + + def test_clearing_the_search_goes_back_to_occurrences(self, editor_widget): + editor_widget.toggle_minimap() + editor = editor_widget.code_edit + editor.setPlainText("total = 1\nx = total\n") + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + editor.set_search_term("total") + editor.set_search_term("") + assert editor_widget.minimap.marker_lines()["occurrence"] == [0, 1] + def test_no_markers_when_there_is_nothing_to_mark(self, editor_widget): editor_widget.toggle_minimap() editor_widget.code_edit.setPlainText("alpha\nbeta\n") diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py index b12bdd7a..431619b2 100644 --- a/test/test_multi_cursor.py +++ b/test/test_multi_cursor.py @@ -13,6 +13,7 @@ clamp_positions, column_caret_columns, column_span, + positions_after_replacing, remove_position, shift_after_delete, shift_after_insert, @@ -68,6 +69,29 @@ def test_clamping_an_empty_document(self): assert clamp_positions([3], -1) == [] +class TestPositionsAfterReplacing: + """Where each caret lands once every range is swapped for the same text.""" + + def test_an_insertion_puts_the_caret_after_it(self): + assert positions_after_replacing([(3, 3)], 2) == [5] + + def test_a_later_caret_absorbs_the_earlier_insertions(self): + assert positions_after_replacing([(0, 0), (5, 5)], 1) == [1, 7] + + def test_replacing_accounts_for_what_was_removed(self): + # "XXX" -> "Z" at 1 and 7 leaves the second caret two places earlier. + assert positions_after_replacing([(1, 4), (7, 10)], 1) == [2, 6] + + def test_deleting_pulls_the_carets_back(self): + assert positions_after_replacing([(1, 2), (5, 6)], 0) == [1, 4] + + def test_ranges_are_handled_in_position_order(self): + assert positions_after_replacing([(5, 5), (0, 0)], 1) == [1, 7] + + def test_no_ranges_gives_no_positions(self): + assert positions_after_replacing([], 3) == [] + + class TestColumnGeometry: def test_span_downwards(self): assert list(column_span(2, 5)) == [2, 3, 4, 5] @@ -131,9 +155,9 @@ def _type(editor, text: str) -> None: QTest.keyClicks(editor, text) -def _press(editor, key) -> None: - """Send one non-printable key.""" - QTest.keyClick(editor, key) +def _press(editor, key, modifier=Qt.KeyboardModifier.NoModifier) -> None: + """Send one non-printable key, optionally with a modifier held.""" + QTest.keyClick(editor, key, modifier) class TestMultiCursorEditing: @@ -216,11 +240,9 @@ def test_delete_reaches_every_line(self, editor): editor.setPlainText("one!\ntwo!\nthree!") _select_all(editor) editor.add_cursors_to_selected_lines() - # Each caret sits at its line end, so Delete takes the newline after it. + # Each caret sits at its line end, so step back one to sit before the "!". + # move_all takes the primary caret with it, so no separate nudge is needed. editor.multi_cursor_manager.move_all(-1) - cursor = editor.textCursor() - cursor.setPosition(cursor.position() - 1) - editor.setTextCursor(cursor) _press(editor, Qt.Key.Key_Delete) assert editor.toPlainText() == "one\ntwo\nthree" @@ -245,6 +267,143 @@ def test_arrow_keys_move_the_extra_carets(self, editor): _press(editor, Qt.Key.Key_Left) assert editor.multi_cursor_manager.positions() == [5] + def test_the_primary_caret_moves_along(self, editor): + # It used to stay behind, so one arrow press pulled the carets apart. + editor.setPlainText("hello world") + cursor = editor.textCursor() + cursor.setPosition(0) + editor.setTextCursor(cursor) + editor.multi_cursor_manager.toggle_at(5) + _press(editor, Qt.Key.Key_Right) + assert editor.textCursor().position() == 1 + + def test_down_moves_every_caret_a_line(self, editor): + editor.setPlainText("alpha\nbravo\ncharlie") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + editor.multi_cursor_manager.toggle_at(3) + _press(editor, Qt.Key.Key_Down) + assert editor.multi_cursor_manager.positions() == [9] + assert editor.textCursor().position() == 7 + + def test_up_moves_every_caret_a_line(self, editor): + editor.setPlainText("alpha\nbravo") + editor.multi_cursor_manager.toggle_at(8) + _press(editor, Qt.Key.Key_Up) + assert editor.multi_cursor_manager.positions() == [2] + + def test_a_caret_past_a_shorter_line_stops_at_its_end(self, editor): + editor.setPlainText("longer line\nab") + editor.multi_cursor_manager.toggle_at(9) + _press(editor, Qt.Key.Key_Down) + assert editor.multi_cursor_manager.positions() == [14] + + def test_moving_past_the_last_line_drops_nothing(self, editor): + editor.setPlainText("only") + editor.multi_cursor_manager.toggle_at(2) + _press(editor, Qt.Key.Key_Down) + assert editor.multi_cursor_manager.positions() == [2] + + def test_end_sends_every_caret_to_its_line_end(self, editor): + editor.setPlainText("alpha\nbravo") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(7) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_End) + assert editor.multi_cursor_manager.positions() == [5] + assert editor.textCursor().position() == 11 + + def test_home_sends_every_caret_to_its_line_start(self, editor): + editor.setPlainText("alpha\nbravo") + editor.multi_cursor_manager.toggle_at(4) + cursor = editor.textCursor() + cursor.setPosition(9) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Home) + assert editor.multi_cursor_manager.positions() == [0] + assert editor.textCursor().position() == 6 + + def test_vertical_movement_needs_extra_carets(self, editor): + editor.setPlainText("alpha\nbravo") + assert editor.multi_cursor_manager.move_all_vertically(1) is False + + def test_shift_right_selects_at_every_caret(self, editor): + editor.setPlainText("abcd\nefgh") + editor.multi_cursor_manager.toggle_at(0) + cursor = editor.textCursor() + cursor.setPosition(5) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(0, 1)] + assert editor.textCursor().selectedText() == "e" + + def test_the_selection_keeps_growing(self, editor): + editor.setPlainText("abcd") + editor.multi_cursor_manager.toggle_at(0) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(0, 2)] + + def test_selecting_backwards_works_too(self, editor): + editor.setPlainText("abcd") + editor.multi_cursor_manager.toggle_at(3) + _press(editor, Qt.Key.Key_Left, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(2, 3)] + + def test_shift_end_selects_to_the_line_end(self, editor): + editor.setPlainText("abcd\nefgh") + editor.multi_cursor_manager.toggle_at(1) + _press(editor, Qt.Key.Key_End, Qt.KeyboardModifier.ShiftModifier) + assert editor.multi_cursor_manager.selections() == [(1, 4)] + + def test_typing_replaces_every_selection(self, editor): + editor.setPlainText("aXc\ndXf") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(5) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Y) + assert editor.toPlainText() == "ayc\ndyf" + + def test_backspace_removes_every_selection(self, editor): + editor.setPlainText("aXc\ndXf") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(5) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Backspace) + assert editor.toPlainText() == "ac\ndf" + + def test_replacing_a_longer_selection_still_lines_up(self, editor): + editor.setPlainText("aXXXc\ndXXXf") + editor.multi_cursor_manager.toggle_at(1) + cursor = editor.textCursor() + cursor.setPosition(7) + editor.setTextCursor(cursor) + for _ in range(3): + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Z) + assert editor.toPlainText() == "azc\ndzf" + + def test_moving_without_shift_drops_the_selection(self, editor): + editor.setPlainText("abcd") + editor.multi_cursor_manager.toggle_at(0) + _press(editor, Qt.Key.Key_Right, Qt.KeyboardModifier.ShiftModifier) + _press(editor, Qt.Key.Key_Right) + assert editor.multi_cursor_manager.selections() == [] + + def test_painting_a_selection_does_not_raise(self, editor): + editor.setPlainText("abcd\nefgh") + editor.multi_cursor_manager.toggle_at(0) + _press(editor, Qt.Key.Key_Down, Qt.KeyboardModifier.ShiftModifier) + editor.show() + QApplication.processEvents() + editor.hide() + def test_carets_never_move_outside_the_document(self, editor): editor.setPlainText("ab") editor.multi_cursor_manager.toggle_at(0) diff --git a/test/test_pytest_panel.py b/test/test_pytest_panel.py index 7f4f1e57..4afebeec 100644 --- a/test/test_pytest_panel.py +++ b/test/test_pytest_panel.py @@ -9,11 +9,39 @@ from je_editor.utils.test_runner.pytest_output import ( PytestResult, failure_for_result, + parse_coverage, parse_failures, parse_results, parse_summary, + parse_tracebacks, + traceback_for_result, + traceback_name, ) +# What --tb=short actually looks like: a banner, then one block per failure +# headed by the test's name between underscores. +TRACEBACK_OUTPUT = """ +============================= test session starts ============================= +test/test_alpha.py::TestAlpha::test_two FAILED [ 50%] +test/test_beta.py::test_three FAILED [100%] + +=================================== FAILURES ================================== +_________________________ TestAlpha.test_two __________________________________ +test/test_alpha.py:42: in test_two + assert total == 2 +E AssertionError: assert 1 == 2 +______________________________ test_three _____________________________________ +test/test_beta.py:11: in test_three + raise ValueError("nope") +E ValueError: nope +---------- coverage: platform win32, python 3.11.9 ----------- +Name Stmts Miss Cover +---------------------------------------- +je_editor/thing.py 40 4 90% +TOTAL 1200 84 93% +========================= 2 failed in 0.42s =================================== +""" + SAMPLE_OUTPUT = """ ============================= test session starts ============================= collected 3 items @@ -29,6 +57,101 @@ """ +class TestFencedTitles: + """ + Banners and headings are found by scanning rather than by a regex: the + pattern for them backtracks exponentially on a line of nothing but fence + characters, and pytest's output is full of those. + """ + + def test_a_banner_title_is_read(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("===== FAILURES =====", "=-") == "FAILURES" + + def test_a_dashed_banner_is_read(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("--- coverage: win32 ---", "=-") == "coverage: win32" + + def test_a_heading_is_read(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("____ TestThing.test_case ____", "_") == "TestThing.test_case" + + def test_a_line_of_only_fence_characters_has_an_empty_title(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("=" * 200, "=-") == "" + + def test_an_ordinary_line_is_not_fenced(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("assert 1 == 2", "=-") is None + + def test_one_fence_character_is_not_enough(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title("=title=", "=-") is None + + def test_a_blank_line_is_not_fenced(self): + from je_editor.utils.test_runner.pytest_output import fenced_title + assert fenced_title(" ", "=-") is None + + +class TestParseTracebacks: + """The panel shows why a test failed, not only that it did.""" + + def test_each_failure_gets_its_own_block(self): + assert set(parse_tracebacks(TRACEBACK_OUTPUT)) == {"TestAlpha.test_two", "test_three"} + + def test_the_block_holds_the_assertion(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + assert "assert 1 == 2" in blocks["TestAlpha.test_two"] + + def test_a_block_stops_at_the_next_failure(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + assert "ValueError" not in blocks["TestAlpha.test_two"] + + def test_the_section_ends_at_the_next_banner(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + assert "2 failed" not in blocks["test_three"] + + def test_output_without_failures_has_no_blocks(self): + assert parse_tracebacks(SAMPLE_OUTPUT.replace("FAILURES", "warnings summary")) == {} + + def test_empty_output_has_no_blocks(self): + assert parse_tracebacks("") == {} + + +class TestTracebackNames: + def test_a_class_based_test_joins_with_a_dot(self): + assert traceback_name("test/test_a.py::TestThing::test_case") == "TestThing.test_case" + + def test_a_plain_test_keeps_its_name(self): + assert traceback_name("test/test_a.py::test_case") == "test_case" + + def test_something_without_a_node_separator_is_unchanged(self): + assert traceback_name("test_case") == "test_case" + + def test_a_result_finds_its_traceback(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + result = PytestResult(node_id="test/test_alpha.py::TestAlpha::test_two", + outcome="FAILED") + assert "assert 1 == 2" in traceback_for_result(result, blocks) + + def test_a_passing_test_has_no_traceback(self): + blocks = parse_tracebacks(TRACEBACK_OUTPUT) + result = PytestResult(node_id="test/test_alpha.py::TestAlpha::test_one", + outcome="PASSED") + assert traceback_for_result(result, blocks) == "" + + +class TestParseCoverage: + def test_the_total_is_read(self): + assert parse_coverage(TRACEBACK_OUTPUT) == "93%" + + def test_output_without_coverage_reports_nothing(self): + assert parse_coverage(SAMPLE_OUTPUT) == "" + + def test_a_per_file_line_is_not_mistaken_for_the_total(self): + assert parse_coverage("je_editor/thing.py 40 4 90%") == "" + + class TestParseResults: def test_every_reported_test_is_read(self): assert len(parse_results(SAMPLE_OUTPUT)) == 3 @@ -116,6 +239,59 @@ def panel(app, tmp_path): widget.deleteLater() +class TestTracebackPane: + """Seeing why a test failed without leaving the panel.""" + + def test_it_starts_empty(self, panel): + assert panel.traceback_view.toPlainText() == "" + + def test_selecting_a_failure_shows_its_traceback(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + panel.result_tree.setCurrentItem(panel.result_tree.topLevelItem(0)) + assert "assert 1 == 2" in panel.traceback_view.toPlainText() + + def test_each_failure_shows_its_own(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + panel.result_tree.setCurrentItem(panel.result_tree.topLevelItem(1)) + assert "ValueError" in panel.traceback_view.toPlainText() + + def test_a_passing_test_shows_nothing(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + rows = [panel.result_tree.topLevelItem(index) + for index in range(panel.result_tree.topLevelItemCount())] + passing = next(row for row in rows if row.text(0) == "PASSED") + panel.result_tree.setCurrentItem(passing) + assert panel.traceback_view.toPlainText() == "" + + def test_a_new_run_clears_the_previous_traceback(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + panel.result_tree.setCurrentItem(panel.result_tree.topLevelItem(0)) + panel.apply_output(SAMPLE_OUTPUT) + assert panel.traceback_view.toPlainText() == "" + + +class TestCoverageOption: + def test_it_is_off_by_default(self, panel): + assert panel.coverage_check.isChecked() is False + + def test_the_command_leaves_coverage_out_by_default(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + assert not any(argument.startswith("--cov") for argument in pytest_command()) + + def test_asking_for_coverage_adds_the_flags(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + command = pytest_command(with_coverage=True) + assert "--cov=." in command and "--cov-report=term" in command + + def test_the_total_reaches_the_status_label(self, panel): + panel.apply_output(TRACEBACK_OUTPUT) + assert "93%" in panel.status_label.text() + + def test_a_run_without_coverage_leaves_the_summary_alone(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert "%" not in panel.status_label.text() + + class TestTestPanel: def test_starts_empty(self, panel): assert panel.results() == [] @@ -159,7 +335,7 @@ def test_command_is_an_argument_list(self): from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command command = pytest_command() assert command[1:3] == ["-m", "pytest"] - assert "-v" in command and "--tb=line" in command + assert "-v" in command and "--tb=short" in command def test_command_can_target_specific_tests(self): from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command diff --git a/test/test_shortcut_registry.py b/test/test_shortcut_registry.py index b2ebdcf1..1a1b225b 100644 --- a/test/test_shortcut_registry.py +++ b/test/test_shortcut_registry.py @@ -69,7 +69,7 @@ def test_clashes_are_collected(self): assert registry.conflicts() == [("ctrl+d", "duplicate_line", "next_occurrence")] def test_reserved_sequences_are_already_taken(self): - registry = ShortcutRegistry({"Ctrl+Shift+P": "pip_install"}) + registry = ShortcutRegistry({"pip_install": "Ctrl+Shift+P"}) assert registry.register("Ctrl+Shift+P", "play_macro") == "pip_install" def test_register_all_reports_only_its_own_clashes(self): @@ -94,12 +94,12 @@ class TestWindowShortcutTable: """ def test_no_sequence_is_listed_twice(self): - normalised = [normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS] + normalised = [normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()] assert len(normalised) == len(set(normalised)) def test_save_all_moved_off_the_editors_sort_shortcut(self): assert normalise_sequence("Ctrl+Alt+S") not in { - normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()} @pytest.fixture() @@ -135,7 +135,7 @@ def test_no_two_actions_share_a_sequence(self, editor): assert len(sequences) == len(set(sequences)) def test_no_action_takes_a_menu_sequence(self, editor): - reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()} taken = { normalise_sequence(action.shortcut().toString()) for action in editor.actions() @@ -143,6 +143,18 @@ def test_no_action_takes_a_menu_sequence(self, editor): } assert taken & reserved == set() + def test_shortcuts_are_scoped_to_the_focused_editor(self, editor): + # A split view shows two editors of the same document at once. With the + # window-level default every sequence would have two owners, and Qt fires + # neither of them. + from PySide6.QtCore import Qt + contexts = { + action.shortcutContext() + for action in editor.actions() + if action.shortcut().toString() + } + assert contexts == {Qt.ShortcutContext.WidgetWithChildrenShortcut} + def test_duplicate_line_still_owns_control_d(self, editor): # Ctrl+D is handled in keyPressEvent; no action may shadow it. assert editor.shortcut_registry.owner_of("Ctrl+D") is None diff --git a/test/test_shortcut_settings.py b/test/test_shortcut_settings.py new file mode 100644 index 00000000..6d8ee529 --- /dev/null +++ b/test/test_shortcut_settings.py @@ -0,0 +1,187 @@ +"""Tests for the shortcut table, user overrides and the settings dialog.""" +from __future__ import annotations + +import pytest + +from je_editor.utils.shortcuts.shortcut_registry import ( + DEFAULT_SHORTCUTS, EDITOR_SHORTCUTS, WINDOW_SHORTCUTS, clean_overrides, + effective_shortcuts, find_conflicts, normalise_sequence, sequence_for +) + + +class TestTheDefaultTable: + def test_it_covers_both_halves(self): + assert set(DEFAULT_SHORTCUTS) == set(WINDOW_SHORTCUTS) | set(EDITOR_SHORTCUTS) + + def test_the_two_halves_do_not_overlap(self): + assert set(WINDOW_SHORTCUTS) & set(EDITOR_SHORTCUTS) == set() + + def test_nothing_is_claimed_twice(self): + # A duplicate here would make Qt fire neither of the two commands. + assert find_conflicts(DEFAULT_SHORTCUTS) == [] + + def test_every_command_has_keys(self): + assert all(sequence for sequence in DEFAULT_SHORTCUTS.values()) + + def test_every_default_survives_a_round_trip_through_qt(self, qapp): + # A default Qt spells differently would be recorded as a user change the + # first time the settings dialog is saved. + from PySide6.QtGui import QKeySequence + for command, sequence in DEFAULT_SHORTCUTS.items(): + assert normalise_sequence(QKeySequence(sequence).toString()) == \ + normalise_sequence(sequence), command + + +class TestLookingUpASequence: + def test_an_unconfigured_command_uses_its_default(self): + assert sequence_for("save_file") == DEFAULT_SHORTCUTS["save_file"] + + def test_a_configured_command_uses_the_override(self): + assert sequence_for("save_file", {"save_file": "Ctrl+Alt+W"}) == "Ctrl+Alt+W" + + def test_an_override_for_another_command_is_ignored(self): + assert sequence_for("save_file", {"open_file": "Ctrl+Alt+W"}) == \ + DEFAULT_SHORTCUTS["save_file"] + + def test_an_empty_override_removes_the_shortcut(self): + assert sequence_for("save_file", {"save_file": ""}) == "" + + def test_an_unknown_command_has_no_sequence(self): + assert sequence_for("no_such_command") == "" + + def test_the_effective_table_covers_every_command(self): + assert set(effective_shortcuts()) == set(DEFAULT_SHORTCUTS) + + def test_the_effective_table_applies_overrides(self): + table = effective_shortcuts({"save_file": "Ctrl+Alt+W"}) + assert table["save_file"] == "Ctrl+Alt+W" + + +class TestFindingConflicts: + def test_two_commands_on_one_sequence_are_reported(self): + assert find_conflicts({"a": "Ctrl+G", "b": "Ctrl+G"}) == [("ctrl+g", ["a", "b"])] + + def test_the_comparison_ignores_case_and_order(self): + assert find_conflicts({"a": "Ctrl+Shift+G", "b": "shift+ctrl+g"}) != [] + + def test_distinct_sequences_are_fine(self): + assert find_conflicts({"a": "Ctrl+G", "b": "Ctrl+H"}) == [] + + def test_unassigned_commands_do_not_clash(self): + assert find_conflicts({"a": "", "b": ""}) == [] + + +class TestCleaningOverrides: + def test_a_real_change_is_kept(self): + assert clean_overrides({"save_file": "Ctrl+Alt+W"}) == {"save_file": "Ctrl+Alt+W"} + + def test_a_value_still_at_its_default_is_dropped(self): + assert clean_overrides({"save_file": DEFAULT_SHORTCUTS["save_file"]}) == {} + + def test_a_default_written_differently_is_still_a_default(self): + assert clean_overrides({"command_palette": "shift+ctrl+a"}) == {} + + def test_an_unknown_command_is_dropped(self): + assert clean_overrides({"no_such_command": "Ctrl+G"}) == {} + + def test_a_non_string_is_dropped(self): + assert clean_overrides({"save_file": 5}) == {} + + def test_nothing_configured_stays_nothing(self): + assert clean_overrides({}) == {} + + +@pytest.fixture() +def dialog(qapp, qtbot): + """The settings dialog, with the real command table behind it.""" + from je_editor.pyside_ui.dialog.shortcut_dialog.shortcut_settings_dialog import ( + ShortcutSettingsDialog + ) + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + saved = dict(user_setting_dict.get("shortcuts") or {}) + widget = ShortcutSettingsDialog() + qtbot.addWidget(widget) + yield widget + user_setting_dict["shortcuts"] = saved + + +class TestTheSettingsDialog: + def test_it_lists_every_command(self, dialog): + assert dialog.tree.topLevelItemCount() == len(DEFAULT_SHORTCUTS) + + def test_it_starts_from_the_current_shortcuts(self, dialog): + assert normalise_sequence(dialog.current_shortcuts()["save_file"]) == \ + normalise_sequence(DEFAULT_SHORTCUTS["save_file"]) + + def test_it_starts_without_conflicts(self, dialog): + assert dialog.conflicts() == [] + + def test_a_clash_is_reported(self, dialog): + from PySide6.QtGui import QKeySequence + dialog._editors["save_file"].setKeySequence( + QKeySequence(DEFAULT_SHORTCUTS["open_file"])) + assert dialog.conflicts() != [] + + def test_a_clash_cannot_be_saved(self, dialog): + from PySide6.QtGui import QKeySequence + dialog._editors["save_file"].setKeySequence( + QKeySequence(DEFAULT_SHORTCUTS["open_file"])) + assert dialog.save() is False + assert dialog.save_button.isEnabled() is False + + def test_a_change_is_stored(self, dialog): + from PySide6.QtGui import QKeySequence + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + assert dialog.save() is True + assert user_setting_dict["shortcuts"]["save_all"] == "Ctrl+Alt+W" + + def test_untouched_commands_are_not_stored(self, dialog): + from PySide6.QtGui import QKeySequence + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + dialog.save() + assert set(user_setting_dict["shortcuts"]) == {"save_all"} + + def test_saving_reaches_the_menus_already_built(self, dialog): + # The menus and the toolbar are built once at startup, so a saved change + # has to be pushed to them rather than waiting for the next launch. + from PySide6.QtGui import QAction, QKeySequence + from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind + action = QAction() + bind(action, "save_all") + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + dialog.save() + assert normalise_sequence(action.shortcut().toString()) == \ + normalise_sequence("Ctrl+Alt+W") + + def test_restoring_defaults_clears_a_change(self, dialog): + from PySide6.QtGui import QKeySequence + dialog._editors["save_all"].setKeySequence(QKeySequence("Ctrl+Alt+W")) + dialog.reset_to_defaults() + assert normalise_sequence(dialog.current_shortcuts()["save_all"]) == \ + normalise_sequence(DEFAULT_SHORTCUTS["save_all"]) + + +class TestTheEditorFollowsTheSetting: + def test_a_configured_sequence_reaches_the_action(self, qapp, qtbot): + from unittest.mock import MagicMock, patch + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + saved = dict(user_setting_dict.get("shortcuts") or {}) + user_setting_dict["shortcuts"] = {"go_to_line": "Ctrl+Alt+W"} + try: + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import ( + CodeEditor + ) + editor = CodeEditor(parent) + qtbot.addWidget(editor) + assert normalise_sequence(editor.goto_line_action.shortcut().toString()) == \ + normalise_sequence("Ctrl+Alt+W") + finally: + user_setting_dict["shortcuts"] = saved diff --git a/test/test_snippets.py b/test/test_snippets.py index a641a93d..e68b8cdd 100644 --- a/test/test_snippets.py +++ b/test/test_snippets.py @@ -14,9 +14,69 @@ default_snippets, expand_snippet, merge_snippets, + positions_after_mirroring, + shift_mirrors, ) +class TestExpandingRepeatedStops: + def test_a_repeat_is_not_a_separate_stop(self): + _text, stops = expand_snippet("${1:name} = ${1:name}") + assert len(stops) == 1 + + def test_the_repeat_is_recorded_as_a_mirror(self): + # "name = name" -- the second copy starts at index 7. + _text, stops = expand_snippet("${1:name} = ${1:name}") + assert stops[0].mirrors == (7,) + + def test_the_repeat_carries_the_first_default(self): + text, _stops = expand_snippet("${1:name} = $1") + assert text == "name = name" + + def test_several_repeats_are_all_recorded(self): + _text, stops = expand_snippet("$1 $1 $1") + assert len(stops[0].mirrors) == 2 + + def test_a_stop_used_once_has_no_mirrors(self): + _text, stops = expand_snippet("${1:one} ${2:two}") + assert all(stop.mirrors == () for stop in stops) + + +class TestShiftingMirrors: + def test_a_mirror_after_the_change_moves(self): + assert shift_mirrors([10], 4, 3) == [13] + + def test_a_mirror_before_the_change_stays(self): + assert shift_mirrors([2], 4, 3) == [2] + + def test_a_deletion_pulls_it_back(self): + assert shift_mirrors([10], 4, -2) == [8] + + def test_a_mirror_exactly_at_the_change_moves(self): + assert shift_mirrors([4], 4, 3) == [7] + + +class TestPositionsAfterMirroring: + def test_nothing_moves_when_the_length_is_unchanged(self): + assert positions_after_mirroring(0, [10], 0) == (0, [10]) + + def test_a_later_mirror_absorbs_every_earlier_rewrite(self): + _start, mirrors = positions_after_mirroring(0, [10, 20], 2) + assert mirrors == [10, 22] + + def test_the_stop_moves_for_mirrors_ahead_of_it(self): + start, _mirrors = positions_after_mirroring(30, [10, 20], 2) + assert start == 34 + + def test_the_stop_ignores_mirrors_behind_it(self): + start, _mirrors = positions_after_mirroring(0, [10, 20], 2) + assert start == 0 + + def test_mirrors_are_handled_in_position_order(self): + _start, mirrors = positions_after_mirroring(0, [20, 10], 5) + assert mirrors == [10, 25] + + class TestExpandSnippet: def test_plain_text_has_no_stops(self): text, stops = expand_snippet("print('hello')") @@ -151,6 +211,57 @@ def _press_tab(editor) -> None: QTest.keyClick(editor, Qt.Key.Key_Tab) +class TestMirroredStops: + """ + A number used more than once in a snippet should only be typed once. The + repeats used to be plain text, so `${1:name}` twice meant typing it twice. + """ + + @staticmethod + def _expand(editor, body: str) -> None: + editor.snippet_manager._snippets["mirror"] = body + editor.setPlainText("mirror") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + + def test_a_repeat_starts_with_the_same_default(self, editor): + self._expand(editor, "${1:name} = ${1:name}") + assert editor.toPlainText() == "name = name" + + def test_typing_updates_the_repeat(self, editor): + self._expand(editor, "${1:name} = ${1:name}") + editor.textCursor().insertText("total") + assert editor.toPlainText() == "total = total" + + def test_every_repeat_follows(self, editor): + self._expand(editor, "${1:x}, ${1:x}, ${1:x}") + editor.textCursor().insertText("ab") + assert editor.toPlainText() == "ab, ab, ab" + + def test_a_repeat_after_other_text_follows(self, editor): + self._expand(editor, "def ${1:name}():\n return ${1:name}") + editor.textCursor().insertText("run") + assert editor.toPlainText() == "def run():\n return run" + + def test_typing_elsewhere_leaves_the_repeat_alone(self, editor): + self._expand(editor, "${1:name} = ${1:name}") + cursor = editor.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + cursor.insertText(" # note") + assert editor.toPlainText() == "name = name # note" + + def test_a_stop_without_repeats_still_works(self, editor): + self._expand(editor, "${1:one} ${2:two}") + editor.textCursor().insertText("first") + assert editor.toPlainText() == "first two" + + def test_moving_on_stops_the_mirroring(self, editor): + self._expand(editor, "${1:name} = ${1:name}$0") + _press_tab(editor) + editor.textCursor().insertText("!") + assert editor.toPlainText() == "name = name!" + + class TestSnippetExpansionInEditor: def test_tab_expands_a_trigger_word(self, editor): editor.setPlainText("for") diff --git a/test/test_status_text.py b/test/test_status_text.py new file mode 100644 index 00000000..579a60e2 --- /dev/null +++ b/test/test_status_text.py @@ -0,0 +1,193 @@ +"""Tests for what the status bar says about the current tab.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.utils.status.status_text import ( + PLAIN_TEXT, cursor_position, encoding_name, language_name, line_ending_display +) + + +class TestLanguageName: + @pytest.mark.parametrize("path,expected", [ + ("app.py", "Python"), + ("stub.pyi", "Python"), + ("script.pyw", "Python"), + ("main.rs", "Rust"), + ("index.ts", "TypeScript"), + ("data.json", "JSON"), + ("query.sql", "SQL"), + ]) + def test_a_known_suffix_names_its_language(self, path, expected): + assert language_name(path) == expected + + def test_the_suffix_is_matched_case_insensitively(self): + assert language_name("APP.PY") == "Python" + + def test_an_unknown_suffix_is_plain_text(self): + assert language_name("notes.qqq") == PLAIN_TEXT + + def test_a_file_without_a_suffix_is_plain_text(self): + assert language_name("Makefile") == PLAIN_TEXT + + def test_an_unsaved_buffer_is_plain_text(self): + assert language_name(None) == PLAIN_TEXT + + def test_a_full_path_still_resolves(self): + assert language_name("/home/user/project/main.go") == "Go" + + +class TestEncodingName: + def test_it_is_upper_cased(self): + assert encoding_name("utf-8") == "UTF-8" + + def test_nothing_falls_back_to_the_default(self): + assert encoding_name(None) == "UTF-8" + + def test_another_encoding_is_shown_as_given(self): + assert encoding_name("big5") == "BIG5" + + +class TestLineEndingDisplay: + @pytest.mark.parametrize("ending,expected", [ + ("\r\n", "CRLF"), ("\n", "LF"), ("\r", "CR"), + ]) + def test_each_ending_has_a_name(self, ending, expected): + assert line_ending_display(ending) == expected + + def test_nothing_falls_back_to_lf(self): + assert line_ending_display(None) == "LF" + + +class TestCursorPosition: + def test_it_reads_as_line_and_column(self): + assert cursor_position(3, 12) == "Ln 3, Col 12" + + def test_positions_below_one_are_clamped(self): + assert cursor_position(0, 0) == "Ln 1, Col 1" + + +@pytest.fixture() +def editor_tab(qapp, qtbot): + """An EditorWidget with a mocked main window, as the other tab tests use.""" + from PySide6.QtWidgets import QTabWidget + mock_main = MagicMock() + mock_main.working_dir = None + mock_main.tab_widget = QTabWidget() + mock_main.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(mock_main) + mock_main.tab_widget.addTab(widget, "Test") + qtbot.addWidget(mock_main.tab_widget) + yield mock_main, widget + widget.close() + + +def _status_window(tab_widget): + """A bare main window with just the status-bar labels the refresh touches.""" + from PySide6.QtWidgets import QLabel + from je_editor.pyside_ui.main_ui.main_editor import EditorMain + window = EditorMain.__new__(EditorMain) # pylint: disable=no-value-for-parameter + window.tab_widget = tab_widget + window._language_label = QLabel() + window._line_ending_label = QLabel() + window._encoding_label = QLabel() + window._cursor_pos_label = QLabel() + return window + + +class TestTheStatusBarReadsTheTab: + """ + It has to describe the tab in front of the user. The encoding label used to + read the global setting and the line-ending label re-read the file from disk + on every tab change, so neither followed a change made from the menu. + """ + + def test_the_language_comes_from_the_file(self, editor_tab): + _window, widget = editor_tab + widget.current_file = "app.py" + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._language_label.text() == "Python" + + def test_an_unsaved_buffer_is_plain_text(self, editor_tab): + _window, widget = editor_tab + widget.current_file = None + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._language_label.text() == PLAIN_TEXT + + def test_the_line_ending_comes_from_the_tab(self, editor_tab): + _window, widget = editor_tab + widget.line_ending = "\r\n" + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._line_ending_label.text() == "CRLF" + + def test_the_encoding_comes_from_the_tab(self, editor_tab): + _window, widget = editor_tab + widget.file_encoding = "big5" + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._encoding_label.text() == "BIG5" + + def test_the_caret_position_is_shown(self, editor_tab): + _window, widget = editor_tab + widget.code_edit.setPlainText("one\ntwo\n") + cursor = widget.code_edit.textCursor() + cursor.setPosition(5) + widget.code_edit.setTextCursor(cursor) + window = _status_window(_window.tab_widget) + window.refresh_status_bar() + assert window._cursor_pos_label.text() == "Ln 2, Col 2" + + def test_a_tab_that_is_not_an_editor_shows_defaults(self, qapp, qtbot): + from PySide6.QtWidgets import QTabWidget, QWidget + tabs = QTabWidget() + qtbot.addWidget(tabs) + tabs.addTab(QWidget(), "Browser") + window = _status_window(tabs) + window.refresh_status_bar() + assert window._language_label.text() == PLAIN_TEXT + assert window._encoding_label.text() == "UTF-8" + assert window._cursor_pos_label.text() == "Ln 1, Col 1" + + +class TestRefreshingFromTheMenu: + """ + Changing the encoding or line ending has to move the status bar with it. + The labels used to read the global setting and the bytes on disk, so a menu + change left them describing the file as it was opened. + """ + + def test_the_line_ending_choice_asks_for_a_refresh(self, editor_tab): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_line_ending_choice + ) + window, widget = editor_tab + assert apply_line_ending_choice(window, "\r\n") is True + assert widget.line_ending == "\r\n" + window.refresh_status_bar.assert_called() + + def test_the_encoding_choice_asks_for_a_refresh(self, editor_tab): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_encoding + ) + window, widget = editor_tab + assert apply_encoding(window, "big5") is True + assert widget.file_encoding == "big5" + window.refresh_status_bar.assert_called() + + def test_a_window_without_a_status_bar_is_tolerated(self, editor_tab): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_line_ending_choice + ) + window, _widget = editor_tab + window.refresh_status_bar = None + assert apply_line_ending_choice(window, "\n") is True diff --git a/test/test_theme_colors.py b/test/test_theme_colors.py new file mode 100644 index 00000000..63abc900 --- /dev/null +++ b/test/test_theme_colors.py @@ -0,0 +1,104 @@ +"""Tests that the editor's own colours follow the chosen window style.""" +from __future__ import annotations + +import pytest + +from je_editor.utils.theme.theme_colors import ( + DARK_COLORS, LIGHT_COLORS, is_light_style, palette_for, retheme +) + + +class TestRecognisingALightStyle: + @pytest.mark.parametrize("name", [ + "light_blue.xml", "light_amber.xml", "LIGHT_RED.XML", " light_teal.xml ", + ]) + def test_a_light_style_is_recognised(self, name): + assert is_light_style(name) is True + + @pytest.mark.parametrize("name", ["dark_amber.xml", "dark_teal.xml", "", None]) + def test_anything_else_is_dark(self, name): + assert is_light_style(name) is False + + +class TestThePalettes: + def test_both_sets_cover_the_same_colours(self): + assert set(DARK_COLORS) == set(LIGHT_COLORS) + + def test_every_colour_is_a_valid_rgb_triple(self): + for palette in (DARK_COLORS, LIGHT_COLORS): + for key, value in palette.items(): + assert len(value) == 3, key + assert all(0 <= channel <= 255 for channel in value), key + + def test_the_light_set_really_is_lighter_behind_the_text(self): + # The minimap draws a background, so it has to invert with the theme. + assert sum(LIGHT_COLORS["minimap_background_color"]) > \ + sum(DARK_COLORS["minimap_background_color"]) + + def test_a_light_style_gets_the_light_set(self): + assert palette_for("light_blue.xml") == LIGHT_COLORS + + def test_a_dark_style_gets_the_dark_set(self): + assert palette_for("dark_amber.xml") == DARK_COLORS + + def test_the_palette_is_a_copy(self): + palette = palette_for("dark_amber.xml") + palette["line_number_color"][0] = 1 + assert DARK_COLORS["line_number_color"][0] != 1 + + +class TestRethemeing: + def test_a_default_moves_to_the_new_theme(self): + result = retheme(dict(DARK_COLORS), "light_blue.xml") + assert result["minimap_background_color"] == LIGHT_COLORS["minimap_background_color"] + + def test_switching_back_restores_the_dark_default(self): + light = retheme(dict(DARK_COLORS), "light_blue.xml") + assert retheme(light, "dark_amber.xml") == DARK_COLORS + + def test_a_colour_the_user_picked_survives(self): + current = dict(DARK_COLORS) + current["line_number_color"] = [10, 20, 30] + result = retheme(current, "light_blue.xml") + assert result["line_number_color"] == [10, 20, 30] + + def test_the_other_colours_still_move(self): + current = dict(DARK_COLORS) + current["line_number_color"] = [10, 20, 30] + result = retheme(current, "light_blue.xml") + assert result["indent_guide_color"] == LIGHT_COLORS["indent_guide_color"] + + def test_a_missing_colour_is_filled_in(self): + result = retheme({}, "light_blue.xml") + assert result == LIGHT_COLORS + + def test_the_input_is_not_modified(self): + current = dict(DARK_COLORS) + retheme(current, "light_blue.xml") + assert current["indent_guide_color"] == DARK_COLORS["indent_guide_color"] + + +class TestApplyingToTheSettings: + def test_applying_a_light_style_updates_the_live_colours(self): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict, apply_theme_colors, user_setting_color_dict + ) + try: + apply_theme_colors("light_blue.xml") + assert user_setting_color_dict["minimap_background_color"] == \ + LIGHT_COLORS["minimap_background_color"] + # The QColor dictionary the painting reads has to move with it. + colour = actually_color_dict["minimap_background_color"] + actual = [colour.red(), colour.green(), colour.blue()] + assert actual == LIGHT_COLORS["minimap_background_color"] + finally: + apply_theme_colors("dark_amber.xml") + + def test_applying_a_dark_style_puts_them_back(self): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + apply_theme_colors, user_setting_color_dict + ) + apply_theme_colors("light_blue.xml") + apply_theme_colors("dark_amber.xml") + assert user_setting_color_dict["minimap_background_color"] == \ + DARK_COLORS["minimap_background_color"] diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py index c7b0b0fa..8e1858f9 100644 --- a/test/test_toolbar_actions.py +++ b/test/test_toolbar_actions.py @@ -63,7 +63,7 @@ def test_new_actions_are_on_the_toolbar(self, toolbar_window): def test_every_toolbar_shortcut_is_reserved(self, toolbar_window): # The editor checks its own shortcuts against this table, so a sequence # the toolbar takes without listing it there could be claimed twice. - reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS.values()} for action in toolbar_window.main_toolbar.actions(): sequence = normalise_sequence(action.shortcut().toString()) if sequence: diff --git a/test/test_word_occurrences.py b/test/test_word_occurrences.py index 08225192..afc663a6 100644 --- a/test/test_word_occurrences.py +++ b/test/test_word_occurrences.py @@ -1,13 +1,43 @@ """Tests for word-under-cursor detection and occurrence finding.""" from __future__ import annotations + from je_editor.utils.occurrence.word_occurrences import ( find_occurrences, is_highlightable_word, + lines_containing, word_at, ) +class TestLinesContaining: + """Backs the search marks, so it matches the typed string, not whole words.""" + + def test_it_reports_the_line(self): + assert lines_containing("alpha\nbeta\ngamma\n", "beta") == [1] + + def test_every_matching_line_is_reported(self): + assert lines_containing("a\nb\na\n", "a") == [0, 2] + + def test_a_partial_word_matches(self): + assert lines_containing("value = 1\n", "val") == [0] + + def test_case_is_ignored_by_default(self): + assert lines_containing("Total\n", "total") == [0] + + def test_case_can_be_required(self): + assert lines_containing("Total\n", "total", case_sensitive=True) == [] + + def test_a_line_matching_twice_is_reported_once(self): + assert lines_containing("aa\n", "a") == [0] + + def test_an_empty_term_matches_nothing(self): + assert lines_containing("anything\n", "") == [] + + def test_a_term_that_is_absent_matches_nothing(self): + assert lines_containing("alpha\n", "zzz") == [] + + class TestWordAt: def test_inside_word(self): assert word_at("foo bar", 1) == ("foo", 0, 3)