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

1"""Edit menu commands on lines and selections: Toggle Line Comment, Move 

2Line Up / Down, Sort Lines, Change Case. 

3 

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""" 

8 

9from __future__ import annotations 

10 

11import re 

12from pathlib import Path 

13 

14from qtpy.QtGui import QTextCursor 

15 

16from .complexity import FIXED_FORM, FORTRAN 

17 

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"} 

24 

25 

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 

39 

40 

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("!$")) 

47 

48 

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] 

73 

74 

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) 

78 

79 

80UPPER, LOWER, TITLE = "upper", "lower", "title" 

81 

82 

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) 

90 

91 

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 

95 

96 

97class LineEditsMixin: 

98 """Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case 

99 (a part of `vibefpmgui.code_editor.CodeEditor`).""" 

100 

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) 

103 

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()) 

109 

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 

121 

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)] 

125 

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)) 

133 

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]) 

161 

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 

170 

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) 

178 

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) 

203 

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) 

223 

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])