vibefpmgui.line_edits

Edit menu commands on lines and selections: Toggle Line Comment, Move Line Up / Down, Sort Lines, Change Case.

The text work is in plain functions (no Qt: toggle_comment, sort_lines, change_case); LineEditsMixin (a part of vibefpmgui.code_editor.CodeEditor) applies them to the document, each as one step to undo.

  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():
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:
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:
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:
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):
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:
208            return
209        lines = self._lines(first, last)
210        ordered = sort_lines(lines, descending)
211        if ordered == lines:
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():
233                target.select(QTextCursor.WordUnderCursor)
234            text = target.selectedText()
235            new = change_case(text, case)
236            if text and new != text:
237                selected = c.hasSelection()
238                start = target.selectionStart()
239                target.insertText(new)
240                if selected:  # (still selected; its length may change, as "ß" → "SS")
241                    c.setPosition(start)
242                    c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor)
243        edit.endEditBlock()
244        self.setTextCursor(cursors[0])
HASH_COMMENTS = {'.mk', '.pl', '.rb', '.r', '.py', '.yaml', '.zsh', '.nml', '.bash', '.ini', '.toml', '.cfg', '.cmake', '.sh', '.yml'}
SLASH_COMMENTS = {'.kt', '.cpp', '.ts', '.cu', '.cs', '.swift', '.hpp', '.js', '.cc', '.rs', '.hh', '.go', '.h', '.cxx', '.java', '.c'}
HASH_NAMES = {'gnumakefile', 'cmakelists.txt', '.gitignore', 'makefile', 'dockerfile'}
def comment_style(path: pathlib.Path | str) -> tuple[str, bool]:
27def comment_style(path: Path | str) -> tuple[str, bool]:
28    """(the line comment for this file, whether it's fixed-form Fortran):
29    "!" for Fortran, "#" for TOML, Python, shell…, "//" for C and the like,
30    "" for a file without a line comment."""
31    path = Path(path)
32    suffix = path.suffix.lower()
33    if suffix in FORTRAN or suffix in (".inc", ".fi", ".fypp"):
34        return "!", suffix in FIXED_FORM
35    if suffix in HASH_COMMENTS or path.name.lower() in HASH_NAMES:
36        return "#", False
37    if suffix in SLASH_COMMENTS:
38        return "//", False
39    return "", False

(the line comment for this file, whether it's fixed-form Fortran): "!" for Fortran, "#" for TOML, Python, shell…, "//" for C and the like, "" for a file without a line comment.

def toggle_comment(lines: list[str], prefix: str, fixed: bool = False) -> list[str]:
50def toggle_comment(lines: list[str], prefix: str, fixed: bool = False) -> list[str]:
51    """The lines commented out, or, if every one with code already is, back
52    as they were. A comment goes at the lines' least indentation ("! x = 1"),
53    or, in fixed form, in the first column ("!" there is a comment; at the
54    sixth it would make the line a continuation). Blank lines stay blank."""
55    code = [line for line in lines if line.strip()]
56    if not prefix or not code:
57        return list(lines)
58    if all(_commented(line, prefix, fixed) for line in code):
59        out = []
60        for line in lines:
61            if not line.strip() or not _commented(line, prefix, fixed):
62                out.append(line)
63            elif fixed:
64                out.append(line[len(prefix):])
65            else:
66                start = len(line) - len(line.lstrip())
67                rest = line[start + len(prefix):]
68                out.append(line[:start] + (rest.removeprefix(" ")))
69        return out
70    if fixed:
71        return [prefix + line if line.strip() else line for line in lines]
72    indent = min(len(line) - len(line.lstrip()) for line in code)
73    return [line[:indent] + prefix + " " + line[indent:] if line.strip() else line for line in lines]

The lines commented out, or, if every one with code already is, back as they were. A comment goes at the lines' least indentation ("! x = 1"), or, in fixed form, in the first column ("!" there is a comment; at the sixth it would make the line a continuation). Blank lines stay blank.

def sort_lines(lines: list[str], descending: bool = False) -> list[str]:
76def sort_lines(lines: list[str], descending: bool = False) -> list[str]:
77    """The lines in order, ignoring case (and, where only case differs, as given)."""
78    return sorted(lines, key=lambda line: (line.casefold(), line), reverse=descending)

The lines in order, ignoring case (and, where only case differs, as given).

def change_case(text: str, case: str) -> str:
84def change_case(text: str, case: str) -> str:
85    """UPPER CASE, lower case, or Title Case (each word's first letter, the rest lower)."""
86    if case == UPPER:
87        return text.upper()
88    if case == LOWER:
89        return text.lower()
90    return re.sub(r"[^\W_]+", lambda m: m.group()[:1].upper() + m.group()[1:].lower(), text)

UPPER CASE, lower case, or Title Case (each word's first letter, the rest lower).

class LineEditsMixin:
 98class LineEditsMixin:
 99    """Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case
100    (a part of `vibefpmgui.code_editor.CodeEditor`)."""
101
102    comment_prefix = "!"   # this file's line comment ("" for none: see comment_style)
103    fixed_form = False     # fixed-form Fortran (its comments go in the first column)
104
105    def _edit_cursor(self) -> QTextCursor:
106        """A cursor for an edit, where the user's is: an Undo puts the cursor
107        back where its edit began (one at the document's start sent it to
108        the top of the file)."""
109        return QTextCursor(self.textCursor())
110
111    def _line_span(self, cursor: QTextCursor) -> tuple[int, int]:
112        """The lines (0-based, first and last) a cursor's selection is on: not
113        the last if the selection only reaches its start."""
114        doc = self.document()
115        start, end = sorted((cursor.anchor(), cursor.position()))
116        first = doc.findBlock(start).blockNumber()
117        last_block = doc.findBlock(end)
118        last = last_block.blockNumber()
119        if last > first and end == last_block.position():
120            last -= 1
121        return first, last
122
123    def _lines(self, first: int, last: int) -> list[str]:
124        doc = self.document()
125        return [doc.findBlockByNumber(n).text() for n in range(first, last + 1)]
126
127    def _replace_lines(self, cursor: QTextCursor, first: int, last: int, lines: list[str]):
128        """Lines ``first`` to ``last`` become ``lines`` (as many), with ``cursor``."""
129        doc = self.document()
130        end = doc.findBlockByNumber(last)
131        cursor.setPosition(doc.findBlockByNumber(first).position())
132        cursor.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor)
133        cursor.insertText("\n".join(lines))
134
135    def toggle_comment(self):
136        """Edit → Toggle Line Comment: the lines of the selection (and of the
137        other cursors'), commented out or back."""
138        if not self.comment_prefix:
139            return
140        spans = sorted({self._line_span(c) for c in [self.textCursor(), *self.extra_cursors]})
141        lines = sorted({n for first, last in spans for n in range(first, last + 1)})
142        if not lines:
143            return
144        all_lines = self._lines(lines[0], lines[-1])
145        chosen = [all_lines[n - lines[0]] for n in lines]
146        changed = toggle_comment(chosen, self.comment_prefix, self.fixed_form)
147        if changed == chosen:
148            return
149        new = list(all_lines)
150        for n, line in zip(lines, changed, strict=True):
151            new[n - lines[0]] = line
152        main = self.textCursor()
153        line, column = main.blockNumber(), main.positionInBlock()
154        anchor_block = self.document().findBlock(main.anchor())
155        anchor_line, anchor_column = anchor_block.blockNumber(), main.anchor() - anchor_block.position()
156        cursor = self._edit_cursor()
157        cursor.beginEditBlock()
158        self._replace_lines(cursor, lines[0], lines[-1], new)
159        cursor.endEditBlock()
160        # The selection stays on the same lines (its columns moved by what was put in or taken out).
161        self._select_columns(anchor_line, anchor_column, line, column, all_lines, new, lines[0])
162
163    def _select_columns(self, anchor_line, anchor_column, line, column, old, new, first):
164        def moved(n: int, col: int) -> int:
165            if not first <= n < first + len(old):
166                return col
167            before, after = old[n - first], new[n - first]
168            shift = _qt_length(after) - _qt_length(before)
169            indent = _qt_length(before) - _qt_length(before.lstrip())
170            return max(0, col + shift) if col >= indent or self.fixed_form else col
171
172        doc = self.document()
173        cursor = QTextCursor(doc)
174        block = doc.findBlockByNumber(anchor_line)
175        cursor.setPosition(block.position() + min(moved(anchor_line, anchor_column), block.length() - 1))
176        block = doc.findBlockByNumber(line)
177        cursor.setPosition(block.position() + min(moved(line, column), block.length() - 1), QTextCursor.KeepAnchor)
178        self.setTextCursor(cursor)
179
180    def move_lines(self, down: bool):
181        """Edit → Lines → Move Line Up / Down: the selection's lines swap
182        places with the line above or below, the selection going with them."""
183        doc = self.document()
184        main = self.textCursor()
185        first, last = self._line_span(main)
186        if (not down and first == 0) or (down and last >= doc.blockCount() - 1):
187            return
188        lines = self._lines(first, last)
189        if down:
190            neighbour = doc.findBlockByNumber(last + 1)
191            new, span, shift = [neighbour.text(), *lines], (first, last + 1), neighbour.length()
192        else:
193            neighbour = doc.findBlockByNumber(first - 1)
194            new, span, shift = [*lines, neighbour.text()], (first - 1, last), -neighbour.length()
195        anchor, position = main.anchor(), main.position()
196        cursor = self._edit_cursor()
197        cursor.beginEditBlock()
198        self._replace_lines(cursor, *span, new)
199        cursor.endEditBlock()
200        moved = QTextCursor(doc)
201        moved.setPosition(anchor + shift)
202        moved.setPosition(position + shift, QTextCursor.KeepAnchor)
203        self.setTextCursor(moved)
204
205    def sort_selected_lines(self, descending: bool = False):
206        """Edit → Lines → Sort Lines: the selection's lines (two or more) in order."""
207        first, last = self._line_span(self.textCursor())
208        if last <= first:
209            return
210        lines = self._lines(first, last)
211        ordered = sort_lines(lines, descending)
212        if ordered == lines:
213            return
214        cursor = self._edit_cursor()
215        cursor.beginEditBlock()
216        self._replace_lines(cursor, first, last, ordered)
217        cursor.endEditBlock()
218        doc = self.document()
219        end = doc.findBlockByNumber(last)
220        selection = QTextCursor(doc)
221        selection.setPosition(doc.findBlockByNumber(first).position())
222        selection.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor)
223        self.setTextCursor(selection)
224
225    def change_selection_case(self, case: str):
226        """Edit → Change Case: each selection (the main one and the other
227        cursors'), or the word at a cursor without one."""
228        cursors = [self.textCursor(), *self.extra_cursors]
229        edit = self._edit_cursor()
230        edit.beginEditBlock()
231        for c in cursors:
232            target = QTextCursor(c)
233            if not target.hasSelection():
234                target.select(QTextCursor.WordUnderCursor)
235            text = target.selectedText()
236            new = change_case(text, case)
237            if text and new != text:
238                selected = c.hasSelection()
239                start = target.selectionStart()
240                target.insertText(new)
241                if selected:  # (still selected; its length may change, as "ß" → "SS")
242                    c.setPosition(start)
243                    c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor)
244        edit.endEditBlock()
245        self.setTextCursor(cursors[0])

Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case (a part of vibefpmgui.code_editor.CodeEditor).

comment_prefix = '!'
fixed_form = False
def toggle_comment(self):
135    def toggle_comment(self):
136        """Edit → Toggle Line Comment: the lines of the selection (and of the
137        other cursors'), commented out or back."""
138        if not self.comment_prefix:
139            return
140        spans = sorted({self._line_span(c) for c in [self.textCursor(), *self.extra_cursors]})
141        lines = sorted({n for first, last in spans for n in range(first, last + 1)})
142        if not lines:
143            return
144        all_lines = self._lines(lines[0], lines[-1])
145        chosen = [all_lines[n - lines[0]] for n in lines]
146        changed = toggle_comment(chosen, self.comment_prefix, self.fixed_form)
147        if changed == chosen:
148            return
149        new = list(all_lines)
150        for n, line in zip(lines, changed, strict=True):
151            new[n - lines[0]] = line
152        main = self.textCursor()
153        line, column = main.blockNumber(), main.positionInBlock()
154        anchor_block = self.document().findBlock(main.anchor())
155        anchor_line, anchor_column = anchor_block.blockNumber(), main.anchor() - anchor_block.position()
156        cursor = self._edit_cursor()
157        cursor.beginEditBlock()
158        self._replace_lines(cursor, lines[0], lines[-1], new)
159        cursor.endEditBlock()
160        # The selection stays on the same lines (its columns moved by what was put in or taken out).
161        self._select_columns(anchor_line, anchor_column, line, column, all_lines, new, lines[0])

