Created
August 24, 2026 03:20
-
-
Save acidnine/abb2850369ed0c1f2adb81293887e58b to your computer and use it in GitHub Desktop.
XED Horizontal Scroll Fix
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=horizontal_scroll_fix | |
| IAge=2 | |
| Name=Horizontal Scroll Fix | |
| Description=When wordwrap is disabled and on a long line, clicking a line that is shorter will cause the horizontal scroll bar to follow the cursor. | |
| Authors=Acidnine; Gemini / ChatGPT / Claude / Grok | |
| Copyright=Copyright © 2026 Acidnine | |
| Website=https://vault21.net/ | |
| Icon=accessories-text-editor | |
| Version=1.0.0 |
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
| """xed plugin: keep the caret on-screen when wrap is off. | |
| With word wrap disabled, GtkTextView keeps the horizontal scrollbar wherever | |
| it was after a long line. Clicking a shorter line below (or pressing Home) | |
| can leave the caret off-screen. This plugin watches the caret and: | |
| - scrolls to column 0 when the caret is at the start of a line **and | |
| off-screen** | |
| - otherwise centers the viewport on the caret when the caret is off-screen | |
| - does nothing when the caret is already in the visible area | |
| Centering matters when the "shorter" line is still wider than the window: | |
| jumping to column 0 would hide a caret that is still far to the right. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from enum import Enum | |
| class ScrollAction(str, Enum): | |
| """What the plugin should do to the view's horizontal scroll.""" | |
| NONE = "none" | |
| TO_START = "to_start" | |
| CENTER_ON_CURSOR = "center_on_cursor" | |
| def decide_horizontal_scroll( | |
| *, | |
| wrap_enabled, | |
| cursor_at_line_start, | |
| cursor_horizontally_visible, | |
| current_value, | |
| lower, | |
| epsilon=0.5, | |
| ): | |
| """Return the horizontal-scroll action for the current caret. | |
| If the caret is already on-screen, do nothing — including when it happens | |
| to sit at column 0 of a line that is still in the viewport. ``TO_START`` | |
| is only for an off-screen caret at line start. Off-screen carets elsewhere | |
| are centered so a shorter-but-still-wide line does not hide the caret. | |
| """ | |
| if wrap_enabled: | |
| return ScrollAction.NONE | |
| if cursor_horizontally_visible: | |
| return ScrollAction.NONE | |
| if cursor_at_line_start: | |
| if abs(current_value - lower) < epsilon: | |
| return ScrollAction.NONE | |
| return ScrollAction.TO_START | |
| return ScrollAction.CENTER_ON_CURSOR | |
| def is_horizontally_visible(x, width, visible_x, visible_width, slop=2.0): | |
| """True when any part of the caret intersects the visible horizontal span.""" | |
| left = x | |
| right = x + max(width, 1.0) | |
| visible_right = visible_x + max(visible_width, 0.0) | |
| return right >= (visible_x - slop) and left <= (visible_right + slop) | |
| def is_in_text_window_x(window_x, window_width, slop=2.0): | |
| """True when a TEXT-window X coordinate is inside the visible text area.""" | |
| if window_width <= 0: | |
| return False | |
| return -slop <= window_x < window_width + slop | |
| def is_stale_cursor_location(line_offset, cursor_x, left_margin=0.0): | |
| """True when layout likely has not computed the caret X yet. | |
| GtkTextView can report X=0 until an idle layout pass. Treating that as | |
| "off-screen at column 0" would yank the scrollbar left on every click. | |
| """ | |
| return line_offset > 0 and cursor_x <= left_margin + 1.0 | |
| def centered_adjustment_value( | |
| cursor_x, cursor_width, lower, upper, page_size, origin=0.0 | |
| ): | |
| """GtkAdjustment value that puts the caret in the middle of the viewport. | |
| ``origin`` is ``hadjustment.value - visible_rect.x``, so buffer coordinates | |
| and the scrollbar stay aligned if they differ by a constant offset. | |
| """ | |
| if page_size <= 0: | |
| return lower | |
| center = cursor_x + max(cursor_width, 0.0) / 2.0 | |
| target = center - page_size / 2.0 + origin | |
| max_value = max(lower, upper - page_size) | |
| return max(lower, min(target, max_value)) | |
| _DEBUG = os.environ.get("XED_DEBUG_HORIZONTAL_SCROLL_FIX", "").strip().lower() in ( | |
| "1", | |
| "true", | |
| "yes", | |
| "on", | |
| ) | |
| def _debug(message): | |
| if _DEBUG: | |
| print("[horizontal-scroll-fix] {}".format(message)) | |
| try: | |
| import gi | |
| gi.require_version("Gtk", "3.0") | |
| gi.require_version("Xed", "1.0") | |
| from gi.repository import GLib, GObject, Gtk, Xed | |
| except (ImportError, ValueError): | |
| # Unit tests import this module on machines that do not have xed. | |
| HorizontalScrollFixViewActivatable = None | |
| else: | |
| class HorizontalScrollFixViewActivatable(GObject.Object, Xed.ViewActivatable): | |
| """Per-view hook that keeps horizontal scroll aligned with the caret.""" | |
| __gtype_name__ = "HorizontalScrollFixViewActivatable" | |
| view = GObject.Property(type=Xed.View) | |
| def __init__(self): | |
| super().__init__() | |
| self._buffer = None | |
| self._cursor_sid = 0 | |
| self._signal_ids = [] | |
| self._idle_id = 0 | |
| self._timeout_id = 0 | |
| self._pointer_down = False | |
| def do_activate(self): | |
| self._signal_ids.append( | |
| self.view.connect("notify::buffer", self._on_notify_buffer) | |
| ) | |
| self._signal_ids.append( | |
| self.view.connect("button-press-event", self._on_button_press) | |
| ) | |
| self._signal_ids.append( | |
| self.view.connect_after( | |
| "button-release-event", self._on_button_release | |
| ) | |
| ) | |
| self._signal_ids.append( | |
| self.view.connect_after("move-cursor", self._on_move_cursor) | |
| ) | |
| self._set_buffer(self.view.get_buffer()) | |
| _debug("activated on view {!r}".format(self.view)) | |
| def do_deactivate(self): | |
| self._cancel_scheduled() | |
| self._disconnect_buffer() | |
| for sid in self._signal_ids: | |
| try: | |
| self.view.disconnect(sid) | |
| except TypeError: | |
| pass | |
| self._signal_ids = [] | |
| _debug("deactivated") | |
| def _on_notify_buffer(self, _view, _pspec): | |
| self._set_buffer(self.view.get_buffer()) | |
| def _disconnect_buffer(self): | |
| if self._buffer is None: | |
| return | |
| if self._cursor_sid: | |
| try: | |
| self._buffer.disconnect(self._cursor_sid) | |
| except TypeError: | |
| pass | |
| self._cursor_sid = 0 | |
| self._buffer = None | |
| def _set_buffer(self, buf): | |
| self._disconnect_buffer() | |
| self._buffer = buf | |
| if buf is None: | |
| return | |
| self._cursor_sid = buf.connect( | |
| "notify::cursor-position", self._on_cursor_position | |
| ) | |
| def _on_button_press(self, _view, event): | |
| if getattr(event, "button", 0) != 1: | |
| return False | |
| self._pointer_down = True | |
| return False | |
| def _on_button_release(self, _view, event): | |
| if getattr(event, "button", 0) != 1: | |
| return False | |
| self._pointer_down = False | |
| self._schedule() | |
| return False | |
| def _on_move_cursor(self, _view, _step, _count, _extend): | |
| if not self._pointer_down: | |
| self._schedule() | |
| def _on_cursor_position(self, _buf, _pspec): | |
| if not self._pointer_down: | |
| self._schedule() | |
| def _cancel_scheduled(self): | |
| if self._idle_id: | |
| GLib.source_remove(self._idle_id) | |
| self._idle_id = 0 | |
| if self._timeout_id: | |
| GLib.source_remove(self._timeout_id) | |
| self._timeout_id = 0 | |
| def _schedule(self): | |
| if self._idle_id == 0: | |
| self._idle_id = GLib.idle_add(self._on_idle) | |
| if self._timeout_id == 0: | |
| # GtkTextView defers some scrolling to an idle/layout pass. | |
| # A short timeout wins that race after a click on a short line. | |
| self._timeout_id = GLib.timeout_add(20, self._on_timeout) | |
| def _on_idle(self): | |
| self._idle_id = 0 | |
| self._adjust_scroll() | |
| return GLib.SOURCE_REMOVE | |
| def _on_timeout(self): | |
| self._timeout_id = 0 | |
| self._adjust_scroll() | |
| return GLib.SOURCE_REMOVE | |
| def _adjust_scroll(self): | |
| view = self.view | |
| buf = self._buffer | |
| if view is None or buf is None: | |
| return | |
| wrap_enabled = view.get_wrap_mode() != Gtk.WrapMode.NONE | |
| insert = buf.get_iter_at_mark(buf.get_insert()) | |
| try: | |
| strong, _weak = view.get_cursor_locations(insert) | |
| cursor_rect = strong | |
| except (TypeError, ValueError): | |
| cursor_rect = view.get_iter_location(insert) | |
| visible = view.get_visible_rect() | |
| hadj = view.get_hadjustment() | |
| if hadj is None: | |
| return | |
| left_margin = 0.0 | |
| try: | |
| left_margin = float(view.get_left_margin()) | |
| except (TypeError, AttributeError): | |
| pass | |
| if is_stale_cursor_location( | |
| insert.get_line_offset(), cursor_rect.x, left_margin | |
| ): | |
| _debug( | |
| "skip stale cursor location line={} offset={} x={:.1f}".format( | |
| insert.get_line() + 1, | |
| insert.get_line_offset(), | |
| cursor_rect.x, | |
| ) | |
| ) | |
| return | |
| buffer_visible = is_horizontally_visible( | |
| cursor_rect.x, | |
| cursor_rect.width, | |
| visible.x, | |
| visible.width, | |
| ) | |
| hadj_visible = is_horizontally_visible( | |
| cursor_rect.x, | |
| cursor_rect.width, | |
| hadj.get_value(), | |
| hadj.get_page_size(), | |
| ) | |
| window_visible = False | |
| try: | |
| win_x, _win_y = view.buffer_to_window_coords( | |
| Gtk.TextWindowType.TEXT, | |
| int(cursor_rect.x), | |
| int(cursor_rect.y), | |
| ) | |
| gdk_win = view.get_window(Gtk.TextWindowType.TEXT) | |
| if gdk_win is not None: | |
| window_visible = is_in_text_window_x( | |
| win_x, gdk_win.get_width() | |
| ) | |
| except (TypeError, ValueError, AttributeError): | |
| window_visible = False | |
| cursor_visible = buffer_visible or hadj_visible or window_visible | |
| action = decide_horizontal_scroll( | |
| wrap_enabled=wrap_enabled, | |
| cursor_at_line_start=insert.starts_line(), | |
| cursor_horizontally_visible=cursor_visible, | |
| current_value=hadj.get_value(), | |
| lower=hadj.get_lower(), | |
| ) | |
| _debug( | |
| "line={} offset={} action={} hadj={:.1f} cursor.x={:.1f} " | |
| "visible.x={:.1f} buf_vis={} hadj_vis={} win_vis={}".format( | |
| insert.get_line() + 1, | |
| insert.get_line_offset(), | |
| action.value, | |
| hadj.get_value(), | |
| cursor_rect.x, | |
| visible.x, | |
| buffer_visible, | |
| hadj_visible, | |
| window_visible, | |
| ) | |
| ) | |
| if action is ScrollAction.TO_START: | |
| hadj.set_value(hadj.get_lower()) | |
| elif action is ScrollAction.CENTER_ON_CURSOR: | |
| target = centered_adjustment_value( | |
| cursor_x=cursor_rect.x, | |
| cursor_width=cursor_rect.width, | |
| lower=hadj.get_lower(), | |
| upper=hadj.get_upper(), | |
| page_size=hadj.get_page_size(), | |
| origin=hadj.get_value() - visible.x, | |
| ) | |
| hadj.set_value(target) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment