Last active
August 24, 2026 03:18
-
-
Save acidnine/bae991ad9daa714c2ef18cbba7572f02 to your computer and use it in GitHub Desktop.
XED Plugin Column Edit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [Plugin] | |
| Loader=python3 | |
| Module=column_edit | |
| IAge=2 | |
| Name=Column Edit | |
| Description=Ctrl+Shift+drag box/column selection with column insert and column delete modes (Sublime/VSCode-style). Works with Selection Stats plugin. | |
| Authors=Acidnine; Gemini / ChatGPT / Claude | |
| Copyright=Copyright © 2026 Acidnine | |
| Website=https://vault21.net/ | |
| Version=1.0.0 | |
| Hidden=false |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Column Edit plugin for Xed. | |
| Adds Sublime/VSCode-style box (column/vertical) selection: | |
| Ctrl+Shift + click, then drag (up/down and optionally left/right) | |
| While a box selection is active: | |
| - typing a character -> inserts that character at the selection's | |
| column on every line in the selection | |
| (Column Insert Mode) | |
| - Backspace / Delete -> deletes a character (or the selected column | |
| range) on every line in the selection | |
| (Column Delete Mode) | |
| - Escape / click elsewhere / focus-out -> exits box mode | |
| Column math: | |
| The column is derived from the pixel position of your click, using the | |
| width of a single space glyph in the view's current font as the | |
| per-character width. Where the mouse position lands on top of real | |
| text, the real character grid is used instead (so it stays accurate | |
| even in a proportional font, or with tabs). Only *past* the end of a | |
| line does it fall back to the fixed-width assumption, since that's a | |
| "virtual" column that doesn't exist yet. | |
| Padding rule: | |
| A line that is shorter than the target column is left completely | |
| alone until you actually type or delete something that would land on | |
| it. Only then is it padded with spaces up to that column. Lines you | |
| never touch are never padded. | |
| """ | |
| import gi | |
| import math | |
| import os | |
| import warnings | |
| gi.require_version("Gtk", "3.0") | |
| gi.require_version("Xed", "1.0") | |
| gi.require_version("PangoCairo", "1.0") | |
| from gi.repository import GObject, GLib, Gtk, Gdk, Xed, PangoCairo # noqa: E402 | |
| DEBUG = bool(os.environ.get("XED_DEBUG_COLUMN_EDIT")) | |
| def _dbg(*args): | |
| if DEBUG: | |
| print("[column-edit]", *args) | |
| # PyGObject keeps its own bookkeeping of Python-side signal closures and | |
| # tries to tidy them up when the underlying GObject is garbage collected. | |
| # Since we already disconnect our handlers ourselves in do_deactivate(), | |
| # that later automatic cleanup can find a handler id we already removed | |
| # and warn about it - harmless (GObject drops all handlers on finalize | |
| # regardless), but noisy. It fires well after our own disconnect call has | |
| # returned, so a local try/except or context manager can't catch it; a | |
| # standing filter on this specific message is what actually works. | |
| warnings.filterwarnings( | |
| "ignore", | |
| message=r".*has no handler with id.*", | |
| category=Warning, | |
| module=r"gi\.overrides\.GObject", | |
| ) | |
| # ============================================================================ | |
| # Appearance / behavior config - edit these to taste, then re-enable the | |
| # plugin (or restart xed) to pick up changes. | |
| # ============================================================================ | |
| # Shown when the box has real width (col_max > col_min) - i.e. typing will | |
| # delete/replace this range on every line. Reddish by default so it reads | |
| # as "this text is about to go away." | |
| REPLACE_HIGHLIGHT_RGBA = "rgba(230, 70, 70, 0.35)" | |
| # Shown when the box is zero-width (pure vertical insert points) - only | |
| # used if SHOW_INSERT_HIGHLIGHT is True below. | |
| INSERT_HIGHLIGHT_RGBA = "rgba(90, 150, 255, 0.30)" | |
| # Zero-width column selections (just insert carets, nothing will be | |
| # deleted) are invisible by default. Set True to show a faint highlight | |
| # for those too. | |
| SHOW_INSERT_HIGHLIGHT = False | |
| # Small in-editor badge (top-right corner of the view) showing the current | |
| # column and whether text will be replaced. Xed's real statusbar isn't | |
| # reachable from a ViewActivatable (it's private to XedWindow), so this | |
| # draws directly on the view instead. | |
| SHOW_STATUS_OVERLAY = True | |
| STATUS_OVERLAY_BG_RGBA = (0.10, 0.10, 0.12, 0.85) | |
| STATUS_OVERLAY_FG_RGBA = (1.0, 1.0, 1.0, 0.95) | |
| # Draws a thin caret on every selected row at the column where typing would | |
| # actually land (the left edge of the range - col_min - since that's where | |
| # a replace/insert starts). This is the only indicator for a zero-width | |
| # (pure insert) column selection, since that case has no highlight by | |
| # default; for a wide selection it sits at the highlight's left edge. | |
| SHOW_MULTI_CURSOR = True | |
| MULTI_CURSOR_RGBA = (1.0, 1.0, 1.0, 0.95) | |
| MULTI_CURSOR_WIDTH_PX = 2 | |
| # Blink the carets like a normal text cursor. Uses GTK's own blink interval | |
| # when available, falling back to a fixed 530ms. | |
| MULTI_CURSOR_BLINK = True | |
| # ============================================================================ | |
| class ColumnEditPlugin(GObject.Object, Xed.ViewActivatable): | |
| """ | |
| Xed instantiates one of these per open XedView automatically (via | |
| XED_TYPE_VIEW_ACTIVATABLE in xed-view.c) - no manual window/tab | |
| tracking required. | |
| """ | |
| __gtype_name__ = "ColumnEditPlugin" | |
| view = GObject.Property(type=Xed.View) | |
| def do_activate(self): | |
| _dbg("activating for view", self.view) | |
| self.helper = ColumnEditHelper(self.view) | |
| # Expose helper dynamically so selection_stats can safely interface with it | |
| self.view.plugin_column_helper = self.helper | |
| def do_deactivate(self): | |
| _dbg("deactivating for view", self.view) | |
| if getattr(self, "helper", None): | |
| self.helper.disconnect_all() | |
| self.helper = None | |
| if hasattr(self.view, 'plugin_column_helper'): | |
| del self.view.plugin_column_helper | |
| class ColumnEditHelper(GObject.Object): | |
| """Owns box-selection state + event handling for a single GtkTextView.""" | |
| __gtype_name__ = "ColumnEditHelper" | |
| __gsignals__ = { | |
| 'column-selection-changed': (GObject.SignalFlags.RUN_FIRST, None, (int, int)), | |
| } | |
| TAG_REPLACE = "column-edit-highlight-replace" | |
| TAG_INSERT = "column-edit-highlight-insert" | |
| def __init__(self, view): | |
| GObject.Object.__init__(self) | |
| self.view = view | |
| self.buffer = view.get_buffer() | |
| self.active = False | |
| self.dragging = False | |
| self.start_line = 0 | |
| self.start_col = 0 | |
| self.end_line = 0 | |
| self.end_col = 0 | |
| self._char_width_cache = None | |
| self._blink_timer_id = None | |
| self._blink_visible = True | |
| self._ids = [ | |
| view.connect("button-press-event", self.on_button_press), | |
| view.connect("motion-notify-event", self.on_motion_notify), | |
| view.connect("button-release-event", self.on_button_release), | |
| view.connect("key-press-event", self.on_key_press), | |
| view.connect("focus-out-event", self.on_focus_out), | |
| ] | |
| if SHOW_STATUS_OVERLAY or SHOW_MULTI_CURSOR: | |
| self._ids.append(view.connect_after("draw", self.on_draw)) | |
| def disconnect_all(self): | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| for hid in self._ids: | |
| try: | |
| if GObject.signal_handler_is_connected(self.view, hid): | |
| self.view.disconnect(hid) | |
| except Exception: | |
| pass | |
| self._end_column_mode() | |
| # --- mouse handling ---------------------------------------------------- | |
| def on_button_press(self, view, event): | |
| if event.button != 1: | |
| return False | |
| ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK) | |
| shift = bool(event.state & Gdk.ModifierType.SHIFT_MASK) | |
| if not (ctrl and shift): | |
| if self.active: | |
| self._end_column_mode() | |
| return False # let xed handle the click normally | |
| bx, by = view.window_to_buffer_coords( | |
| Gtk.TextWindowType.TEXT, int(event.x), int(event.y) | |
| ) | |
| line_num, col = self._line_col_at_buffer_xy(bx, by) | |
| self.active = True | |
| self.dragging = True | |
| self.start_line = self.end_line = line_num | |
| self.start_col = self.end_col = col | |
| self._char_width_cache = None | |
| self._update_highlight() | |
| self._start_blink() | |
| _dbg("box start", line_num, col) | |
| return True # swallow: don't move the real cursor | |
| def on_motion_notify(self, view, event): | |
| if not (self.active and self.dragging): | |
| return False | |
| bx, by = view.window_to_buffer_coords( | |
| Gtk.TextWindowType.TEXT, int(event.x), int(event.y) | |
| ) | |
| line_num, col = self._line_col_at_buffer_xy(bx, by) | |
| self.end_line = line_num | |
| self.end_col = col | |
| self._update_highlight() | |
| return True | |
| def on_button_release(self, view, event): | |
| if self.dragging: | |
| self.dragging = False | |
| return True | |
| return False | |
| def on_focus_out(self, view, event): | |
| if self.active: | |
| self._end_column_mode() | |
| return False | |
| # --- keyboard handling --------------------------------------------------- | |
| def on_key_press(self, view, event): | |
| if not self.active: | |
| return False | |
| keyval = event.keyval | |
| state = event.state | |
| # 1. Ignore pure modifier keys so pressing Shift doesn't instantly exit mode | |
| modifier_keys = ( | |
| Gdk.KEY_Shift_L, Gdk.KEY_Shift_R, | |
| Gdk.KEY_Control_L, Gdk.KEY_Control_R, | |
| Gdk.KEY_Alt_L, Gdk.KEY_Alt_R, | |
| Gdk.KEY_Meta_L, Gdk.KEY_Meta_R, | |
| Gdk.KEY_Super_L, Gdk.KEY_Super_R, | |
| Gdk.KEY_Caps_Lock, Gdk.KEY_Num_Lock | |
| ) | |
| if keyval in modifier_keys: | |
| return False | |
| if keyval == Gdk.KEY_Escape: | |
| self._end_column_mode() | |
| return True | |
| ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) | |
| shift = bool(state & Gdk.ModifierType.SHIFT_MASK) | |
| alt = bool(state & Gdk.ModifierType.MOD1_MASK) | |
| # 2. Column-aware copy, cut, paste bindings | |
| is_copy = (ctrl and keyval in (Gdk.KEY_c, Gdk.KEY_C, Gdk.KEY_Insert)) | |
| is_cut = (ctrl and keyval in (Gdk.KEY_x, Gdk.KEY_X)) or (shift and keyval == Gdk.KEY_Delete) | |
| is_paste = (ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V)) or (shift and keyval == Gdk.KEY_Insert) | |
| if is_copy: | |
| self._apply_copy() | |
| return True | |
| if is_cut: | |
| self._apply_copy() | |
| self._apply_delete_forward() | |
| return True | |
| if is_paste: | |
| self._apply_paste() | |
| return True | |
| # 3. Handle Deletions | |
| if keyval == Gdk.KEY_BackSpace: | |
| self._apply_backspace() | |
| return True | |
| if keyval == Gdk.KEY_Delete: | |
| self._apply_delete_forward() | |
| return True | |
| if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): | |
| self._end_column_mode() | |
| return True | |
| # Tab has no printable unicode representation (chr(9) is not | |
| # "printable"), so without this it fell through to the catch-all | |
| # below and exited column mode, inserting a single tab at the real | |
| # cursor instead of one per selected row. Respect the editor's own | |
| # "insert spaces instead of tabs" setting rather than always | |
| # inserting a literal tab char. | |
| if keyval in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab, Gdk.KEY_KP_Tab) and not (ctrl or alt): | |
| col_min, _col_max = sorted((self.start_col, self.end_col)) | |
| self._apply_insert(self._tab_text(col_min)) | |
| return True | |
| # 4. Explicit navigation keys bail out of column mode | |
| nav_keys = ( | |
| Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right, | |
| Gdk.KEY_Home, Gdk.KEY_End, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down | |
| ) | |
| if keyval in nav_keys: | |
| self._end_column_mode() | |
| return False | |
| # 5. Process printable characters when standard command modifiers aren't held | |
| if not (ctrl or alt): | |
| unicode_val = Gdk.keyval_to_unicode(keyval) | |
| if unicode_val: | |
| ch = chr(unicode_val) | |
| if ch.isprintable(): | |
| self._apply_insert(ch) | |
| return True | |
| if ctrl or alt: | |
| return False | |
| self._end_column_mode() | |
| return False | |
| # --- column math --------------------------------------------------------- | |
| def _char_width_px(self): | |
| if self._char_width_cache is None: | |
| layout = self.view.create_pango_layout(" ") | |
| width, _height = layout.get_pixel_size() | |
| self._char_width_cache = max(width, 1) | |
| return self._char_width_cache | |
| def _line_bounds_iters(self, line_num): | |
| n_lines = self.buffer.get_line_count() | |
| line_num = max(0, min(line_num, n_lines - 1)) | |
| start_it = self.buffer.get_iter_at_line(line_num) | |
| end_it = start_it.copy() | |
| if not end_it.ends_line(): | |
| end_it.forward_to_line_end() | |
| return start_it, end_it | |
| def _line_col_at_buffer_xy(self, bx, by): | |
| line_result = self.view.get_line_at_y(by) | |
| if len(line_result) == 3: | |
| _found, line_it, _line_top = line_result | |
| else: | |
| line_it, _line_top = line_result | |
| line_num = line_it.get_line() | |
| _start_it, end_it = self._line_bounds_iters(line_num) | |
| line_len = end_it.get_line_offset() | |
| end_rect = self.view.get_iter_location(end_it) | |
| end_x = end_rect.x + end_rect.width | |
| if bx <= end_x: | |
| result = self.view.get_iter_at_position(int(bx), int(by)) | |
| if len(result) == 3: | |
| _found, it, trailing = result | |
| else: | |
| it, trailing = result | |
| col = it.get_line_offset() | |
| if trailing: | |
| col += 1 | |
| return line_num, min(col, line_len) | |
| else: | |
| char_w = self._char_width_px() | |
| extra_px = bx - end_x | |
| extra_chars = int(round(extra_px / char_w)) | |
| return line_num, line_len + max(extra_chars, 0) | |
| # --- editing ops ----------------------------------------------------- | |
| def _line_len(self, line_num): | |
| _s, e = self._line_bounds_iters(line_num) | |
| return e.get_line_offset() | |
| def _insert_on_line(self, line_num, col_min, col_max, ch): | |
| if line_num >= self.buffer.get_line_count(): | |
| return | |
| line_len = self._line_len(line_num) | |
| if col_max > col_min and line_len > col_min: | |
| del_end_col = min(col_max, line_len) | |
| d_start = self.buffer.get_iter_at_line_offset(line_num, col_min) | |
| d_end = self.buffer.get_iter_at_line_offset(line_num, del_end_col) | |
| self.buffer.delete(d_start, d_end) | |
| line_len = self._line_len(line_num) | |
| if line_len < col_min: | |
| pad_it = self.buffer.get_iter_at_line_offset(line_num, line_len) | |
| self.buffer.insert(pad_it, " " * (col_min - line_len)) | |
| ins_it = self.buffer.get_iter_at_line_offset(line_num, col_min) | |
| self.buffer.insert(ins_it, ch) | |
| def _delete_range_on_line(self, line_num, col_from, col_to): | |
| if line_num >= self.buffer.get_line_count(): | |
| return | |
| line_len = self._line_len(line_num) | |
| if line_len <= col_from: | |
| return | |
| actual_to = min(col_to, line_len) | |
| s_it = self.buffer.get_iter_at_line_offset(line_num, col_from) | |
| e_it = self.buffer.get_iter_at_line_offset(line_num, actual_to) | |
| self.buffer.delete(s_it, e_it) | |
| def _tab_text(self, col): | |
| """What a Tab press should insert at buffer column `col`, matching | |
| the editor's own settings: a literal tab, or - if the editor is | |
| configured to insert spaces instead of tabs - enough spaces to | |
| reach the next indent stop. Since every selected row is inserted | |
| at the same numeric column in column mode, this is computed once | |
| and reused for all rows (each row lands on the same stop).""" | |
| try: | |
| use_spaces = self.view.get_insert_spaces_instead_of_tabs() | |
| except Exception: | |
| use_spaces = False | |
| if not use_spaces: | |
| return "\t" | |
| width = None | |
| get_indent_width = getattr(self.view, "get_indent_width", None) | |
| if get_indent_width: | |
| try: | |
| iw = get_indent_width() | |
| if iw and iw > 0: | |
| width = iw | |
| except Exception: | |
| pass | |
| if not width: | |
| try: | |
| width = self.view.get_tab_width() | |
| except Exception: | |
| width = 8 | |
| if not width or width <= 0: | |
| width = 8 | |
| n = width - (col % width) | |
| if n <= 0: | |
| n = width | |
| return " " * n | |
| def _apply_insert(self, ch): | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| self.buffer.begin_user_action() | |
| for line in range(lo, hi + 1): | |
| self._insert_on_line(line, col_min, col_max, ch) | |
| self.buffer.end_user_action() | |
| new_col = col_min + len(ch) | |
| self.start_col = new_col | |
| self.end_col = new_col | |
| self._update_highlight() | |
| def _apply_backspace(self): | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| self.buffer.begin_user_action() | |
| if col_max > col_min: | |
| for line in range(lo, hi + 1): | |
| self._delete_range_on_line(line, col_min, col_max) | |
| self.start_col = self.end_col = col_min | |
| elif col_min > 0: | |
| for line in range(lo, hi + 1): | |
| self._delete_range_on_line(line, col_min - 1, col_min) | |
| self.start_col = self.end_col = col_min - 1 | |
| self.buffer.end_user_action() | |
| self._update_highlight() | |
| def _apply_delete_forward(self): | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| self.buffer.begin_user_action() | |
| if col_max > col_min: | |
| for line in range(lo, hi + 1): | |
| self._delete_range_on_line(line, col_min, col_max) | |
| self.start_col = self.end_col = col_min | |
| else: | |
| for line in range(lo, hi + 1): | |
| self._delete_range_on_line(line, col_min, col_min + 1) | |
| self.buffer.end_user_action() | |
| self._update_highlight() | |
| def _apply_copy(self): | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| if col_max <= col_min: | |
| return | |
| lines_text = [] | |
| for line in range(lo, hi + 1): | |
| line_len = self._line_len(line) | |
| actual_min = min(col_min, line_len) | |
| actual_max = min(col_max, line_len) | |
| if actual_max > actual_min: | |
| s_it = self.buffer.get_iter_at_line_offset(line, actual_min) | |
| e_it = self.buffer.get_iter_at_line_offset(line, actual_max) | |
| text = self.buffer.get_text(s_it, e_it, False) | |
| lines_text.append(text) | |
| else: | |
| lines_text.append("") | |
| text = "\n".join(lines_text) | |
| clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) | |
| clipboard.set_text(text, -1) | |
| def _apply_paste(self): | |
| clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) | |
| text = clipboard.wait_for_text() | |
| if not text: | |
| return | |
| # Deliberately NOT stripping a trailing empty element here. Our own | |
| # _apply_copy never appends a trailing newline, so a trailing "" | |
| # after split means the last copied row genuinely had no characters | |
| # at that column (line was too short) - it's a real row, not an | |
| # artifact, and needs to land on the matching destination row so | |
| # that row gets cleared rather than skipped. | |
| lines = text.split("\n") | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| # A single-line clipboard (no embedded newlines) is broadcast to | |
| # every selected row, same as typing a character in column mode. | |
| # Anything with multiple lines is mapped row-for-row: destination | |
| # row i gets clipboard line i. If the destination selection has | |
| # more rows than were copied, the extra rows just get their | |
| # existing column range cleared (nothing to merge in). If the | |
| # destination has fewer rows than were copied, the extra clipboard | |
| # lines are simply not used. | |
| broadcast = len(lines) == 1 | |
| self.buffer.begin_user_action() | |
| for i, line_num in reversed(list(enumerate(range(lo, hi + 1)))): | |
| if broadcast: | |
| paste_str = lines[0] | |
| elif i < len(lines): | |
| paste_str = lines[i] | |
| else: | |
| paste_str = "" | |
| self._insert_on_line(line_num, col_min, col_max, paste_str) | |
| self.buffer.end_user_action() | |
| if broadcast: | |
| new_col = col_min + len(lines[0]) | |
| self.start_col = self.end_col = new_col | |
| self._update_highlight() | |
| else: | |
| # Row-for-row pastes don't collapse to a single meaningful | |
| # column (rows can differ in length), so just drop out of | |
| # box mode, same as a normal multi-line paste would. | |
| self._end_column_mode() | |
| # --- visuals: box highlight --------------------------------------------- | |
| def _get_tag(self, name, rgba_str): | |
| table = self.buffer.get_tag_table() | |
| tag = table.lookup(name) | |
| if tag is None: | |
| tag = self.buffer.create_tag(name) | |
| rgba = Gdk.RGBA() | |
| rgba.parse(rgba_str) | |
| tag.set_property("background-rgba", rgba) | |
| return tag | |
| def _clear_highlight(self): | |
| for name, color in ( | |
| (self.TAG_REPLACE, REPLACE_HIGHLIGHT_RGBA), | |
| (self.TAG_INSERT, INSERT_HIGHLIGHT_RGBA), | |
| ): | |
| tag = self._get_tag(name, color) | |
| self.buffer.remove_tag(tag, self.buffer.get_start_iter(), self.buffer.get_end_iter()) | |
| def _update_highlight(self): | |
| self._blink_visible = True | |
| self._clear_highlight() | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| n_lines = self.buffer.get_line_count() | |
| will_replace = col_max > col_min | |
| if not will_replace and not SHOW_INSERT_HIGHLIGHT: | |
| self.view.queue_draw() | |
| self.emit('column-selection-changed', 0, 0) | |
| return | |
| tag = ( | |
| self._get_tag(self.TAG_REPLACE, REPLACE_HIGHLIGHT_RGBA) | |
| if will_replace | |
| else self._get_tag(self.TAG_INSERT, INSERT_HIGHLIGHT_RGBA) | |
| ) | |
| total_chars = 0 | |
| for line in range(lo, min(hi, n_lines - 1) + 1): | |
| line_len = self._line_len(line) | |
| start_col = min(col_min, line_len) | |
| end_col = min(col_max, line_len) if will_replace else min(col_min + 1, line_len) | |
| if end_col <= start_col: | |
| continue | |
| s_it = self.buffer.get_iter_at_line_offset(line, start_col) | |
| e_it = self.buffer.get_iter_at_line_offset(line, end_col) | |
| self.buffer.apply_tag(tag, s_it, e_it) | |
| if will_replace: | |
| total_chars += (end_col - start_col) | |
| rows_count = (hi - lo) + 1 | |
| self.emit('column-selection-changed', rows_count, total_chars) | |
| self.view.queue_draw() | |
| # --- visuals: multi-cursor carets --------------------------------------- | |
| def _blink_interval_ms(self): | |
| try: | |
| settings = Gtk.Settings.get_default() | |
| if settings and settings.get_property("gtk-cursor-blink"): | |
| return max(settings.get_property("gtk-cursor-blink-time") // 2, 100) | |
| except Exception: | |
| pass | |
| return 530 | |
| def _start_blink(self): | |
| if not (SHOW_MULTI_CURSOR and MULTI_CURSOR_BLINK): | |
| return | |
| if self._blink_timer_id is None: | |
| self._blink_visible = True | |
| self._blink_timer_id = GLib.timeout_add( | |
| self._blink_interval_ms(), self._on_blink_tick | |
| ) | |
| def _stop_blink(self): | |
| if self._blink_timer_id is not None: | |
| try: | |
| GLib.source_remove(self._blink_timer_id) | |
| except Exception: | |
| pass | |
| self._blink_timer_id = None | |
| self._blink_visible = True | |
| def _on_blink_tick(self): | |
| if not self.active: | |
| self._blink_timer_id = None | |
| return False | |
| self._blink_visible = not self._blink_visible | |
| try: | |
| self.view.queue_draw() | |
| except Exception: | |
| pass | |
| return True | |
| def _column_screen_pos(self, line_num, col): | |
| """(x, y, height) in buffer coords for `col` on `line_num`, using the | |
| same real-grid-then-fixed-width-fallback logic as the mouse->column | |
| math, so the caret lines up with text even past the end of a | |
| (not-yet-padded) short line.""" | |
| n_lines = self.buffer.get_line_count() | |
| if line_num >= n_lines: | |
| return None | |
| line_len = self._line_len(line_num) | |
| if col <= line_len: | |
| it = self.buffer.get_iter_at_line_offset(line_num, col) | |
| rect = self.view.get_iter_location(it) | |
| return rect.x, rect.y, rect.height | |
| end_it = self.buffer.get_iter_at_line_offset(line_num, line_len) | |
| end_rect = self.view.get_iter_location(end_it) | |
| char_w = self._char_width_px() | |
| extra = col - line_len | |
| return end_rect.x + extra * char_w, end_rect.y, end_rect.height | |
| def _draw_multi_cursors(self, view, cr): | |
| if not (SHOW_MULTI_CURSOR and self.active): | |
| return | |
| if MULTI_CURSOR_BLINK and not self._blink_visible: | |
| return | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, _col_max = sorted((self.start_col, self.end_col)) | |
| n_lines = self.buffer.get_line_count() | |
| cr.save() | |
| cr.set_source_rgba(*MULTI_CURSOR_RGBA) | |
| for line in range(lo, min(hi, n_lines - 1) + 1): | |
| pos = self._column_screen_pos(line, col_min) | |
| if pos is None: | |
| continue | |
| bx, by, bh = pos | |
| wx, wy = view.buffer_to_window_coords( | |
| Gtk.TextWindowType.WIDGET, int(bx), int(by) | |
| ) | |
| cr.rectangle(wx, wy, MULTI_CURSOR_WIDTH_PX, bh) | |
| cr.fill() | |
| cr.restore() | |
| # --- visuals: status overlay -------------------------------------------- | |
| def _status_text(self): | |
| lo, hi = sorted((self.start_line, self.end_line)) | |
| col_min, col_max = sorted((self.start_col, self.end_col)) | |
| n_lines = hi - lo + 1 | |
| display_col = col_min + 1 | |
| if col_max > col_min: | |
| width = col_max - col_min | |
| return "Col {}\N{EN DASH}{} \N{BULLET} {} char{} \N{RIGHTWARDS ARROW} replaced \N{BULLET} {} line{}".format( | |
| display_col, | |
| col_max + 1, | |
| width, | |
| "" if width == 1 else "s", | |
| n_lines, | |
| "" if n_lines == 1 else "s", | |
| ) | |
| else: | |
| return "Col {} \N{BULLET} insert \N{BULLET} {} line{}".format( | |
| display_col, n_lines, "" if n_lines == 1 else "s" | |
| ) | |
| def _rounded_rect(self, cr, x, y, w, h, r): | |
| cr.new_sub_path() | |
| cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) | |
| cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) | |
| cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) | |
| cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) | |
| cr.close_path() | |
| def on_draw(self, view, cr): | |
| if not self.active: | |
| return False | |
| self._draw_multi_cursors(view, cr) | |
| if not SHOW_STATUS_OVERLAY: | |
| return False | |
| text = self._status_text() | |
| layout = view.create_pango_layout(text) | |
| _ink, logical = layout.get_pixel_extents() | |
| pad_x, pad_y = 10, 5 | |
| w = logical.width + pad_x * 2 | |
| h = logical.height + pad_y * 2 | |
| alloc = view.get_allocation() | |
| x = max(alloc.width - w - 12, 4) | |
| y = 8 | |
| cr.save() | |
| self._rounded_rect(cr, x, y, w, h, 6) | |
| cr.set_source_rgba(*STATUS_OVERLAY_BG_RGBA) | |
| cr.fill() | |
| cr.set_source_rgba(*STATUS_OVERLAY_FG_RGBA) | |
| cr.move_to(x + pad_x, y + pad_y) | |
| PangoCairo.show_layout(cr, layout) | |
| cr.restore() | |
| return False | |
| def _end_column_mode(self): | |
| self.active = False | |
| self.dragging = False | |
| self._stop_blink() | |
| self._clear_highlight() | |
| self.emit('column-selection-changed', 0, 0) | |
| try: | |
| self.view.queue_draw() | |
| except Exception: | |
| pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment