Fix multi-select word movement for snake_case and add Ctrl+scroll zoom

- Implement snake_case-aware word movement treating underscores as part of words
- Fix selection edge detection to only move word markers when caret is at appropriate boundary
- Add INSERT state guards to line_up/down commands to prevent movement in non-insert modes
- Add Ctrl+scroll zoom in/out functionality for text size
This commit is contained in:
2026-03-22 23:47:27 -05:00
parent 9f1c3cc452
commit 2758d6b62b
8 changed files with 101 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ gi.require_version('GtkSource', '4')
from gi.repository import GtkSource
# Application imports
from libs.dto.states import SourceViewStates
@@ -17,4 +18,6 @@ def execute(
**kwargs
):
logger.debug("Command: Line Down")
if not view.state == SourceViewStates.INSERT: return
view.emit("move-lines", True)

View File

@@ -8,6 +8,7 @@ gi.require_version('GtkSource', '4')
from gi.repository import GtkSource
# Application imports
from libs.dto.states import SourceViewStates
@@ -17,4 +18,6 @@ def execute(
**kwargs
):
logger.debug("Command: Line Up")
if not view.state == SourceViewStates.INSERT: return
view.emit("move-lines", False)

View File

@@ -56,10 +56,28 @@ class MarkerManager(MarkSupportMixin):
continue
if has_selection:
caret_itr = buffer.get_iter_at_mark(end_mark)
start_itr = buffer.get_iter_at_mark(start_mark)
is_left_edge = caret_itr.compare(start_itr) <= 0
is_right_edge = not is_left_edge
can_move = (
(is_forward and is_right_edge) or
(not is_forward and is_left_edge)
)
self.collapse_selection(buffer, mark_hash, start_mark, end_mark, is_forward)
if mode == "word":
if not can_move: continue
itr = caret_itr
self._move_iter(buffer, itr, mode, is_forward)
buffer.move_mark(start_mark, itr)
buffer.move_mark(end_mark, itr)
continue
# No selection — move both anchor and caret together
# No selection - move both anchor and caret together
self._move_iter(buffer, end_itr, mode, is_forward)
buffer.move_mark(start_mark, end_itr)
@@ -81,17 +99,50 @@ class MarkerManager(MarkSupportMixin):
left = end_itr
right = start_itr
# If moving forward collapse to right edge
# If moving forward -> collapse to right edge
collapse_itr = right if is_forward else left
buffer.move_mark(start_mark, collapse_itr)
buffer.move_mark(end_mark, collapse_itr)
def move_word_snake_case(self, itr: Gtk.TextIter, count: int):
def is_word(ch):
return ch and (ch.isalnum() or ch == "_")
def step(fwd):
return itr.forward_cursor_position() if fwd else itr.backward_cursor_position()
def peek(fwd):
if fwd: return itr.get_char()
tmp = itr.copy()
return tmp.backward_cursor_position() and tmp.get_char()
def walk(fwd, cond):
while True:
ch = peek(fwd)
if not cond(ch): break
if not step(fwd): return False
return True
fwd = count > 0
for _ in range(abs(count)):
ch = itr.get_char() if fwd else peek(False)
if is_word(ch):
# inside word
if not walk(fwd, is_word): return
else:
# in separators -> skip them, then the word
if not walk(fwd, lambda c: not is_word(c)): return
if not walk(fwd, is_word): return
def _move_iter(self, buffer, itr_, mode: str, is_forward: bool):
if mode == "char":
itr_.forward_char() if is_forward else itr_.backward_char()
elif mode == "word":
itr_.forward_word_end() if is_forward else itr_.backward_word_start()
self.move_word_snake_case(itr_, 1 if is_forward else -1)
elif mode == "line":
line = itr_.get_line()
offset = itr_.get_line_offset()

View File

@@ -57,6 +57,7 @@ class SourceViewSignalMapper:
"key-release-event": self._key_release_event,
"button-press-event": self._button_press_event,
"button-release-event": self._button_release_event,
"scroll-event": self._scroll_event,
"populate-popup": self._populate_popup
}
@@ -81,5 +82,8 @@ class SourceViewSignalMapper:
def _button_release_event(self, source_view: SourceView, eve):
return self.state_manager.handle_button_release_event(source_view, eve)
def _populate_popup(self, source_view, menu):
def _scroll_event(self, source_view: SourceView, eve):
return self.state_manager.handle_scroll_event(source_view, eve)
def _populate_popup(self, source_view: SourceView, menu):
return self.state_manager.handle_populate_popup(source_view, menu, self.emit)

View File

@@ -53,8 +53,15 @@ class SourceViewStateManager:
def handle_button_release_event(self, source_view, eve):
return self.states[source_view.state].button_release_event(source_view, eve)
def handle_scroll_event(self, source_view, eve):
return self.states[source_view.state].scroll_event(
source_view, eve, self.key_mapper
)
def handle_populate_popup(self, source_view, menu, emit):
return self.states[source_view.state].populate_popup(source_view, menu, emit)
return self.states[source_view.state].populate_popup(
source_view, menu, emit
)
def _handle_multi_insert_toggle(self, source_view, eve):
is_control = self.key_mapper.is_control(eve)

View File

@@ -1,6 +1,9 @@
# Python imports
# Lib imports
import gi
gi.require_version('Gdk', '3.0')
from gi.repository import Gdk
# Application imports
from libs.event_factory import Event_Factory, Code_Event_Types
@@ -79,6 +82,29 @@ class SourceViewsBaseState:
return True if not response else response
def scroll_event(self, source_view, eve, key_mapper):
is_control = key_mapper.is_control(eve)
if not is_control: return
if eve.direction == Gdk.ScrollDirection.SMOOTH:
has_deltas, dx, dy = eve.get_scroll_deltas()
if not has_deltas: return False
if dy < 0:
source_view.command.exec("zoom_in")
elif dy > 0:
source_view.command.exec("zoom_out")
return True
if eve.direction == Gdk.ScrollDirection.UP:
source_view.command.exec("zoom_in")
elif eve.direction == Gdk.ScrollDirection.DOWN:
source_view.command.exec("zoom_out")
return True
def populate_popup(self, source_view, menu, emit):
buffer = source_view.get_buffer()
event = Event_Factory.create_event(

View File

@@ -61,7 +61,6 @@ class SourceViewsMultiInsertState(SourceViewsBaseState):
self.marker_manager.apply_to_marks(buffer, replace_word)
return True
def move_cursor(self, source_view, step, count, is_selection, emit):
is_forward = count > 0
buffer = source_view.get_buffer()
@@ -78,6 +77,8 @@ class SourceViewsMultiInsertState(SourceViewsBaseState):
self._signal_cursor_moved(source_view, emit)
return False
def key_press_event(self, source_view, event, key_mapper):
char = key_mapper.get_raw_keyname(event).upper()
self.is_control = key_mapper.is_control(event)

View File

@@ -106,7 +106,6 @@ class MarkSupportMixin:
name = f"multi-insert-end-{hash}",
left_gravity = False
)
# left_gravity = True
buffer.add_mark(start_mark, target_iter)
buffer.add_mark(end_mark, target_iter)