Last active
August 9, 2026 06:39
-
-
Save acidnine/c2e103bdb6a0af772a1e19f22013314c to your computer and use it in GitHub Desktop.
XED Plugin Macro Recorder
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=xed_macro | |
| IAge=3 | |
| Name=Macro Recorder | |
| Description=Record and playback text insertions and deletions (CTRL+F8 to Record, CTRL+F9 to Play). | |
| Authors=Acidnine; Gemini / ChatGPT / Claude | |
| Copyright=Copyright © 2026 Acidnine | |
| Website=https://vault21.net/ | |
| 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
| import gi | |
| import os | |
| import json | |
| gi.require_version('Gtk', '3.0') | |
| gi.require_version('Xed', '1.0') | |
| from gi.repository import GObject, Gtk, Gdk, Gio, GLib, Xed | |
| # Added MacroSave and the MacrosMenuAction sub-menu for dynamic population | |
| UI_XML = """ | |
| <ui> | |
| <menubar name="MenuBar"> | |
| <menu name="ToolsMenu" action="Tools"> | |
| <placeholder name="ToolsOps_3"> | |
| <separator name="MacroSeparator"/> | |
| <menuitem action="MacroRecord"/> | |
| <menuitem action="MacroPlay"/> | |
| <menuitem action="MacroSave"/> | |
| <menu name="MacrosMenu" action="MacrosMenuAction"> | |
| <placeholder name="DynamicMacros"/> | |
| </menu> | |
| </placeholder> | |
| </menu> | |
| </menubar> | |
| </ui> | |
| """ | |
| class XedMacroRecorderPlugin(GObject.Object, Xed.WindowActivatable): | |
| __gtype_name__ = "XedMacroRecorderPlugin" | |
| window = GObject.Property(type=Xed.Window) | |
| def __init__(self): | |
| GObject.Object.__init__(self) | |
| self.is_recording = False | |
| self.macro_steps = [] | |
| self.handler_ids = {} | |
| self.action_group = None | |
| self.ui_id = 0 | |
| # Debounce state: True only for the duration of the synchronous | |
| # dispatch of a single key-press-event. The first insert-text that | |
| # arrives while this is True is treated as the direct, manual result | |
| # of that keystroke, and is recorded exactly as Xed inserted it. | |
| # Anything else - additional insert-text calls during the same | |
| # dispatch, or ones that show up later with no keypress backing | |
| # them at all - is Xed's own internal auto-format behavior | |
| # (auto-indent, auto-close-bracket, snippet expansion, autocomplete, | |
| # etc.) and is not recorded as a macro step. | |
| self._awaiting_manual_insert = False | |
| # If a keystroke that's expected to insert something (Return, Tab) | |
| # produces no insert-text at all - because Xed swallowed it for | |
| # something else, like indenting a selection - this fallback | |
| # character is recorded instead, so the keystroke isn't lost. | |
| self._pending_fallback_text = None | |
| # Dynamic macros list tracking | |
| self.macros_action_group = None | |
| self.macros_ui_id = 0 | |
| self.macro_dir = os.path.expanduser("~/.local/share/xed/macros/") | |
| # Tracks whether the "Recording is about to start" hint dialog has | |
| # already been shown once this session. Only the very first | |
| # recording (per plugin activation) should prompt with the dialog; | |
| # subsequent recordings start immediately. | |
| self._record_hint_shown = False | |
| def do_activate(self): | |
| """Called when the plugin is enabled in Xed.""" | |
| self.key_handler = self.window.connect("key-press-event", self.on_window_key_press) | |
| self.tab_handler = self.window.connect("active-tab-changed", self.on_tab_changed) | |
| self.attach_to_document(self.window.get_active_document(), self.window.get_active_view()) | |
| # Automatically create the macros directory if it doesn't exist yet | |
| os.makedirs(self.macro_dir, exist_ok=True) | |
| self.setup_menu() | |
| self.update_macros_menu() | |
| def do_deactivate(self): | |
| """Called when the plugin is disabled.""" | |
| try: | |
| self.window.disconnect(self.key_handler) | |
| self.window.disconnect(self.tab_handler) | |
| except Exception: | |
| pass | |
| self.detach_from_document(self.window.get_active_document()) | |
| self.teardown_menu() | |
| def do_update_state(self): | |
| pass | |
| # --- Menu Integration via UIManager --- | |
| def setup_menu(self): | |
| manager = self.window.get_ui_manager() | |
| self.action_group = Gtk.ActionGroup.new("XedMacroPluginActions") | |
| action_record = Gtk.Action.new("MacroRecord", "Macro Record", "Record a macro", None) | |
| action_record.connect("activate", lambda a: self.toggle_recording()) | |
| self.action_record = action_record | |
| action_play = Gtk.Action.new("MacroPlay", "Macro Play", "Play recorded macro", None) | |
| action_play.connect("activate", lambda a: self.play_macro()) | |
| action_play.set_sensitive(False) # Disabled initially, same as MacroSave | |
| self.action_play = action_play | |
| self.action_save = Gtk.Action.new("MacroSave", "Save Macro...", "Save macro to file", None) | |
| self.action_save.connect("activate", lambda a: self.save_macro()) | |
| self.action_save.set_sensitive(False) # Disabled initially | |
| action_macros_menu = Gtk.Action.new("MacrosMenuAction", "Macro Load", "Saved macros list", None) | |
| self.action_group.add_action_with_accel(action_record, "<Primary>F8") | |
| self.action_group.add_action_with_accel(action_play, "<Primary>F9") | |
| self.action_group.add_action(self.action_save) | |
| self.action_group.add_action(action_macros_menu) | |
| manager.insert_action_group(self.action_group, 0) | |
| self.ui_id = manager.add_ui_from_string(UI_XML) | |
| manager.ensure_update() | |
| def teardown_menu(self): | |
| manager = self.window.get_ui_manager() | |
| if self.macros_action_group: | |
| if self.macros_ui_id: | |
| manager.remove_ui(self.macros_ui_id) | |
| manager.remove_action_group(self.macros_action_group) | |
| if self.action_group and self.window: | |
| if self.ui_id: | |
| manager.remove_ui(self.ui_id) | |
| manager.remove_action_group(self.action_group) | |
| manager.ensure_update() | |
| def update_macros_menu(self): | |
| """Scans the macro directory and populates the Macros sub-menu dynamically.""" | |
| manager = self.window.get_ui_manager() | |
| if self.macros_action_group: | |
| if self.macros_ui_id: | |
| manager.remove_ui(self.macros_ui_id) | |
| manager.remove_action_group(self.macros_action_group) | |
| self.macros_action_group = Gtk.ActionGroup.new("XedMacroDynamicActions") | |
| manager.insert_action_group(self.macros_action_group, 0) | |
| xml = """ | |
| <ui> | |
| <menubar name="MenuBar"> | |
| <menu name="ToolsMenu" action="Tools"> | |
| <placeholder name="ToolsOps_3"> | |
| <menu name="MacrosMenu" action="MacrosMenuAction"> | |
| <placeholder name="DynamicMacros"> | |
| """ | |
| macro_files = [f for f in os.listdir(self.macro_dir) if f.endswith('.xed-macro')] | |
| for i, filename in enumerate(sorted(macro_files)): | |
| action_name = f"PlayMacro_{i}" | |
| # Closure to capture the filename correctly | |
| def make_cb(f): | |
| return lambda a: self.load_saved_macro(f) | |
| display_name = filename.replace('.xed-macro', '') | |
| action = Gtk.Action.new(action_name, display_name, f"Load {filename}", None) | |
| action.connect("activate", make_cb(filename)) | |
| self.macros_action_group.add_action(action) | |
| xml += f"<menuitem action='{action_name}'/>\n" | |
| if not macro_files: | |
| action = Gtk.Action.new("NoMacros", "No saved macros", None, None) | |
| action.set_sensitive(False) | |
| self.macros_action_group.add_action(action) | |
| xml += "<menuitem action='NoMacros'/>\n" | |
| xml += "</placeholder></menu></placeholder></menu></menubar></ui>" | |
| self.macros_ui_id = manager.add_ui_from_string(xml) | |
| manager.ensure_update() | |
| # --- UI & Shortcuts --- | |
| def on_window_key_press(self, widget, event): | |
| # Clear any stale flag left over from a previous keystroke first, so | |
| # an insert-text that shows up later with no keypress behind it | |
| # (autocomplete, async snippet expansion, etc.) is never mistaken | |
| # for a manual one. | |
| self._awaiting_manual_insert = False | |
| self._pending_fallback_text = None | |
| keyname = Gdk.keyval_name(event.keyval) | |
| if not keyname: | |
| return False | |
| # Ctrl+F8 (record) and Ctrl+F9 (play) are handled entirely by the | |
| # menu accelerators registered in setup_menu(), via Gtk's own accel | |
| # group - no need to check for them here as well. | |
| if self.is_recording and not getattr(self, '_is_playing', False): | |
| # Arm the debounce for this keystroke. It only stays "live" for | |
| # the synchronous processing of this one event - the first | |
| # matching insert-text consumes it and records whatever Xed | |
| # *actually* inserted. The idle callback below runs once we're | |
| # back in the main loop: if nothing was inserted by then (e.g. | |
| # this key got swallowed - Tab used to indent a selection, | |
| # Return consumed by autocomplete, etc.) it records a sane | |
| # fallback character instead, so the keystroke isn't lost. | |
| self._awaiting_manual_insert = True | |
| self._pending_fallback_text = None | |
| GLib.idle_add(self._resolve_manual_insert_window) | |
| has_shift = bool(event.state & Gdk.ModifierType.SHIFT_MASK) | |
| has_ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK) | |
| # Return/Tab don't always produce an insert-text signal in a | |
| # predictable way, so set a fallback in case Xed swallows the | |
| # key without inserting anything - but let the real signal | |
| # (below, in on_insert_text) record what actually happened | |
| # whenever one does arrive, instead of guessing here. | |
| if keyname in ["Return", "KP_Enter"]: | |
| self._pending_fallback_text = '\n' | |
| return False | |
| if keyname in ["Tab", "KP_Tab", "ISO_Left_Tab"]: | |
| self._pending_fallback_text = '\t' | |
| return False | |
| nav_keys = ["Up", "Down", "Left", "Right", "Home", "End", "Page_Up", "Page_Down"] | |
| if keyname in nav_keys: | |
| self.macro_steps.append(('move', (keyname, has_shift, has_ctrl))) | |
| return False | |
| if has_ctrl and keyname.lower() in ['c', 'x', 'v']: | |
| view = self.window.get_active_view() | |
| if view: | |
| action_map = {'c': 'copy', 'x': 'cut', 'v': 'paste'} | |
| action = action_map[keyname.lower()] | |
| self.macro_steps.append((action, None)) | |
| self._is_playing = True | |
| view.emit(f'{action}-clipboard') | |
| # Flush the GTK event loop to ensure async clipboard actions | |
| # complete before recording the next keystroke. | |
| while Gtk.events_pending(): | |
| Gtk.main_iteration() | |
| self._is_playing = False | |
| return True | |
| if has_ctrl and keyname.lower() in ['d', 'z', 'y']: | |
| doc = self.window.get_active_document() | |
| if doc: | |
| key_lower = keyname.lower() | |
| if key_lower == 'd': | |
| action = 'duplicate_line' | |
| elif key_lower == 'z': | |
| action = 'undo' | |
| elif key_lower == 'y': | |
| action = 'redo' | |
| else: | |
| return False | |
| self.macro_steps.append((action, None)) | |
| self._is_playing = True | |
| if action == 'undo': | |
| doc.undo() | |
| elif action == 'redo': | |
| doc.redo() | |
| while Gtk.events_pending(): | |
| Gtk.main_iteration() | |
| self._is_playing = False | |
| if action != 'duplicate_line': | |
| return True | |
| return False | |
| def toggle_recording(self): | |
| if not self.is_recording: | |
| if not self._record_hint_shown: | |
| dialog = Gtk.MessageDialog( | |
| transient_for=self.window, | |
| flags=Gtk.DialogFlags.MODAL, | |
| message_type=Gtk.MessageType.INFO, | |
| buttons=Gtk.ButtonsType.OK_CANCEL, # Added OK_CANCEL to allow aborting | |
| text="Macro Recorder" | |
| ) | |
| dialog.format_secondary_text( | |
| "Recording is about to start.\n\nTo stop recording, press CTRL+F8.\n\nClick OK to begin recording." | |
| ) | |
| dialog.set_default_response(Gtk.ResponseType.OK) | |
| response = dialog.run() | |
| dialog.destroy() | |
| if response != Gtk.ResponseType.OK: | |
| return # Abort if they press Cancel or X out of the window | |
| # Only show this hint once - subsequent recordings start | |
| # immediately without prompting. | |
| self._record_hint_shown = True | |
| self.is_recording = True | |
| self.macro_steps = [] | |
| if hasattr(self, 'action_record'): | |
| self.action_record.set_label("Macro Stop") | |
| if hasattr(self, 'action_save'): | |
| self.action_save.set_sensitive(False) | |
| if hasattr(self, 'action_play'): | |
| self.action_play.set_sensitive(False) | |
| self.show_message("Macro Recording Started...") | |
| else: | |
| self.is_recording = False | |
| if hasattr(self, 'action_record'): | |
| self.action_record.set_label("Macro Record") | |
| has_steps = len(self.macro_steps) > 0 | |
| if hasattr(self, 'action_save') and has_steps: | |
| self.action_save.set_sensitive(True) | |
| if hasattr(self, 'action_play') and has_steps: | |
| self.action_play.set_sensitive(True) | |
| self.show_message(f"Macro Recording Stopped. ({len(self.macro_steps)} actions saved)") | |
| def show_message(self, message): | |
| statusbar = self.window.get_statusbar() | |
| context_id = statusbar.get_context_id("macro_plugin") | |
| statusbar.push(context_id, message) | |
| # --- Document Signal Handling --- | |
| def on_tab_changed(self, window, tab): | |
| doc = tab.get_document() | |
| view = tab.get_view() if hasattr(tab, 'get_view') else self.window.get_active_view() | |
| self.attach_to_document(doc, view) | |
| def attach_to_document(self, doc, view=None): | |
| if not doc: return | |
| if doc in self.handler_ids: return | |
| insert_id = doc.connect("insert-text", self.on_insert_text) | |
| delete_id = doc.connect("delete-range", self.on_delete_range) | |
| self.handler_ids[doc] = (insert_id, delete_id) | |
| def detach_from_document(self, doc): | |
| if not doc or doc not in self.handler_ids: return | |
| handlers = self.handler_ids[doc] | |
| insert_id, delete_id = handlers[0], handlers[1] | |
| doc.disconnect(insert_id) | |
| doc.disconnect(delete_id) | |
| del self.handler_ids[doc] | |
| # --- Recording Logic --- | |
| def _resolve_manual_insert_window(self): | |
| """Idle-callback safety net, run once we're back in the main loop | |
| after a keystroke. If no insert-text arrived for it (still armed), | |
| Xed swallowed the key without inserting anything - record the | |
| fallback character for Return/Tab so the keystroke isn't lost. | |
| Keys with no fallback (e.g. plain letters that got consumed by | |
| something else) are simply dropped, same as before.""" | |
| if self._awaiting_manual_insert: | |
| if self._pending_fallback_text: | |
| self.macro_steps.append(('insert', self._pending_fallback_text)) | |
| self._awaiting_manual_insert = False | |
| self._pending_fallback_text = None | |
| return False # GLib.SOURCE_REMOVE - run once, don't repeat | |
| def on_insert_text(self, buffer, iterator, text, length): | |
| if not self.is_recording or getattr(self, '_is_playing', False): | |
| return | |
| if getattr(self, '_awaiting_manual_insert', False): | |
| # This is the first insert-text triggered synchronously by the | |
| # user's keystroke - record exactly what Xed actually inserted | |
| # (a literal '\t', N spaces if "insert spaces instead of tabs" | |
| # is on, a plain '\n', '\n' + auto-indent whitespace combined | |
| # as one call, etc.) rather than guessing. | |
| self._awaiting_manual_insert = False | |
| self._pending_fallback_text = None | |
| self.macro_steps.append(('insert', text)) | |
| return | |
| # We're not in the manual-insert window: this insertion wasn't the | |
| # direct result of a keystroke we just processed. It's Xed's own | |
| # auto-format behavior (auto-indent after a newline, auto-close | |
| # brackets/quotes, snippet expansion, autocomplete acceptance, etc). | |
| # Don't record it as a step - replaying the keystroke that caused it | |
| # via insert_interactive() will make Xed regenerate the same | |
| # behavior on its own during playback. | |
| def on_delete_range(self, buffer, start_iter, end_iter): | |
| if self.is_recording: | |
| if not getattr(self, '_is_playing', False): | |
| # Check cursor position relative to deletion to determine direction | |
| cursor_mark = buffer.get_insert() | |
| cursor_iter = buffer.get_iter_at_mark(cursor_mark) | |
| start_offset = start_iter.get_offset() | |
| end_offset = end_iter.get_offset() | |
| cursor_offset = cursor_iter.get_offset() | |
| direction = 'backward' # e.g. Backspace | |
| if cursor_offset <= start_offset: | |
| direction = 'forward' # e.g. Delete key | |
| length = end_offset - start_offset | |
| self.macro_steps.append(('delete', (length, direction))) | |
| def macro_to_json(self): | |
| """Converts internal macro_steps to Sublime-style JSON.""" | |
| out = [] | |
| for action, data in self.macro_steps: | |
| step = {"command": action, "args": None} | |
| if action == 'insert': | |
| step["args"] = {"characters": data} | |
| elif action == 'delete': | |
| step["args"] = {"length": data[0], "direction": data[1]} | |
| elif action == 'move': | |
| keyname, extend, has_ctrl = data | |
| step["args"] = {"keyname": keyname, "extend": extend, "has_ctrl": has_ctrl} | |
| out.append(step) | |
| return out | |
| def json_to_macro(self, json_list): | |
| """Parses Sublime-style JSON back into internal macro_steps format.""" | |
| steps = [] | |
| for step in json_list: | |
| action = step.get("command") | |
| args = step.get("args") or {} | |
| data = None | |
| if action == 'insert': | |
| data = args.get("characters", "") | |
| elif action == 'delete': | |
| data = (args.get("length", 1), args.get("direction", "backward")) | |
| elif action == 'move': | |
| data = (args.get("keyname"), args.get("extend", False), args.get("has_ctrl", False)) | |
| steps.append((action, data)) | |
| return steps | |
| def save_macro(self): | |
| if not self.macro_steps: | |
| self.show_message("No macro steps to save!") | |
| return | |
| dialog = Gtk.FileChooserDialog( | |
| title="Save Macro", | |
| transient_for=self.window, | |
| action=Gtk.FileChooserAction.SAVE, | |
| ) | |
| dialog.add_buttons( | |
| Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, | |
| Gtk.STOCK_SAVE, Gtk.ResponseType.ACCEPT | |
| ) | |
| dialog.set_current_folder(self.macro_dir) | |
| dialog.set_current_name("untitled.xed-macro") | |
| # Add file filter | |
| filter_macro = Gtk.FileFilter() | |
| filter_macro.set_name("Xed Macro (*.xed-macro)") | |
| filter_macro.add_pattern("*.xed-macro") | |
| dialog.add_filter(filter_macro) | |
| response = dialog.run() | |
| if response == Gtk.ResponseType.ACCEPT: | |
| file_path = dialog.get_filename() | |
| if not file_path.endswith('.xed-macro'): | |
| file_path += '.xed-macro' | |
| try: | |
| with open(file_path, 'w') as f: | |
| json.dump(self.macro_to_json(), f, indent=4) | |
| self.show_message(f"Saved: {os.path.basename(file_path)}") | |
| self.update_macros_menu() # Refresh dynamic list immediately | |
| except Exception as e: | |
| self.show_message(f"Error saving macro: {e}") | |
| dialog.destroy() | |
| def load_saved_macro(self, filename): | |
| """Loads a saved macro from disk into the active in-memory buffer | |
| (self.macro_steps) so it can be replayed with CTRL+F9, without | |
| playing it back immediately.""" | |
| if self.is_recording: | |
| self.show_message("Cannot load a macro while recording!") | |
| return | |
| file_path = os.path.join(self.macro_dir, filename) | |
| try: | |
| with open(file_path, 'r') as f: | |
| data = json.load(f) | |
| self.macro_steps = self.json_to_macro(data) | |
| if hasattr(self, 'action_play'): | |
| self.action_play.set_sensitive(True) | |
| if hasattr(self, 'action_save'): | |
| self.action_save.set_sensitive(True) | |
| self.show_message("Macro loaded, press CTRL+F9 to play it back.") | |
| except Exception as e: | |
| self.show_message(f"Error loading macro: {e}") | |
| # --- Playback Logic --- | |
| def _synthesize_key_event(self, view, keyval, state=0): | |
| """Build and dispatch a real GdkEventKey (press + release) through | |
| GTK's normal event pipeline via Gtk.main_do_event(). Used for | |
| actions like Ctrl+D duplicate-line that Xed implements via its own | |
| internal keybinding rather than a public buffer/view API or a | |
| standard '-clipboard' signal - we don't need to know how Xed wires | |
| it up, we just need to reproduce the actual keystroke and let Xed | |
| handle it exactly as it would if typed live. | |
| Key events in GTK3 are delivered to the *toplevel* window and then | |
| routed internally to whichever widget currently has focus - not to | |
| the text view's own child window (which is also why recording | |
| listens on self.window rather than the view). So we target | |
| self.window's GdkWindow here, the same as a real key event would.""" | |
| window = self.window.get_window() | |
| if window is None: | |
| return False | |
| keymap = Gdk.Keymap.get_for_display(view.get_display()) | |
| found, keys = keymap.get_entries_for_keyval(keyval) | |
| keycode = keys[0].keycode if found and keys else 0 | |
| group = keys[0].group if found and keys else 0 | |
| # GTK expects every event to carry the GdkDevice that generated it. | |
| # A synthetic event built by hand has none by default, which is | |
| # harmless but triggers a "not holding a GdkDevice" warning on the | |
| # console. Attach the display's real keyboard device so the event | |
| # looks like one GTK generated itself. | |
| display = view.get_display() | |
| seat = display.get_default_seat() | |
| keyboard_device = seat.get_keyboard() if seat else None | |
| for event_type in (Gdk.EventType.KEY_PRESS, Gdk.EventType.KEY_RELEASE): | |
| ev = Gdk.Event.new(event_type) | |
| ev.window = window | |
| ev.send_event = True | |
| ev.time = Gdk.CURRENT_TIME | |
| ev.state = state | |
| ev.keyval = keyval | |
| ev.hardware_keycode = keycode | |
| ev.group = group | |
| ev.is_modifier = False | |
| if keyboard_device is not None: | |
| ev.set_device(keyboard_device) | |
| Gtk.main_do_event(ev) | |
| return True | |
| def play_macro(self): | |
| if self.is_recording: | |
| self.show_message("Cannot play while recording!") | |
| return | |
| if not self.macro_steps: | |
| self.show_message("No macro recorded.") | |
| return | |
| doc = self.window.get_active_document() | |
| view = self.window.get_active_view() | |
| if not doc or not view: return | |
| self._is_playing = True | |
| self.show_message("Playing Macro...") | |
| doc.begin_user_action() | |
| try: | |
| for index, (action, data) in enumerate(self.macro_steps): | |
| try: | |
| self._play_step(doc, view, action, data) | |
| except Exception as e: | |
| # A single bad step should never silently swallow the | |
| # rest of the macro (that's what made the earlier | |
| # duplicate_line bug so confusing to track down) - log | |
| # which step failed and keep going with the rest. | |
| print(f"[Macro] Step {index} ('{action}') failed: {e}") | |
| self.show_message(f"Macro step {index} ('{action}') failed - see terminal for details.") | |
| finally: | |
| doc.end_user_action() | |
| self._is_playing = False | |
| self.show_message("Macro Playback Complete.") | |
| def _play_step(self, doc, view, action, data): | |
| if action == 'insert': | |
| # insert_interactive_at_cursor tells Xed "pretend the user | |
| # just typed this", running it through the buffer's normal | |
| # editable-region checks and letting Xed's own insertion | |
| # pipeline (auto-indent, auto-close-pairs, etc.) react to it | |
| # exactly as it would to a real keystroke - unlike | |
| # insert_at_cursor, which forces the raw text in and | |
| # bypasses all of that. | |
| inserted = doc.insert_interactive_at_cursor(data, -1, True) | |
| if not inserted: | |
| # Cursor is in a non-editable region (e.g. a read-only | |
| # section) - fall back to a raw insert so playback | |
| # doesn't silently drop text. | |
| doc.insert_at_cursor(data) | |
| elif action == 'delete': | |
| length, direction = data | |
| cursor_mark = doc.get_insert() | |
| iter1 = doc.get_iter_at_mark(cursor_mark) | |
| iter2 = iter1.copy() | |
| # Apply deletion relative to correct direction | |
| if direction == 'backward': | |
| iter2.backward_chars(length) | |
| doc.delete(iter2, iter1) | |
| else: | |
| iter2.forward_chars(length) | |
| doc.delete(iter1, iter2) | |
| elif action in ['copy', 'cut', 'paste']: | |
| view.emit(f'{action}-clipboard') | |
| # CRITICAL: Force GTK to process the asynchronous clipboard IPC messages | |
| # before moving the cursor again. Prevents pasting the wrong text or | |
| # pasting at the wrong cursor location. | |
| while Gtk.events_pending(): | |
| Gtk.main_iteration() | |
| elif action == 'undo': | |
| doc.undo() | |
| elif action == 'redo': | |
| doc.redo() | |
| elif action == 'duplicate_line': | |
| self._synthesize_key_event(view, Gdk.KEY_d, Gdk.ModifierType.CONTROL_MASK) | |
| # Same as copy/cut/paste: let Xed finish handling the | |
| # synthetic keystroke before the next step runs. | |
| while Gtk.events_pending(): | |
| Gtk.main_iteration() | |
| elif action == 'set_cursor': | |
| row, col = data | |
| iter = doc.get_iter_at_line_offset(row, col) | |
| doc.place_cursor(iter) | |
| elif action == 'move': | |
| keyname, extend_selection, has_ctrl = data | |
| if keyname == "Right": | |
| step = Gtk.MovementStep.WORDS if has_ctrl else Gtk.MovementStep.LOGICAL_POSITIONS | |
| count = 1 | |
| elif keyname == "Left": | |
| step = Gtk.MovementStep.WORDS if has_ctrl else Gtk.MovementStep.LOGICAL_POSITIONS | |
| count = -1 | |
| elif keyname == "Up": | |
| step, count = Gtk.MovementStep.DISPLAY_LINES, -1 | |
| elif keyname == "Down": | |
| step, count = Gtk.MovementStep.DISPLAY_LINES, 1 | |
| elif keyname == "Home": | |
| step, count = Gtk.MovementStep.DISPLAY_LINE_ENDS, -1 | |
| elif keyname == "End": | |
| step, count = Gtk.MovementStep.DISPLAY_LINE_ENDS, 1 | |
| elif keyname == "Page_Up": | |
| step, count = Gtk.MovementStep.PAGES, -1 | |
| elif keyname == "Page_Down": | |
| step, count = Gtk.MovementStep.PAGES, 1 | |
| else: | |
| return | |
| view.emit("move-cursor", step, count, extend_selection) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment