Skip to content

Instantly share code, notes, and snippets.

@acidnine
Created August 24, 2026 03:20
Show Gist options
  • Select an option

  • Save acidnine/abb2850369ed0c1f2adb81293887e58b to your computer and use it in GitHub Desktop.

Select an option

Save acidnine/abb2850369ed0c1f2adb81293887e58b to your computer and use it in GitHub Desktop.
XED Horizontal Scroll Fix
[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
"""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