Coverage for vibefpmgui/line_edits.py: 92%
173 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Edit menu commands on lines and selections: Toggle Line Comment, Move
2Line Up / Down, Sort Lines, Change Case.
4The text work is in plain functions (no Qt: `toggle_comment`, `sort_lines`,
5`change_case`); `LineEditsMixin` (a part of `vibefpmgui.code_editor.CodeEditor`)
6applies them to the document, each as one step to undo.
7"""
9from __future__ import annotations
11import re
12from pathlib import Path
14from qtpy.QtGui import QTextCursor
16from .complexity import FIXED_FORM, FORTRAN
18# A file's line comment, by its suffix or name ("": none, e.g. Markdown's).
19HASH_COMMENTS = {".toml", ".py", ".sh", ".bash", ".zsh", ".yaml", ".yml", ".cmake", ".mk", ".cfg", ".ini",
20 ".r", ".pl", ".rb", ".nml"}
21SLASH_COMMENTS = {".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".js", ".ts", ".java", ".cs", ".go", ".rs",
22 ".swift", ".kt", ".cu"}
23HASH_NAMES = {"makefile", "gnumakefile", "cmakelists.txt", "dockerfile", ".gitignore"}
26def comment_style(path: Path | str) -> tuple[str, bool]:
27 """(the line comment for this file, whether it's fixed-form Fortran):
28 "!" for Fortran, "#" for TOML, Python, shell…, "//" for C and the like,
29 "" for a file without a line comment."""
30 path = Path(path)
31 suffix = path.suffix.lower()
32 if suffix in FORTRAN or suffix in (".inc", ".fi", ".fypp"):
33 return "!", suffix in FIXED_FORM
34 if suffix in HASH_COMMENTS or path.name.lower() in HASH_NAMES:
35 return "#", False
36 if suffix in SLASH_COMMENTS:
37 return "//", False
38 return "", False
41def _commented(line: str, prefix: str, fixed: bool) -> bool:
42 if fixed:
43 return line.startswith(prefix)
44 rest = line.lstrip()
45 # (A directive, as "!$omp parallel", is code, not a comment.)
46 return rest.startswith(prefix) and not (prefix == "!" and rest.startswith("!$"))
49def toggle_comment(lines: list[str], prefix: str, fixed: bool = False) -> list[str]:
50 """The lines commented out, or, if every one with code already is, back
51 as they were. A comment goes at the lines' least indentation ("! x = 1"),
52 or, in fixed form, in the first column ("!" there is a comment; at the
53 sixth it would make the line a continuation). Blank lines stay blank."""
54 code = [line for line in lines if line.strip()]
55 if not prefix or not code:
56 return list(lines)
57 if all(_commented(line, prefix, fixed) for line in code):
58 out = []
59 for line in lines:
60 if not line.strip() or not _commented(line, prefix, fixed):
61 out.append(line)
62 elif fixed:
63 out.append(line[len(prefix):])
64 else:
65 start = len(line) - len(line.lstrip())
66 rest = line[start + len(prefix):]
67 out.append(line[:start] + (rest.removeprefix(" ")))
68 return out
69 if fixed:
70 return [prefix + line if line.strip() else line for line in lines]
71 indent = min(len(line) - len(line.lstrip()) for line in code)
72 return [line[:indent] + prefix + " " + line[indent:] if line.strip() else line for line in lines]
75def sort_lines(lines: list[str], descending: bool = False) -> list[str]:
76 """The lines in order, ignoring case (and, where only case differs, as given)."""
77 return sorted(lines, key=lambda line: (line.casefold(), line), reverse=descending)
80UPPER, LOWER, TITLE = "upper", "lower", "title"
83def change_case(text: str, case: str) -> str:
84 """UPPER CASE, lower case, or Title Case (each word's first letter, the rest lower)."""
85 if case == UPPER:
86 return text.upper()
87 if case == LOWER:
88 return text.lower()
89 return re.sub(r"[^\W_]+", lambda m: m.group()[:1].upper() + m.group()[1:].lower(), text)
92def _qt_length(text: str) -> int:
93 """Its length in the document's positions (UTF-16: a character beyond U+FFFF counts twice)."""
94 return len(text) if text.isascii() else len(text.encode("utf-16-le")) // 2
97class LineEditsMixin:
98 """Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case
99 (a part of `vibefpmgui.code_editor.CodeEditor`)."""
101 comment_prefix = "!" # this file's line comment ("" for none: see comment_style)
102 fixed_form = False # fixed-form Fortran (its comments go in the first column)
104 def _edit_cursor(self) -> QTextCursor:
105 """A cursor for an edit, where the user's is: an Undo puts the cursor
106 back where its edit began (one at the document's start sent it to
107 the top of the file)."""
108 return QTextCursor(self.textCursor())
110 def _line_span(self, cursor: QTextCursor) -> tuple[int, int]:
111 """The lines (0-based, first and last) a cursor's selection is on: not
112 the last if the selection only reaches its start."""
113 doc = self.document()
114 start, end = sorted((cursor.anchor(), cursor.position()))
115 first = doc.findBlock(start).blockNumber()
116 last_block = doc.findBlock(end)
117 last = last_block.blockNumber()
118 if last > first and end == last_block.position(): 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 last -= 1
120 return first, last
122 def _lines(self, first: int, last: int) -> list[str]:
123 doc = self.document()
124 return [doc.findBlockByNumber(n).text() for n in range(first, last + 1)]
126 def _replace_lines(self, cursor: QTextCursor, first: int, last: int, lines: list[str]):
127 """Lines ``first`` to ``last`` become ``lines`` (as many), with ``cursor``."""
128 doc = self.document()
129 end = doc.findBlockByNumber(last)
130 cursor.setPosition(doc.findBlockByNumber(first).position())
131 cursor.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor)
132 cursor.insertText("\n".join(lines))
134 def toggle_comment(self):
135 """Edit → Toggle Line Comment: the lines of the selection (and of the
136 other cursors'), commented out or back."""
137 if not self.comment_prefix: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 return
139 spans = sorted({self._line_span(c) for c in [self.textCursor(), *self.extra_cursors]})
140 lines = sorted({n for first, last in spans for n in range(first, last + 1)})
141 if not lines: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 return
143 all_lines = self._lines(lines[0], lines[-1])
144 chosen = [all_lines[n - lines[0]] for n in lines]
145 changed = toggle_comment(chosen, self.comment_prefix, self.fixed_form)
146 if changed == chosen: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 return
148 new = list(all_lines)
149 for n, line in zip(lines, changed, strict=True):
150 new[n - lines[0]] = line
151 main = self.textCursor()
152 line, column = main.blockNumber(), main.positionInBlock()
153 anchor_block = self.document().findBlock(main.anchor())
154 anchor_line, anchor_column = anchor_block.blockNumber(), main.anchor() - anchor_block.position()
155 cursor = self._edit_cursor()
156 cursor.beginEditBlock()
157 self._replace_lines(cursor, lines[0], lines[-1], new)
158 cursor.endEditBlock()
159 # The selection stays on the same lines (its columns moved by what was put in or taken out).
160 self._select_columns(anchor_line, anchor_column, line, column, all_lines, new, lines[0])
162 def _select_columns(self, anchor_line, anchor_column, line, column, old, new, first):
163 def moved(n: int, col: int) -> int:
164 if not first <= n < first + len(old): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 return col
166 before, after = old[n - first], new[n - first]
167 shift = _qt_length(after) - _qt_length(before)
168 indent = _qt_length(before) - _qt_length(before.lstrip())
169 return max(0, col + shift) if col >= indent or self.fixed_form else col
171 doc = self.document()
172 cursor = QTextCursor(doc)
173 block = doc.findBlockByNumber(anchor_line)
174 cursor.setPosition(block.position() + min(moved(anchor_line, anchor_column), block.length() - 1))
175 block = doc.findBlockByNumber(line)
176 cursor.setPosition(block.position() + min(moved(line, column), block.length() - 1), QTextCursor.KeepAnchor)
177 self.setTextCursor(cursor)
179 def move_lines(self, down: bool):
180 """Edit → Lines → Move Line Up / Down: the selection's lines swap
181 places with the line above or below, the selection going with them."""
182 doc = self.document()
183 main = self.textCursor()
184 first, last = self._line_span(main)
185 if (not down and first == 0) or (down and last >= doc.blockCount() - 1):
186 return
187 lines = self._lines(first, last)
188 if down:
189 neighbour = doc.findBlockByNumber(last + 1)
190 new, span, shift = [neighbour.text(), *lines], (first, last + 1), neighbour.length()
191 else:
192 neighbour = doc.findBlockByNumber(first - 1)
193 new, span, shift = [*lines, neighbour.text()], (first - 1, last), -neighbour.length()
194 anchor, position = main.anchor(), main.position()
195 cursor = self._edit_cursor()
196 cursor.beginEditBlock()
197 self._replace_lines(cursor, *span, new)
198 cursor.endEditBlock()
199 moved = QTextCursor(doc)
200 moved.setPosition(anchor + shift)
201 moved.setPosition(position + shift, QTextCursor.KeepAnchor)
202 self.setTextCursor(moved)
204 def sort_selected_lines(self, descending: bool = False):
205 """Edit → Lines → Sort Lines: the selection's lines (two or more) in order."""
206 first, last = self._line_span(self.textCursor())
207 if last <= first: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 return
209 lines = self._lines(first, last)
210 ordered = sort_lines(lines, descending)
211 if ordered == lines: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 return
213 cursor = self._edit_cursor()
214 cursor.beginEditBlock()
215 self._replace_lines(cursor, first, last, ordered)
216 cursor.endEditBlock()
217 doc = self.document()
218 end = doc.findBlockByNumber(last)
219 selection = QTextCursor(doc)
220 selection.setPosition(doc.findBlockByNumber(first).position())
221 selection.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor)
222 self.setTextCursor(selection)
224 def change_selection_case(self, case: str):
225 """Edit → Change Case: each selection (the main one and the other
226 cursors'), or the word at a cursor without one."""
227 cursors = [self.textCursor(), *self.extra_cursors]
228 edit = self._edit_cursor()
229 edit.beginEditBlock()
230 for c in cursors:
231 target = QTextCursor(c)
232 if not target.hasSelection(): 232 ↛ 234line 232 didn't jump to line 234 because the condition on line 232 was always true
233 target.select(QTextCursor.WordUnderCursor)
234 text = target.selectedText()
235 new = change_case(text, case)
236 if text and new != text: 236 ↛ 230line 236 didn't jump to line 230 because the condition on line 236 was always true
237 selected = c.hasSelection()
238 start = target.selectionStart()
239 target.insertText(new)
240 if selected: # (still selected; its length may change, as "ß" → "SS") 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 c.setPosition(start)
242 c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor)
243 edit.endEditBlock()
244 self.setTextCursor(cursors[0])