patx/twig
twig - an ultra minimal code editor for linux
Commit 077a37c · harrison erd · 2026-06-13T18:58:33-04:00
Comments
No comments yet.
Diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..43ae0e2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.py[cod]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0a86dcd
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+DATADIR ?= $(PREFIX)/share
+APPDIR ?= $(DATADIR)/applications
+ICONDIR ?= $(DATADIR)/icons/hicolor
+
+.PHONY: install uninstall check
+
+install:
+ install -Dm755 twig.py "$(DESTDIR)$(BINDIR)/twig"
+ install -Dm644 twig.desktop "$(DESTDIR)$(APPDIR)/twig.desktop"
+ for size in 16 24 32 48 64 128 256 512; do \
+ install -Dm644 "icons/hicolor/$${size}x$${size}/apps/twig.png" \
+ "$(DESTDIR)$(ICONDIR)/$${size}x$${size}/apps/twig.png"; \
+ done
+
+uninstall:
+ rm -f "$(DESTDIR)$(BINDIR)/twig"
+ rm -f "$(DESTDIR)$(APPDIR)/twig.desktop"
+ for size in 16 24 32 48 64 128 256 512; do \
+ rm -f "$(DESTDIR)$(ICONDIR)/$${size}x$${size}/apps/twig.png"; \
+ done
+
+check:
+ python3 -m py_compile twig.py
+ python3 -m unittest discover -s tests
+ desktop-file-validate twig.desktop
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..613d236
--- /dev/null
+++ b/README.md
@@ -0,0 +1,76 @@
+# Twig
+
+Twig is a small GTK code editor intended for lightweight Linux desktops such
+as CrunchBang++. It focuses on the basics: one file per window, open/save,
+syntax highlighting, line numbers, find/replace, undo/redo, dirty indicators,
+and save-before-close prompts.
+
+Repository: <https://github.com/patx/twig>
+
+## Dependencies
+
+On CrunchBang++ or Debian-based systems:
+
+```sh
+sudo apt install python3 python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4
+```
+
+Some older systems package GtkSourceView 3 instead:
+
+```sh
+sudo apt install gir1.2-gtksource-3.0
+```
+
+## Run
+
+```sh
+./twig.py
+./twig.py path/to/file.py
+```
+
+## Install
+
+```sh
+sudo make install
+```
+
+Install somewhere else:
+
+```sh
+make install PREFIX="$HOME/.local"
+```
+
+Uninstall:
+
+```sh
+sudo make uninstall
+```
+
+## Shortcuts
+
+Twig intentionally has no toolbar or menu bar. Use these keyboard shortcuts:
+
+| Shortcut | Action |
+| --- | --- |
+| `Ctrl+N` or `Ctrl+T` | Open a new empty editor window |
+| `Ctrl+O` | Open one or more files |
+| `Ctrl+S` | Save the current file |
+| `Ctrl+Shift+S` | Save the current file as a new path |
+| `Ctrl+W` | Close the current window |
+| `Ctrl+P` | Print |
+| `Ctrl+Q` | Quit Twig, prompting for unsaved files |
+| `Ctrl+Z` | Undo |
+| `Ctrl+Shift+Z` or `Ctrl+Y` | Redo |
+| `Ctrl+X` | Cut |
+| `Ctrl+C` | Copy |
+| `Ctrl+V` | Paste |
+| `Ctrl+A` | Select all |
+| `Ctrl+F` | Open or focus Find and Replace |
+| `Ctrl+G` or `F3` | Find next match |
+| `Ctrl+Shift+G` or `Shift+F3` | Find previous match |
+| `Ctrl+H` or `Ctrl+R` | Open Find and Replace with the replace field focused |
+| `Ctrl+J` | Jump to line |
+| `Tab` with selected lines | Indent selected lines with spaces |
+| `Shift+Tab` with selected lines | Unindent selected lines |
+| `Enter` in Find | Find next match |
+| `Enter` in Replace | Replace current match |
diff --git a/icons/hicolor/128x128/apps/twig.png b/icons/hicolor/128x128/apps/twig.png
new file mode 100644
index 0000000..32a5c01
Binary files /dev/null and b/icons/hicolor/128x128/apps/twig.png differ
diff --git a/icons/hicolor/16x16/apps/twig.png b/icons/hicolor/16x16/apps/twig.png
new file mode 100644
index 0000000..9250385
Binary files /dev/null and b/icons/hicolor/16x16/apps/twig.png differ
diff --git a/icons/hicolor/24x24/apps/twig.png b/icons/hicolor/24x24/apps/twig.png
new file mode 100644
index 0000000..1c5cf38
Binary files /dev/null and b/icons/hicolor/24x24/apps/twig.png differ
diff --git a/icons/hicolor/256x256/apps/twig.png b/icons/hicolor/256x256/apps/twig.png
new file mode 100644
index 0000000..68f65b9
Binary files /dev/null and b/icons/hicolor/256x256/apps/twig.png differ
diff --git a/icons/hicolor/32x32/apps/twig.png b/icons/hicolor/32x32/apps/twig.png
new file mode 100644
index 0000000..48ed21a
Binary files /dev/null and b/icons/hicolor/32x32/apps/twig.png differ
diff --git a/icons/hicolor/48x48/apps/twig.png b/icons/hicolor/48x48/apps/twig.png
new file mode 100644
index 0000000..881eceb
Binary files /dev/null and b/icons/hicolor/48x48/apps/twig.png differ
diff --git a/icons/hicolor/512x512/apps/twig.png b/icons/hicolor/512x512/apps/twig.png
new file mode 100644
index 0000000..43a4a1b
Binary files /dev/null and b/icons/hicolor/512x512/apps/twig.png differ
diff --git a/icons/hicolor/64x64/apps/twig.png b/icons/hicolor/64x64/apps/twig.png
new file mode 100644
index 0000000..a58fa8f
Binary files /dev/null and b/icons/hicolor/64x64/apps/twig.png differ
diff --git a/icons/twig.png b/icons/twig.png
new file mode 100644
index 0000000..64599c2
Binary files /dev/null and b/icons/twig.png differ
diff --git a/tests/test_encoding.py b/tests/test_encoding.py
new file mode 100644
index 0000000..25f4e4a
--- /dev/null
+++ b/tests/test_encoding.py
@@ -0,0 +1,41 @@
+import importlib.util
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("twig", ROOT / "twig.py")
+twig = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(twig)
+
+
+class EncodingTests(unittest.TestCase):
+ def test_latin1_file_saves_as_utf8_when_text_needs_unicode(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "legacy.txt"
+ path.write_bytes("caf\xe9".encode("latin-1"))
+
+ text, encoding = twig.read_text_file(path)
+ self.assertEqual(text, "café")
+ self.assertEqual(encoding, "latin-1")
+
+ new_encoding = twig.write_text_file(path, text + " 🌿", encoding)
+
+ self.assertEqual(new_encoding, "utf-8")
+ self.assertEqual(path.read_text(encoding="utf-8"), "café 🌿")
+
+ def test_latin1_file_keeps_latin1_when_possible(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "legacy.txt"
+ path.write_bytes("caf\xe9".encode("latin-1"))
+
+ text, encoding = twig.read_text_file(path)
+ new_encoding = twig.write_text_file(path, text + "!", encoding)
+
+ self.assertEqual(new_encoding, "latin-1")
+ self.assertEqual(path.read_bytes(), "café!".encode("latin-1"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/twig.desktop b/twig.desktop
new file mode 100644
index 0000000..04e67e3
--- /dev/null
+++ b/twig.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Twig
+Comment=Lightweight code editor
+Exec=twig %F
+Icon=twig
+Terminal=false
+Categories=Utility;TextEditor;
+MimeType=text/plain;text/x-python;text/x-c;text/x-c++;text/x-shellscript;text/html;text/css;application/javascript;
+StartupNotify=true
diff --git a/twig.py b/twig.py
new file mode 100755
index 0000000..becf183
--- /dev/null
+++ b/twig.py
@@ -0,0 +1,699 @@
+#!/usr/bin/env python3
+"""Twig: a tiny GTK code editor for lightweight Linux desktops."""
+
+import sys
+from pathlib import Path
+
+try:
+ import gi
+
+ gi.require_version("Gdk", "3.0")
+ gi.require_version("Gtk", "3.0")
+ from gi.repository import Gdk, Gio, GLib, Gtk
+
+ try:
+ gi.require_version("GtkSource", "4")
+ except ValueError:
+ gi.require_version("GtkSource", "3.0")
+ from gi.repository import GtkSource
+except (ImportError, ValueError) as exc:
+ print(
+ "Twig requires GTK 3, PyGObject, and GtkSourceView.\n"
+ "On CrunchBang++/Debian, install:\n"
+ " sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4\n"
+ f"\nStartup error: {exc}",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+APP_ID = "io.github.patx.twig"
+UNTITLED = "Untitled"
+
+
+def read_text_file(path):
+ data = Path(path).read_bytes()
+ for encoding in ("utf-8", "utf-8-sig", "latin-1"):
+ try:
+ return data.decode(encoding), encoding
+ except UnicodeDecodeError:
+ pass
+ return data.decode("utf-8", errors="replace"), "utf-8"
+
+
+def write_text_file(path, text, encoding):
+ target_encoding = encoding or "utf-8"
+ try:
+ Path(path).write_text(text, encoding=target_encoding)
+ return target_encoding
+ except UnicodeEncodeError:
+ Path(path).write_text(text, encoding="utf-8")
+ return "utf-8"
+
+
+def install_css():
+ css = b"""
+ entry {
+ font: 10pt sans;
+ }
+
+ textview, textview text {
+ background: #1f2329;
+ color: #d8dee9;
+ font: 10pt monospace;
+ }
+
+ textview border {
+ background: #181b20;
+ color: #7f8a99;
+ }
+ """
+ provider = Gtk.CssProvider()
+ provider.load_from_data(css)
+ Gtk.StyleContext.add_provider_for_screen(
+ Gdk.Screen.get_default(),
+ provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
+ )
+
+
+class TwigWindow(Gtk.ApplicationWindow):
+ def __init__(self, app, path=None):
+ super().__init__(application=app)
+ self.app = app
+ self.path = Path(path).resolve() if path else None
+ self.encoding = "utf-8"
+ self.find_dialog = None
+ self.buffer = GtkSource.Buffer()
+ self.view = GtkSource.View.new_with_buffer(self.buffer)
+
+ self.set_default_size(920, 640)
+ self._build_actions()
+ self._build_ui()
+ self._configure_editor()
+ self.buffer.connect("modified-changed", lambda _buffer: self.update_title())
+ self.connect("delete-event", self._on_delete_event)
+
+ if self.path:
+ self.load()
+ else:
+ self.apply_language()
+ self.buffer.set_modified(False)
+ self.update_title()
+
+ @property
+ def title_name(self):
+ return self.path.name if self.path else UNTITLED
+
+ def _build_actions(self):
+ actions = {
+ "new": self.on_new,
+ "open": self.on_open,
+ "save": self.on_save,
+ "save-as": self.on_save_as,
+ "close": self.on_close,
+ "print": self.on_print,
+ "find": self.on_find,
+ "find-next": self.on_find_next,
+ "find-prev": self.on_find_prev,
+ "replace": self.on_replace,
+ "jump-to": self.on_jump_to,
+ "undo": self.on_undo,
+ "redo": self.on_redo,
+ "cut": self.on_cut,
+ "copy": self.on_copy,
+ "paste": self.on_paste,
+ "select-all": self.on_select_all,
+ }
+ for name, callback in actions.items():
+ action = Gio.SimpleAction.new(name, None)
+ action.connect("activate", callback)
+ self.add_action(action)
+
+ def _build_ui(self):
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+
+ scroller = Gtk.ScrolledWindow()
+ scroller.set_hexpand(True)
+ scroller.set_vexpand(True)
+ scroller.add(self.view)
+ root.pack_start(scroller, True, True, 0)
+
+ self.add(root)
+ self.show_all()
+ self.view.grab_focus()
+
+ def _configure_editor(self):
+ self.view.set_show_line_numbers(True)
+ self.view.set_monospace(True)
+ self.view.set_tab_width(4)
+ self.view.set_insert_spaces_instead_of_tabs(True)
+ self.view.set_auto_indent(True)
+ self.view.set_highlight_current_line(False)
+ self.view.connect("key-press-event", self.on_key_press)
+
+ if hasattr(self.buffer, "set_max_undo_levels"):
+ self.buffer.set_max_undo_levels(-1)
+ self.buffer.set_highlight_syntax(True)
+
+ style_manager = GtkSource.StyleSchemeManager.get_default()
+ for scheme_id in ("oblivion", "solarized-dark", "classic"):
+ scheme = style_manager.get_scheme(scheme_id)
+ if scheme:
+ self.buffer.set_style_scheme(scheme)
+ break
+
+ def apply_language(self):
+ manager = GtkSource.LanguageManager.get_default()
+ if not self.path:
+ self.buffer.set_language(None)
+ return
+
+ filename = str(self.path)
+ content_type = None
+ if self.path.exists():
+ content_type, _uncertain = Gio.content_type_guess(filename, None)
+ self.buffer.set_language(manager.guess_language(filename, content_type))
+
+ def load(self):
+ try:
+ text, self.encoding = read_text_file(self.path)
+ except OSError as exc:
+ self.show_error("Open failed", str(exc))
+ self.path = None
+ text = ""
+ self.buffer.set_text(text)
+ self.apply_language()
+ self.buffer.set_modified(False)
+ self.buffer.place_cursor(self.buffer.get_start_iter())
+ self.update_title()
+
+ def save(self, path=None):
+ if path:
+ self.path = Path(path).resolve()
+ if not self.path:
+ return self.save_as()
+
+ start = self.buffer.get_start_iter()
+ end = self.buffer.get_end_iter()
+ text = self.buffer.get_text(start, end, True)
+ try:
+ self.encoding = write_text_file(self.path, text, self.encoding)
+ except (OSError, UnicodeError) as exc:
+ self.show_error("Save failed", str(exc))
+ return False
+
+ self.apply_language()
+ self.buffer.set_modified(False)
+ self.update_title()
+ return True
+
+ def save_as(self):
+ dialog = Gtk.FileChooserDialog(
+ title="Save File",
+ transient_for=self,
+ action=Gtk.FileChooserAction.SAVE,
+ )
+ dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Save", Gtk.ResponseType.OK)
+ dialog.set_do_overwrite_confirmation(True)
+ if self.path:
+ dialog.set_filename(str(self.path))
+ response = dialog.run()
+ filename = dialog.get_filename()
+ dialog.destroy()
+ if response != Gtk.ResponseType.OK or not filename:
+ return False
+ return self.save(filename)
+
+ def confirm_save(self):
+ if not self.buffer.get_modified():
+ return True
+
+ dialog = Gtk.MessageDialog(
+ transient_for=self,
+ modal=True,
+ message_type=Gtk.MessageType.WARNING,
+ buttons=Gtk.ButtonsType.NONE,
+ text=f"Save changes to {self.title_name}?",
+ )
+ dialog.format_secondary_text("Unsaved changes will be lost if you discard them.")
+ dialog.add_buttons(
+ "_Cancel",
+ Gtk.ResponseType.CANCEL,
+ "_Discard",
+ Gtk.ResponseType.NO,
+ "_Save",
+ Gtk.ResponseType.YES,
+ )
+ response = dialog.run()
+ dialog.destroy()
+
+ if response == Gtk.ResponseType.YES:
+ return self.save()
+ if response == Gtk.ResponseType.NO:
+ return True
+ return False
+
+ def selected_text(self):
+ if not self.buffer.get_has_selection():
+ return ""
+ start, end = self.buffer.get_selection_bounds()
+ return self.buffer.get_text(start, end, True)
+
+ def find_text(self, needle, forward=True):
+ if not needle:
+ return False
+
+ flags = Gtk.TextSearchFlags.CASE_INSENSITIVE
+ insert = self.buffer.get_iter_at_mark(self.buffer.get_insert())
+ match = insert.forward_search(needle, flags, None) if forward else insert.backward_search(needle, flags, None)
+ if not match:
+ edge = self.buffer.get_start_iter() if forward else self.buffer.get_end_iter()
+ match = edge.forward_search(needle, flags, None) if forward else edge.backward_search(needle, flags, None)
+ if not match:
+ return False
+
+ start, end = match
+ if forward:
+ self.buffer.select_range(end, start)
+ else:
+ self.buffer.select_range(start, end)
+ self.view.scroll_to_iter(start, 0.15, False, 0.0, 0.0)
+ return True
+
+ def replace_selection(self, needle, replacement):
+ if not needle or not self.buffer.get_has_selection():
+ return False
+
+ start, end = self.buffer.get_selection_bounds()
+ selected = self.buffer.get_text(start, end, True)
+ if selected.lower() != needle.lower():
+ return False
+
+ self.buffer.begin_user_action()
+ self.buffer.delete(start, end)
+ self.buffer.insert_at_cursor(replacement)
+ self.buffer.end_user_action()
+ return True
+
+ def replace_current(self, needle, replacement):
+ if not needle:
+ return False
+ if not self.replace_selection(needle, replacement):
+ if not self.find_text(needle, True):
+ return False
+ if not self.replace_selection(needle, replacement):
+ return False
+ self.find_text(needle, True)
+ return True
+
+ def replace_all(self, needle, replacement):
+ if not needle:
+ return 0
+
+ count = 0
+ flags = Gtk.TextSearchFlags.CASE_INSENSITIVE
+ self.buffer.begin_user_action()
+ cursor = self.buffer.get_start_iter()
+ while True:
+ match = cursor.forward_search(needle, flags, None)
+ if not match:
+ break
+ start, end = match
+ next_offset = start.get_offset() + len(replacement)
+ self.buffer.delete(start, end)
+ self.buffer.insert(start, replacement)
+ cursor = self.buffer.get_iter_at_offset(next_offset)
+ count += 1
+ self.buffer.end_user_action()
+
+ return count
+
+ def selected_line_bounds(self):
+ start, end = self.buffer.get_selection_bounds()
+ if start.compare(end) > 0:
+ start, end = end, start
+ line_start = self.buffer.get_iter_at_line(start.get_line())
+ last_line = end.get_line()
+ if end.starts_line() and end.compare(start) != 0:
+ last_line -= 1
+ return line_start, max(start.get_line(), last_line)
+
+ def indent_selection(self):
+ if not self.buffer.get_has_selection():
+ return False
+
+ start, last_line = self.selected_line_bounds()
+ self.buffer.begin_user_action()
+ for line in range(start.get_line(), last_line + 1):
+ line_iter = self.buffer.get_iter_at_line(line)
+ self.buffer.insert(line_iter, " " * self.view.get_tab_width())
+ self.buffer.end_user_action()
+ return True
+
+ def unindent_selection(self):
+ if not self.buffer.get_has_selection():
+ return False
+
+ start, last_line = self.selected_line_bounds()
+ self.buffer.begin_user_action()
+ for line in range(start.get_line(), last_line + 1):
+ line_start = self.buffer.get_iter_at_line(line)
+ line_end = line_start.copy()
+ removed = 0
+ while removed < self.view.get_tab_width() and not line_end.ends_line():
+ char = line_end.get_char()
+ if char == " ":
+ line_end.forward_char()
+ removed += 1
+ elif char == "\t":
+ line_end.forward_char()
+ removed += self.view.get_tab_width()
+ break
+ else:
+ break
+ if line_start.compare(line_end) != 0:
+ self.buffer.delete(line_start, line_end)
+ self.buffer.end_user_action()
+ return True
+
+ def jump_to_line(self, line_number):
+ line_count = self.buffer.get_line_count()
+ line_index = max(0, min(line_number - 1, line_count - 1))
+ line_iter = self.buffer.get_iter_at_line(line_index)
+ self.buffer.place_cursor(line_iter)
+ self.view.scroll_to_iter(line_iter, 0.2, False, 0.0, 0.0)
+
+ def open_files(self, paths):
+ for index, path in enumerate(paths):
+ if index == 0 and self.is_empty_untitled():
+ self.path = Path(path).resolve()
+ self.load()
+ else:
+ self.app.open_window(path)
+
+ def is_empty_untitled(self):
+ start = self.buffer.get_start_iter()
+ end = self.buffer.get_end_iter()
+ return not self.path and not self.buffer.get_modified() and not self.buffer.get_text(start, end, True)
+
+ def update_title(self):
+ dirty = "*" if self.buffer.get_modified() else ""
+ name = str(self.path) if self.path else self.title_name
+ self.set_title(f"{dirty}{name} - Twig")
+
+ def show_error(self, title, detail):
+ dialog = Gtk.MessageDialog(
+ transient_for=self,
+ modal=True,
+ message_type=Gtk.MessageType.ERROR,
+ buttons=Gtk.ButtonsType.OK,
+ text=title,
+ )
+ dialog.format_secondary_text(detail)
+ dialog.run()
+ dialog.destroy()
+
+ def on_new(self, *_args):
+ self.app.open_window()
+
+ def on_open(self, *_args):
+ dialog = Gtk.FileChooserDialog(
+ title="Open File",
+ transient_for=self,
+ action=Gtk.FileChooserAction.OPEN,
+ )
+ dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Open", Gtk.ResponseType.OK)
+ dialog.set_select_multiple(True)
+ response = dialog.run()
+ filenames = dialog.get_filenames()
+ dialog.destroy()
+ if response == Gtk.ResponseType.OK:
+ self.open_files(filenames)
+
+ def on_save(self, *_args):
+ self.save()
+
+ def on_save_as(self, *_args):
+ self.save_as()
+
+ def on_close(self, *_args):
+ self.close()
+
+ def on_print(self, *_args):
+ compositor = GtkSource.PrintCompositor.new_from_view(self.view)
+ compositor.set_print_line_numbers(5)
+ operation = Gtk.PrintOperation()
+ operation.connect("paginate", lambda _operation, context: compositor.paginate(context))
+ operation.connect("draw-page", lambda _operation, context, page: compositor.draw_page(context, page))
+
+ def on_begin_print(_operation, context):
+ while not compositor.paginate(context):
+ pass
+ operation.set_n_pages(compositor.get_n_pages())
+
+ operation.connect("begin-print", on_begin_print)
+ try:
+ operation.run(Gtk.PrintOperationAction.PRINT_DIALOG, self)
+ except GLib.Error as exc:
+ self.show_error("Print failed", str(exc))
+
+ def on_find(self, *_args):
+ if self.find_dialog:
+ self.find_dialog.present()
+ return
+ self.find_dialog = FindReplaceDialog(self)
+ self.find_dialog.connect("destroy", lambda _dialog: setattr(self, "find_dialog", None))
+ self.find_dialog.show_all()
+
+ def on_find_next(self, *_args):
+ if self.find_dialog:
+ self.find_dialog.find_next()
+ else:
+ self.on_find()
+
+ def on_find_prev(self, *_args):
+ if self.find_dialog:
+ self.find_dialog.find_previous()
+ else:
+ self.on_find()
+
+ def on_replace(self, *_args):
+ self.on_find()
+ if self.find_dialog:
+ self.find_dialog.replace_entry.grab_focus()
+
+ def on_jump_to(self, *_args):
+ dialog = JumpToDialog(self)
+ dialog.show_all()
+
+ def on_undo(self, *_args):
+ if self.buffer.can_undo():
+ self.buffer.undo()
+
+ def on_redo(self, *_args):
+ if self.buffer.can_redo():
+ self.buffer.redo()
+
+ def on_cut(self, *_args):
+ clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
+ self.buffer.cut_clipboard(clipboard, True)
+
+ def on_copy(self, *_args):
+ clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
+ self.buffer.copy_clipboard(clipboard)
+
+ def on_paste(self, *_args):
+ clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
+ self.buffer.paste_clipboard(clipboard, None, True)
+
+ def on_select_all(self, *_args):
+ self.buffer.select_range(self.buffer.get_start_iter(), self.buffer.get_end_iter())
+
+ def on_key_press(self, _view, event):
+ if event.keyval == Gdk.KEY_Tab and self.buffer.get_has_selection():
+ return self.indent_selection()
+ if event.keyval in (Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Tab) and event.state & Gdk.ModifierType.SHIFT_MASK:
+ if self.buffer.get_has_selection():
+ return self.unindent_selection()
+ return False
+
+ def _on_delete_event(self, *_args):
+ return not self.confirm_save()
+
+
+class FindReplaceDialog(Gtk.Window):
+ def __init__(self, editor):
+ super().__init__(title="Find and Replace", transient_for=editor, modal=False)
+ self.editor = editor
+ self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
+ self.set_resizable(False)
+ self.set_border_width(8)
+
+ self.find_entry = Gtk.Entry()
+ self.replace_entry = Gtk.Entry()
+ self.status = Gtk.Label(xalign=0)
+
+ selected = editor.selected_text()
+ if selected and "\n" not in selected:
+ self.find_entry.set_text(selected)
+
+ self._build_ui()
+ self.find_entry.connect("activate", lambda _entry: self.find_next())
+ self.replace_entry.connect("activate", lambda _entry: self.replace_current())
+
+ def _build_ui(self):
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ grid = Gtk.Grid(column_spacing=8, row_spacing=6)
+
+ grid.attach(Gtk.Label(label="Find", xalign=0), 0, 0, 1, 1)
+ grid.attach(self.find_entry, 1, 0, 4, 1)
+ grid.attach(Gtk.Label(label="Replace", xalign=0), 0, 1, 1, 1)
+ grid.attach(self.replace_entry, 1, 1, 4, 1)
+
+ buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ buttons.pack_start(self._button("Previous", lambda _button: self.find_previous()), False, False, 0)
+ buttons.pack_start(self._button("Next", lambda _button: self.find_next()), False, False, 0)
+ buttons.pack_start(self._button("Replace", lambda _button: self.replace_current()), False, False, 0)
+ buttons.pack_start(self._button("Replace All", lambda _button: self.replace_all()), False, False, 0)
+ buttons.pack_start(self._button("Close", lambda _button: self.destroy()), False, False, 0)
+
+ outer.pack_start(grid, False, False, 0)
+ outer.pack_start(buttons, False, False, 0)
+ outer.pack_start(self.status, False, False, 0)
+ self.add(outer)
+
+ def _button(self, label, callback):
+ button = Gtk.Button(label=label)
+ button.connect("clicked", callback)
+ return button
+
+ def needle(self):
+ return self.find_entry.get_text()
+
+ def replacement(self):
+ return self.replace_entry.get_text()
+
+ def find_next(self):
+ found = self.editor.find_text(self.needle(), True)
+ self.status.set_text("" if found else "No matches")
+
+ def find_previous(self):
+ found = self.editor.find_text(self.needle(), False)
+ self.status.set_text("" if found else "No matches")
+
+ def replace_current(self):
+ replaced = self.editor.replace_current(self.needle(), self.replacement())
+ self.status.set_text("" if replaced else "No match selected")
+
+ def replace_all(self):
+ count = self.editor.replace_all(self.needle(), self.replacement())
+ self.status.set_text(f"Replaced {count} match" + ("" if count == 1 else "es"))
+
+
+class JumpToDialog(Gtk.Window):
+ def __init__(self, editor):
+ super().__init__(title="Jump To", transient_for=editor, modal=False)
+ self.editor = editor
+ self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
+ self.set_resizable(False)
+ self.set_border_width(8)
+
+ self.line_entry = Gtk.SpinButton()
+ self.line_entry.set_range(1, max(1, editor.buffer.get_line_count()))
+ self.line_entry.set_increments(1, 10)
+ self.line_entry.set_value(editor.buffer.get_iter_at_mark(editor.buffer.get_insert()).get_line() + 1)
+ self.line_entry.connect("activate", lambda _entry: self.jump())
+
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ row.pack_start(Gtk.Label(label="Line", xalign=0), False, False, 0)
+ row.pack_start(self.line_entry, True, True, 0)
+
+ buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ jump_button = Gtk.Button(label="Jump")
+ close_button = Gtk.Button(label="Close")
+ jump_button.connect("clicked", lambda _button: self.jump())
+ close_button.connect("clicked", lambda _button: self.destroy())
+ buttons.pack_start(jump_button, False, False, 0)
+ buttons.pack_start(close_button, False, False, 0)
+
+ outer.pack_start(row, False, False, 0)
+ outer.pack_start(buttons, False, False, 0)
+ self.add(outer)
+ self.line_entry.grab_focus()
+
+ def jump(self):
+ self.editor.jump_to_line(self.line_entry.get_value_as_int())
+ self.destroy()
+
+
+class TwigApp(Gtk.Application):
+ def __init__(self, initial_files):
+ flags = Gio.ApplicationFlags.NON_UNIQUE | Gio.ApplicationFlags.HANDLES_OPEN
+ super().__init__(application_id=APP_ID, flags=flags)
+ self.initial_files = initial_files
+
+ def do_startup(self):
+ Gtk.Application.do_startup(self)
+ install_css()
+
+ quit_action = Gio.SimpleAction.new("quit", None)
+ quit_action.connect("activate", self.on_quit)
+ self.add_action(quit_action)
+
+ shortcuts = {
+ "win.new": ["<Primary>n", "<Primary>t"],
+ "win.open": ["<Primary>o"],
+ "win.save": ["<Primary>s"],
+ "win.save-as": ["<Primary><Shift>s"],
+ "win.close": ["<Primary>w"],
+ "app.quit": ["<Primary>q"],
+ "win.print": ["<Primary>p"],
+ "win.find": ["<Primary>f"],
+ "win.replace": ["<Primary>h", "<Primary>r"],
+ "win.find-next": ["F3", "<Primary>g"],
+ "win.find-prev": ["<Shift>F3", "<Primary><Shift>g"],
+ "win.jump-to": ["<Primary>j"],
+ "win.undo": ["<Primary>z"],
+ "win.redo": ["<Primary>y", "<Primary><Shift>z"],
+ "win.cut": ["<Primary>x"],
+ "win.copy": ["<Primary>c"],
+ "win.paste": ["<Primary>v"],
+ "win.select-all": ["<Primary>a"],
+ }
+ for action, accels in shortcuts.items():
+ self.set_accels_for_action(action, accels)
+
+ def do_activate(self):
+ if self.initial_files:
+ for path in self.initial_files:
+ self.open_window(path)
+ self.initial_files = []
+ elif not self.get_windows():
+ self.open_window()
+
+ def do_open(self, files, _n_files, _hint):
+ for file in files:
+ self.open_window(file.get_path())
+
+ def open_window(self, path=None):
+ window = TwigWindow(self, path)
+ window.present()
+ return window
+
+ def on_quit(self, *_args):
+ for window in list(self.get_windows()):
+ window.close()
+ if window in self.get_windows():
+ break
+
+
+def main(argv):
+ files = [arg for arg in argv[1:] if not arg.startswith("-")]
+ app = TwigApp(files)
+ return app.run(argv)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))