Edit → Toggle Line Comment: the lines of the selection (and of the other cursors'), commented out or back.

def move_lines(self, down: bool):
180    def move_lines(self, down: bool):
181        """Edit → Lines → Move Line Up / Down: the selection's lines swap
182        places with the line above or below, the selection going with them."""
183        doc = self.document()
184        main = self.textCursor()
185        first, last = self._line_span(main)
186        if (not down and first == 0) or (down and last >= doc.blockCount() - 1):
187            return
188        lines = self._lines(first, last)
189        if down:
190            neighbour = doc.findBlockByNumber(last + 1)
191            new, span, shift = [neighbour.text(), *lines], (first, last + 1), neighbour.length()
192        else:
193            neighbour = doc.findBlockByNumber(first - 1)
194            new, span, shift = [*lines, neighbour.text()], (first - 1, last), -neighbour.length()
195        anchor, position = main.anchor(), main.position()
196        cursor = self._edit_cursor()
197        cursor.beginEditBlock()
198        self._replace_lines(cursor, *span, new)
199        cursor.endEditBlock()
200        moved = QTextCursor(doc)
201        moved.setPosition(anchor + shift)
202        moved.setPosition(position + shift, QTextCursor.KeepAnchor)
203        self.setTextCursor(moved)

Edit → Lines → Move Line Up / Down: the selection's lines swap places with the line above or below, the selection going with them.

def sort_selected_lines(self, descending: bool = False):
205    def sort_selected_lines(self, descending: bool = False):
206        """Edit → Lines → Sort Lines: the selection's lines (two or more) in order."""
207        first, last = self._line_span(self.textCursor())
208        if last <= first:
209            return
210        lines = self._lines(first, last)
211        ordered = sort_lines(lines, descending)
212        if ordered == lines:
213            return
214        cursor = self._edit_cursor()
215        cursor.beginEditBlock()
216        self._replace_lines(cursor, first, last, ordered)
217        cursor.endEditBlock()
218        doc = self.document()
219        end = doc.findBlockByNumber(last)
220        selection = QTextCursor(doc)
221        selection.setPosition(doc.findBlockByNumber(first).position())
222        selection.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor)
223        self.setTextCursor(selection)

Edit → Lines → Sort Lines: the selection's lines (two or more) in order.

def change_selection_case(self, case: str):
225    def change_selection_case(self, case: str):
226        """Edit → Change Case: each selection (the main one and the other
227        cursors'), or the word at a cursor without one."""
228        cursors = [self.textCursor(), *self.extra_cursors]
229        edit = self._edit_cursor()
230        edit.beginEditBlock()
231        for c in cursors:
232            target = QTextCursor(c)
233            if not target.hasSelection():
234                target.select(QTextCursor.WordUnderCursor)
235            text = target.selectedText()
236            new = change_case(text, case)
237            if text and new != text:
238                selected = c.hasSelection()
239                start = target.selectionStart()
240                target.insertText(new)
241                if selected:  # (still selected; its length may change, as "ß" → "SS")
242                    c.setPosition(start)
243                    c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor)
244        edit.endEditBlock()
245        self.setTextCursor(cursors[0])

Edit → Change Case: each selection (the main one and the other cursors'), or the word at a cursor without one.