Skip to content

Instantly share code, notes, and snippets.

@acidnine
Last active August 9, 2026 06:39
Show Gist options
  • Select an option

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

Select an option

Save acidnine/c2e103bdb6a0af772a1e19f22013314c to your computer and use it in GitHub Desktop.
XED Plugin Macro Recorder
[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
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