From a358e6d33051c28a924f42b0221624fafcfa7bce Mon Sep 17 00:00:00 2001 From: Tomlin7 Date: Sun, 5 Jul 2026 03:29:31 +0530 Subject: [PATCH] feat: add Vim mode (#405) - VimMode state machine (NORMAL / INSERT / VISUAL / VISUAL LINE / COMMAND) - motion.py: h/j/k/l, w/b/e, 0/$/^, gg/G, word boundaries - operator.py: dd/yy/p/P, dw/diw/cw/ciw, x, visual delete/yank/change - VimBar: : command-line input (:w :q :wq :), ephemeral op messages (1 line yanked, 2 lines deleted, selection yanked, etc.) - Status bar mode indicator (color-coded NORMAL/INSERT/VISUAL/COMMAND) - Keypress display (streamer mode): shows key combos in status bar pill, toggled via command palette - Toggle Keypress Display - All specific bindings (brackets, BackSpace, Return, Tab) guarded so Normal/Visual modes block character insertion correctly - Toggle via Ctrl+Alt+V or command palette - Toggle Vim Mode - Persistent vim_mode flag in settings.toml - keypress_display flag in config --- docs/vercel.json | 3 + src/biscuit/binder.py | 1 + src/biscuit/commands.py | 26 + src/biscuit/common/helpers.py | 1 + src/biscuit/common/ui/vimbar.py | 227 +++++++ src/biscuit/config.py | 6 + src/biscuit/editor/text/text.py | 20 + src/biscuit/editor/text/vim/__init__.py | 9 + src/biscuit/editor/text/vim/motion.py | 185 ++++++ src/biscuit/editor/text/vim/operator.py | 197 +++++++ src/biscuit/editor/text/vim/vim.py | 618 ++++++++++++++++++++ src/biscuit/gui.py | 6 + src/biscuit/layout/root.py | 18 +- src/biscuit/layout/statusbar/keypresslog.py | 178 ++++++ src/biscuit/layout/statusbar/statusbar.py | 50 +- src/biscuit/settings/config.py | 3 +- vercel.json | 5 + 17 files changed, 1546 insertions(+), 7 deletions(-) create mode 100644 docs/vercel.json create mode 100644 src/biscuit/common/ui/vimbar.py create mode 100644 src/biscuit/editor/text/vim/__init__.py create mode 100644 src/biscuit/editor/text/vim/motion.py create mode 100644 src/biscuit/editor/text/vim/operator.py create mode 100644 src/biscuit/editor/text/vim/vim.py create mode 100644 src/biscuit/layout/statusbar/keypresslog.py create mode 100644 vercel.json diff --git a/docs/vercel.json b/docs/vercel.json new file mode 100644 index 00000000..f92a3f8a --- /dev/null +++ b/docs/vercel.json @@ -0,0 +1,3 @@ +{ + "framework": "nextjs" +} diff --git a/src/biscuit/binder.py b/src/biscuit/binder.py index bcb87dc1..d4176d02 100644 --- a/src/biscuit/binder.py +++ b/src/biscuit/binder.py @@ -66,6 +66,7 @@ def late_bind_all(self) -> None: self.bind( self.bindings.open_recent_session, self.events.restore_last_closed_editor ) + self.bind("", self.events.toggle_vim_mode) def bind(self, this, to_this) -> None: self.base.bind(this, to_this) diff --git a/src/biscuit/commands.py b/src/biscuit/commands.py index 2ce68d2a..3f0754cf 100644 --- a/src/biscuit/commands.py +++ b/src/biscuit/commands.py @@ -257,6 +257,32 @@ def toggle_relative_line_numbering(self, *_) -> None: if e.content and e.content.editable: e.content.text.toggle_relative_numbering() + def toggle_vim_mode(self, *_) -> None: + """Toggle Vim modal editing on the active text editor.""" + self.base.vim_mode = not self.base.vim_mode + for e in self.base.editorsmanager.active_editors: + if e.content and e.content.editable: + if self.base.vim_mode: + e.content.text.enable_vim_mode() + else: + e.content.text.disable_vim_mode() + if not self.base.vim_mode: + try: + self.base.statusbar.clear_vim_mode() + except Exception: + pass + + def toggle_keypress_display(self, *_) -> None: + """Toggle the streamer keypress display in the status bar.""" + self.base.keypress_display = not self.base.keypress_display + try: + if self.base.keypress_display: + self.base.statusbar.enable_keypress_display() + else: + self.base.statusbar.disable_keypress_display() + except Exception: + pass + def undo(self, *_) -> None: if editor := self.base.editorsmanager.active_editor: if editor.content and editor.content.editable: diff --git a/src/biscuit/common/helpers.py b/src/biscuit/common/helpers.py index 01db6963..7fdd3b2e 100644 --- a/src/biscuit/common/helpers.py +++ b/src/biscuit/common/helpers.py @@ -24,6 +24,7 @@ def check_python_installation(): try: if os.name == "nt": sp.check_call(["python", "--version"]) + # sp.Popen(["pip", "install", "python-lsp-server"]) else: sp.check_call(["python3", "--version"]) diff --git a/src/biscuit/common/ui/vimbar.py b/src/biscuit/common/ui/vimbar.py new file mode 100644 index 00000000..244a1446 --- /dev/null +++ b/src/biscuit/common/ui/vimbar.py @@ -0,0 +1,227 @@ +"""Vim command-line bar. + +A thin bar that appears above the status bar when Vim mode enters +Command mode (:). Also shows Vim operation messages (e.g. '1 line yanked') +that auto-dismiss after a short timeout. +Accepts :w, :q, :wq, and : (goto line). +""" + +from __future__ import annotations + +import tkinter as tk + + +class VimBar(tk.Frame): + """Vim-style command-line bar rendered at the bottom of the window. + + Positioned above the status bar (hidden by default). + - VimMode calls show()/hide() for command-mode input. + - VimMode calls show_message() for ephemeral operation messages. + """ + + def __init__(self, master, *args, **kwargs) -> None: + super().__init__(master, *args, **kwargs) + # Traverse master chain to find App (which sets self.base = self) + node = master + while node is not None: + if hasattr(node, "base"): + self.base = node.base + break + node = getattr(node, "master", None) + else: + self.base = master # fallback: assume master IS the App + self._visible = False + self._msg_after_id = None # pending auto-hide timer + + # Theming + bg = self.base.theme.layout.statusbar.background + fg = self.base.theme.layout.statusbar.button.foreground + self.configure(bg=bg, height=26) + + # --- Command-input row (: prompt + entry) -------------------------- + self._cmd_frame = tk.Frame(self, bg=bg) + + self._prompt = tk.Label( + self._cmd_frame, + text=":", + bg=bg, + fg=fg, + font=self.base.settings.uifont, + padx=6, + ) + self._prompt.pack(side=tk.LEFT) + + self._entry = tk.Entry( + self._cmd_frame, + bg=bg, + fg=fg, + insertbackground=fg, + relief=tk.FLAT, + highlightthickness=0, + font=self.base.settings.uifont, + bd=0, + ) + self._entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + self._entry.bind("", self._on_submit) + self._entry.bind("", self._on_escape) + self._entry.bind("", self._on_submit) + + # --- Message label (ephemeral vim operation messages) --------------- + self._msg_label = tk.Label( + self, + text="", + bg=bg, + fg=fg, + font=self.base.settings.uifont, + anchor=tk.W, + padx=8, + ) + + # ------------------------------------------------------------------ + # Visibility — command mode + # ------------------------------------------------------------------ + + def show(self) -> None: + """Show the command bar (: prompt) and focus the entry.""" + self._cancel_msg_timer() + self._msg_label.pack_forget() + if not self._visible: + self._visible = True + self._entry.delete(0, tk.END) + self._pack_above_statusbar() + self._cmd_frame.pack(fill=tk.X, expand=True) + self._entry.focus_set() + + def hide(self) -> None: + """Hide the command bar and return focus to the active editor.""" + if self._visible: + self._visible = False + self._cmd_frame.pack_forget() + self.pack_forget() + self._return_focus() + + # ------------------------------------------------------------------ + # Ephemeral operation messages (e.g. "1 line yanked") + # ------------------------------------------------------------------ + + def show_message(self, msg: str, timeout_ms: int = 2000) -> None: + """Display a temporary Vim-style operation message. + + The bar appears, shows *msg*, then auto-hides after *timeout_ms*. + Works independently of command mode. + """ + self._cancel_msg_timer() + + # Don't cover an active command input + if self._visible: + return + + fg = self.base.theme.layout.statusbar.button.foreground + self._msg_label.configure(text=msg, fg=fg) + self._cmd_frame.pack_forget() + self._pack_above_statusbar() + self._msg_label.pack(fill=tk.X, expand=True) + + self._msg_after_id = self.after(timeout_ms, self._hide_message) + + def _hide_message(self) -> None: + """Auto-hide the message label and remove the bar.""" + self._msg_after_id = None + self._msg_label.pack_forget() + if not self._visible: + self.pack_forget() + self._return_focus() + + def _cancel_msg_timer(self) -> None: + if self._msg_after_id is not None: + try: + self.after_cancel(self._msg_after_id) + except Exception: + pass + self._msg_after_id = None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _pack_above_statusbar(self) -> None: + """Pack this bar above the status bar in the same container.""" + try: + self.base.statusbar.pack_forget() + self.pack(fill=tk.X) + self.base.statusbar.pack() + except Exception: + self.pack(fill=tk.X) + + def _return_focus(self) -> None: + """Return keyboard focus to the active editor.""" + try: + editor = self.base.editorsmanager.active_editor + if editor and editor.content and editor.content.editable: + editor.content.text.focus_set() + except Exception: + pass + + # ------------------------------------------------------------------ + # Command dispatch + # ------------------------------------------------------------------ + + def _on_escape(self, _: tk.Event) -> None: + self.hide() + # Tell VimMode to return to Normal + try: + editor = self.base.editorsmanager.active_editor + if editor and editor.content and editor.content.editable: + text = editor.content.text + if text.vim: + from biscuit.editor.text.vim.vim import NORMAL + text.vim._set_mode(NORMAL) + except Exception: + pass + + def _on_submit(self, _: tk.Event) -> None: + cmd = self._entry.get().strip() + self.hide() + self._dispatch(cmd) + + def _dispatch(self, cmd: str) -> None: + """Parse and execute a Vim ex command.""" + if not cmd: + return + if self._dispatch_file_cmd(cmd): + return + if self._dispatch_goto(cmd): + return + # Unknown command — notify + try: + self.base.notifications.warning(f"Vim: unknown command ':{cmd}'") + except Exception: + pass + + def _dispatch_file_cmd(self, cmd: str) -> bool: + """Handle :w, :q, :wq variants. Returns True if handled.""" + if cmd in ("wq", "wq!"): + self.base.commands.save_file() + self.base.commands.close_editor() + return True + if cmd in ("w", "w!"): + self.base.commands.save_file() + return True + if cmd in ("q", "q!"): + self.base.commands.close_editor() + return True + return False + + def _dispatch_goto(self, cmd: str) -> bool: + """Handle : goto-line. Returns True if handled.""" + if not cmd.lstrip("-").isdigit(): + return False + try: + line = int(cmd) + editor = self.base.editorsmanager.active_editor + if editor and editor.content and editor.content.editable: + editor.content.text.goto_line(line) + except Exception: + pass + return True diff --git a/src/biscuit/config.py b/src/biscuit/config.py index d381425a..59726bf2 100644 --- a/src/biscuit/config.py +++ b/src/biscuit/config.py @@ -67,6 +67,8 @@ class ConfigManager: tab_spaces = 4 relative_line_numbers = False block_cursor = False + vim_mode = False + keypress_display = False active_directory = None active_branch_name = None onupdate_callbacks = [] @@ -99,6 +101,10 @@ def setup_configs(self) -> None: self.execution_manager = ExecutionManager(self) self.debugger_manager = DebuggerManager(self) + # Apply persistent vim_mode setting + self.vim_mode = self.config.vim_mode + self.keypress_display = getattr(self.config, "keypress_display", False) + def setup_path(self, appdir: str) -> None: """Sets up the application paths and directories.""" diff --git a/src/biscuit/editor/text/text.py b/src/biscuit/editor/text/text.py index c7de022a..d5ead892 100644 --- a/src/biscuit/editor/text/text.py +++ b/src/biscuit/editor/text/text.py @@ -35,6 +35,7 @@ from ..comment_prefix import get_comment_prefix from .highlighter import Highlighter +from .vim import VimMode BRACKET_MAP = {"(": ")", "{": "}", "[": "]"} BRACKET_MAP_REV = {v: k for k, v in BRACKET_MAP.items()} @@ -134,6 +135,13 @@ def __init__( self._edit_stack = [] self._edit_stack_index = -1 + # Vim mode instance (None when disabled) + self.vim: VimMode | None = None + + # Auto-enable if vim mode is globally active + if not self.minimalist and not self.standalone and getattr(self.base, "vim_mode", False): + self.after_idle(self.enable_vim_mode) + def config_tags(self): self.tag_config(tk.SEL, background=self.base.theme.editors.selection) @@ -1001,6 +1009,18 @@ def set_tab_size(self, size: int) -> None: def set_block_cursor(self, flag: bool) -> None: self.configure(blockcursor=flag) + def enable_vim_mode(self) -> None: + """Activate Vim modal editing for this Text widget.""" + if self.vim is not None: + return # already active + self.vim = VimMode(self) + + def disable_vim_mode(self) -> None: + """Deactivate Vim modal editing and restore normal editing.""" + if self.vim is not None: + self.vim.disable() + self.vim = None + def toggle_relative_numbering(self) -> None: self.relative_line_numbers = not self.relative_line_numbers self.master.on_change() diff --git a/src/biscuit/editor/text/vim/__init__.py b/src/biscuit/editor/text/vim/__init__.py new file mode 100644 index 00000000..818a0c16 --- /dev/null +++ b/src/biscuit/editor/text/vim/__init__.py @@ -0,0 +1,9 @@ +"""Vim mode emulation for Biscuit text editor. + +Provides Normal, Insert, Visual, and Command modes with authentic +Vim keybindings. Toggle via command palette or Ctrl+Alt+V. +""" + +from .vim import VimMode + +__all__ = ["VimMode"] diff --git a/src/biscuit/editor/text/vim/motion.py b/src/biscuit/editor/text/vim/motion.py new file mode 100644 index 00000000..6439ed1b --- /dev/null +++ b/src/biscuit/editor/text/vim/motion.py @@ -0,0 +1,185 @@ +"""Motion helpers for Vim mode. + +Pure functions that compute new cursor positions in Tkinter Text widget +index notation (e.g. '3.7'). All functions accept and return strings +that are valid Tkinter text indices. +""" + +from __future__ import annotations + +import re +import tkinter as tk +import typing + +if typing.TYPE_CHECKING: + from biscuit.editor.text.text import Text + + +# --------------------------------------------------------------------------- +# Basic motion +# --------------------------------------------------------------------------- + +def move_left(text: "Text") -> str: + """Move one character left, stopping at line start.""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + if col > 0: + return f"{line}.{col - 1}" + return idx + + +def move_right(text: "Text") -> str: + """Move one character right, stopping before the newline.""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + line_end_col = int(text.index(f"{line}.end").split(".")[1]) + if col < line_end_col: + return f"{line}.{col + 1}" + return idx + + +def move_up(text: "Text") -> str: + """Move one line up, keeping column if possible.""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + if line <= 1: + return f"1.{col}" + new_line = line - 1 + new_line_len = int(text.index(f"{new_line}.end").split(".")[1]) + return f"{new_line}.{min(col, new_line_len)}" + + +def move_down(text: "Text") -> str: + """Move one line down, keeping column if possible.""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + last_line = int(text.index(tk.END).split(".")[0]) - 1 + if line >= last_line: + return idx + new_line = line + 1 + new_line_len = int(text.index(f"{new_line}.end").split(".")[1]) + return f"{new_line}.{min(col, new_line_len)}" + + +# --------------------------------------------------------------------------- +# Word motions +# --------------------------------------------------------------------------- + +_WORD_CHARS = re.compile(r"\w") + + +def _is_word_char(ch: str) -> bool: + return bool(_WORD_CHARS.match(ch)) + + +def word_end(text: "Text") -> str: + """Move to end of next word (like Vim's 'e').""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + content = text.get(f"{line}.0", f"{line}.end") + # skip current word chars, then skip non-word, then find word end + i = col + length = len(content) + # skip whitespace first + while i < length and not _is_word_char(content[i]): + i += 1 + # move to end of the word + while i < length and _is_word_char(content[i]): + i += 1 + i = max(0, i - 1) + return f"{line}.{i}" + + +def word_start_next(text: "Text") -> str: + """Move to start of next word (like Vim's 'w').""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + total_lines = int(text.index(tk.END).split(".")[0]) - 1 + content = text.get(f"{line}.0", f"{line}.end") + i = col + length = len(content) + # skip current word + while i < length and _is_word_char(content[i]): + i += 1 + # skip whitespace + while i < length and not _is_word_char(content[i]): + i += 1 + if i < length: + return f"{line}.{i}" + # wrap to next line + if line < total_lines: + next_content = text.get(f"{line + 1}.0", f"{line + 1}.end") + j = 0 + while j < len(next_content) and not _is_word_char(next_content[j]): + j += 1 + return f"{line + 1}.{j}" + return idx + + +def word_start_prev(text: "Text") -> str: + """Move to start of previous word (like Vim's 'b').""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + content = text.get(f"{line}.0", f"{line}.end") + i = col - 1 + # skip whitespace backwards + while i >= 0 and not _is_word_char(content[i]): + i -= 1 + # skip word chars backwards + while i > 0 and _is_word_char(content[i - 1]): + i -= 1 + if i >= 0: + return f"{line}.{i}" + if line > 1: + prev_content = text.get(f"{line - 1}.0", f"{line - 1}.end") + j = len(prev_content) - 1 + while j > 0 and not _is_word_char(prev_content[j]): + j -= 1 + while j > 0 and _is_word_char(prev_content[j - 1]): + j -= 1 + return f"{line - 1}.{j}" + return idx + + +# --------------------------------------------------------------------------- +# Word boundary detection (for diw / ciw) +# --------------------------------------------------------------------------- + +def get_word_bounds(text: "Text") -> tuple[str, str]: + """Return (start_index, end_index) of the word under the cursor.""" + idx = text.index(tk.INSERT) + line, col = map(int, idx.split(".")) + content = text.get(f"{line}.0", f"{line}.end") + if not content: + return (idx, idx) + + # Find word boundaries around col + start = col + end = col + while start > 0 and _is_word_char(content[start - 1]): + start -= 1 + while end < len(content) and _is_word_char(content[end]): + end += 1 + return (f"{line}.{start}", f"{line}.{end}") + + +# --------------------------------------------------------------------------- +# Line helpers +# --------------------------------------------------------------------------- + +def get_line_start(text: "Text") -> str: + return text.index("insert linestart") + + +def get_line_end(text: "Text") -> str: + return text.index("insert lineend") + + +def get_first_nonblank(text: "Text") -> str: + """Return index of first non-blank character in current line (like ^).""" + line = int(text.index(tk.INSERT).split(".")[0]) + content = text.get(f"{line}.0", f"{line}.end") + col = 0 + while col < len(content) and content[col] in (" ", "\t"): + col += 1 + return f"{line}.{col}" diff --git a/src/biscuit/editor/text/vim/operator.py b/src/biscuit/editor/text/vim/operator.py new file mode 100644 index 00000000..e3e5ff04 --- /dev/null +++ b/src/biscuit/editor/text/vim/operator.py @@ -0,0 +1,197 @@ +"""Vim operator actions (delete, yank, change, paste). + +These functions operate on the Tkinter Text widget directly. +They also maintain an internal yank register and mirror it to +the system clipboard for cross-application pasting. +""" + +from __future__ import annotations + +import tkinter as tk +import typing + +from . import motion + +if typing.TYPE_CHECKING: + from biscuit.editor.text.text import Text + +# Internal yank register (single unnamed register like Vim's `"`) +_register: str = "" + + +def get_register() -> str: + return _register + + +def set_register(text: "Text", content: str) -> None: + global _register + _register = content + # Mirror to system clipboard + try: + text.clipboard_clear() + text.clipboard_append(content) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Delete operations +# --------------------------------------------------------------------------- + +def delete_char(text: "Text") -> None: + """x — delete character under cursor, yank it.""" + idx = text.index(tk.INSERT) + char = text.get(idx, f"{idx}+1c") + if char and char != "\n": + set_register(text, char) + text.delete(idx, f"{idx}+1c") + + +def delete_line(text: "Text") -> None: + """dd — delete current line, yank it.""" + line = int(text.index(tk.INSERT).split(".")[0]) + total = int(text.index(tk.END).split(".")[0]) - 1 + + if total == 1: + # Only one line: clear it but keep the line + content = text.get(f"{line}.0", f"{line}.end") + set_register(text, content + "\n") + text.delete(f"{line}.0", f"{line}.end") + else: + content = text.get(f"{line}.0", f"{line}.end\n") + if not content.endswith("\n"): + content += "\n" + set_register(text, content) + text.delete(f"{line}.0", f"{line + 1}.0") + + +def delete_to_word_end(text: "Text") -> None: + """dw — delete from cursor to end of word.""" + start = text.index(tk.INSERT) + end = motion.word_end(text) + if start != end: + content = text.get(start, f"{end}+1c") + set_register(text, content) + text.delete(start, f"{end}+1c") + + +def delete_inner_word(text: "Text") -> None: + """diw — delete word under cursor.""" + start, end = motion.get_word_bounds(text) + content = text.get(start, end) + set_register(text, content) + text.delete(start, end) + text.mark_set(tk.INSERT, start) + + +def delete_selection(text: "Text") -> None: + """Delete selected text, yank it.""" + if text.tag_ranges(tk.SEL): + content = text.get(tk.SEL_FIRST, tk.SEL_LAST) + set_register(text, content) + text.delete(tk.SEL_FIRST, tk.SEL_LAST) + text.tag_remove(tk.SEL, "1.0", tk.END) + + +def delete_lines_selection(text: "Text") -> None: + """Delete selected lines (Visual Line mode), yank them.""" + if not text.tag_ranges(tk.SEL): + return + sel_start = text.index(tk.SEL_FIRST) + sel_end = text.index(tk.SEL_LAST) + start_line = int(sel_start.split(".")[0]) + end_line = int(sel_end.split(".")[0]) + content = text.get(f"{start_line}.0", f"{end_line}.end\n") + set_register(text, content) + text.delete(f"{start_line}.0", f"{end_line + 1}.0") + text.tag_remove(tk.SEL, "1.0", tk.END) + + +# --------------------------------------------------------------------------- +# Yank operations +# --------------------------------------------------------------------------- + +def yank_line(text: "Text") -> None: + """yy — yank current line.""" + line = int(text.index(tk.INSERT).split(".")[0]) + content = text.get(f"{line}.0", f"{line}.end") + "\n" + set_register(text, content) + + +def yank_selection(text: "Text") -> None: + """Yank selected text.""" + if text.tag_ranges(tk.SEL): + content = text.get(tk.SEL_FIRST, tk.SEL_LAST) + set_register(text, content) + text.tag_remove(tk.SEL, "1.0", tk.END) + + +def yank_lines_selection(text: "Text") -> None: + """Yank selected lines (Visual Line mode).""" + if not text.tag_ranges(tk.SEL): + return + sel_start = text.index(tk.SEL_FIRST) + sel_end = text.index(tk.SEL_LAST) + start_line = int(sel_start.split(".")[0]) + end_line = int(sel_end.split(".")[0]) + content = text.get(f"{start_line}.0", f"{end_line}.end") + "\n" + set_register(text, content) + text.tag_remove(tk.SEL, "1.0", tk.END) + + +# --------------------------------------------------------------------------- +# Change operations (delete + enter Insert mode) +# --------------------------------------------------------------------------- + +def change_to_word_end(text: "Text") -> None: + """cw — delete from cursor to word end (caller enters Insert mode).""" + start = text.index(tk.INSERT) + end = motion.word_end(text) + if start != end: + content = text.get(start, f"{end}+1c") + set_register(text, content) + text.delete(start, f"{end}+1c") + + +def change_inner_word(text: "Text") -> None: + """ciw — delete inner word (caller enters Insert mode).""" + start, end = motion.get_word_bounds(text) + content = text.get(start, end) + set_register(text, content) + text.delete(start, end) + text.mark_set(tk.INSERT, start) + + +# --------------------------------------------------------------------------- +# Paste +# --------------------------------------------------------------------------- + +def paste_after(text: "Text") -> None: + """p — paste after cursor (line-wise: below, char-wise: after).""" + content = _register + if not content: + return + if content.endswith("\n"): + # Line-wise paste: insert below current line + line = int(text.index(tk.INSERT).split(".")[0]) + text.insert(f"{line}.end", "\n" + content.rstrip("\n")) + text.mark_set(tk.INSERT, f"{line + 1}.0") + else: + # Character-wise paste: insert after cursor + idx = text.index(tk.INSERT) + text.insert(f"{idx}+1c", content) + text.mark_set(tk.INSERT, f"{idx}+{len(content)}c") + + +def paste_before(text: "Text") -> None: + """P — paste before cursor.""" + content = _register + if not content: + return + if content.endswith("\n"): + line = int(text.index(tk.INSERT).split(".")[0]) + text.insert(f"{line}.0", content) + text.mark_set(tk.INSERT, f"{line}.0") + else: + idx = text.index(tk.INSERT) + text.insert(idx, content) diff --git a/src/biscuit/editor/text/vim/vim.py b/src/biscuit/editor/text/vim/vim.py new file mode 100644 index 00000000..24ed2dc9 --- /dev/null +++ b/src/biscuit/editor/text/vim/vim.py @@ -0,0 +1,618 @@ +"""Vim mode core state machine for the Biscuit text editor. + +Manages the four Vim modes: NORMAL, INSERT, VISUAL, COMMAND. +Intercepts key events on the Text widget and dispatches to +motion helpers and operator functions. + +Usage (called from Text widget): + self.vim = VimMode(self) # enable + self.vim.disable() # disable +""" + +from __future__ import annotations + +import tkinter as tk +import typing + +from . import motion, operator + +if typing.TYPE_CHECKING: + from biscuit.editor.text.text import Text + +# Mode constants +NORMAL = "NORMAL" +INSERT = "INSERT" +VISUAL = "VISUAL" +VISUAL_LINE = "VISUAL LINE" +COMMAND = "COMMAND" + +# Sequences set up by Text.config_bindings that insert/delete text. +# These take Tk priority over the generic binding, so they +# must be individually wrapped to respect the current Vim mode. +# Maps (sequence -> method_name_on_Text). +_CHAR_SEQ_METHODS: dict[str, str] = { + "": "enter_key_events", + "": "tab_key_events", + "": "dedent_selection", + "": "remove_pair", + "": "open_bracket", + "": "open_bracket", + "": "open_bracket", + "": "close_bracket", + "": "close_bracket", + "": "close_bracket", + "": "complete_pair", + "": "complete_pair", +} + + +class VimMode: + """Vim modal editing state machine. + + Binds to the parent Text widget. In Normal/Visual mode ALL character + input (including specific bindings like ) is blocked. + In Insert mode the original handlers are called through normally. + """ + + def __init__(self, text: "Text") -> None: + self.text = text + self.base = text.base + self._mode: str = NORMAL + # Pending command buffer (e.g., 'd', 'c', 'g') + self._pending: str = "" + # Visual mode anchor index + self._visual_anchor: str = "" + self._visual_line_anchor: int = 0 + + # Set block cursor for Normal mode + self.text.configure(blockcursor=True) + + # Generic KeyPress handler (letters, digits, symbols without specific bindings) + self.text.bind("", self._on_keypress, add=True) + self.text.bind("", self._on_mouse_click, add=True) + + # Wrap specific char bindings so Normal/Visual modes block them + self._setup_char_guards() + + self._set_mode(NORMAL) + + # ------------------------------------------------------------------ + # Mode management + # ------------------------------------------------------------------ + + def _set_mode(self, mode: str) -> None: + self._mode = mode + self._pending = "" + self._apply_cursor_for_mode(mode) + self._notify_statusbar(mode) + self._apply_vimbar_for_mode(mode) + + def _apply_cursor_for_mode(self, mode: str) -> None: + """Set block/line cursor based on mode.""" + self.text.configure(blockcursor=(mode != INSERT and mode != COMMAND)) + + def _notify_statusbar(self, mode: str) -> None: + """Update status bar mode label.""" + try: + self.base.statusbar.set_vim_mode(mode) + except Exception: + pass + + def _apply_vimbar_for_mode(self, mode: str) -> None: + """Show/hide the vim command bar.""" + try: + if mode == COMMAND: + self.base.vimbar.show() + else: + self.base.vimbar.hide() + except Exception: + pass + + def _vim_msg(self, msg: str) -> None: + """Show a Vim-style ephemeral message in the VimBar.""" + try: + self.base.vimbar.show_message(msg) + except Exception: + pass + + def disable(self) -> None: + """Remove all Vim bindings and restore default cursor.""" + try: + self.text.unbind("") + self.text.unbind("") + except Exception: + pass + self._restore_char_bindings() + self.text.configure(blockcursor=self.base.block_cursor) + try: + self.base.statusbar.clear_vim_mode() + except Exception: + pass + + # ------------------------------------------------------------------ + # Specific-binding guards + # ------------------------------------------------------------------ + + def _setup_char_guards(self) -> None: + """Replace specific char bindings with vim-mode-aware wrappers. + + In Tk, a specific binding like has higher priority + than the generic , so it fires INSTEAD of . + We must wrap each one to block input in non-INSERT modes. + """ + for seq, method_name in _CHAR_SEQ_METHODS.items(): + original = getattr(self.text, method_name, None) + if original is not None: + self.text.bind(seq, self._make_guard(seq, original)) + + def _restore_char_bindings(self) -> None: + """Restore the original specific bindings when vim mode is disabled.""" + for seq, method_name in _CHAR_SEQ_METHODS.items(): + original = getattr(self.text, method_name, None) + if original is not None: + self.text.bind(seq, original) + + def _make_guard(self, seq: str, original): + """Return a wrapper that blocks *seq* outside of INSERT mode. + + In INSERT mode the original handler is called through unchanged. + In NORMAL mode some keys have special Vim meanings (BackSpace → h, + Return → j). In VISUAL modes all char-inserting keys are blocked. + """ + def guard(event: tk.Event) -> str | None: + if self._mode == INSERT: + return original(event) # normal editing + # Normal-mode special motions + if self._mode == NORMAL: + if seq == "": + self.text.mark_set(tk.INSERT, motion.move_left(self.text)) + self.text.see(tk.INSERT) + return "break" + if seq == "": + self.text.mark_set(tk.INSERT, motion.move_down(self.text)) + self.text.see(tk.INSERT) + return "break" + # All other modes / sequences: block + return "break" + return guard + + # ------------------------------------------------------------------ + # Mouse support + # ------------------------------------------------------------------ + + def _on_mouse_click(self, event: tk.Event) -> None: + """In VISUAL modes, extend selection to click position.""" + if self._mode in (VISUAL, VISUAL_LINE): + # Let the default click move the cursor first, then extend selection + self.text.after_idle(self._update_visual_selection) + + # ------------------------------------------------------------------ + # Master key dispatcher + # ------------------------------------------------------------------ + + def _on_keypress(self, event: tk.Event) -> str | None: + keysym = event.keysym + char = event.char + + if self._mode == INSERT: + return self._handle_insert(keysym, char, event) + elif self._mode == NORMAL: + return self._handle_normal(keysym, char, event) + elif self._mode in (VISUAL, VISUAL_LINE): + return self._handle_visual(keysym, char, event) + elif self._mode == COMMAND: + # Command bar handles its own input; just catch Escape here + if keysym == "Escape": + self._set_mode(NORMAL) + return "break" + return None + + # ------------------------------------------------------------------ + # INSERT mode + # ------------------------------------------------------------------ + + def _handle_insert(self, keysym: str, char: str, event: tk.Event) -> str | None: + if keysym == "Escape": + # Move cursor left one (Vim moves back when exiting insert) + idx = self.text.index(tk.INSERT) + new_idx = motion.move_left(self.text) + if new_idx != idx: + self.text.mark_set(tk.INSERT, new_idx) + self._set_mode(NORMAL) + return "break" + # All other keys: let the text widget handle normally + return None + + # ------------------------------------------------------------------ + # NORMAL mode + # ------------------------------------------------------------------ + + def _handle_normal(self, keysym: str, char: str, event: tk.Event) -> str | None: # noqa: C901 + """Dispatch Normal-mode key. Returns 'break' to suppress insertion.""" + + # --- Modifier-key-only events (pass through) --- + if keysym in ( + "Control_L", "Control_R", "Alt_L", "Alt_R", + "Shift_L", "Shift_R", "Meta_L", "Meta_R", + "Super_L", "Super_R", "Caps_Lock", "Num_Lock", + ): + return None + + # --- Ctrl combos that should still work in Normal mode --- + if event.state & 0x4: # Control key held + if keysym == "r": + self.text.stack_redo() + return "break" + if keysym == "f": + self.text.open_find_replace() + return "break" + # All other ctrl combos: pass through (save, quit, etc.) + return None + + # ---------------------------------------------------------- + # Pending two-char sequences: 'd', 'c', 'g' operators + # ---------------------------------------------------------- + if self._pending: + result = self._handle_pending(keysym, char) + return "break" if result is not None else "break" + + # ---------------------------------------------------------- + # Motion keys + # ---------------------------------------------------------- + match char: + case "h": + self.text.mark_set(tk.INSERT, motion.move_left(self.text)) + self.text.see(tk.INSERT) + return "break" + case "l": + self.text.mark_set(tk.INSERT, motion.move_right(self.text)) + self.text.see(tk.INSERT) + return "break" + case "k": + self.text.mark_set(tk.INSERT, motion.move_up(self.text)) + self.text.see(tk.INSERT) + return "break" + case "j": + self.text.mark_set(tk.INSERT, motion.move_down(self.text)) + self.text.see(tk.INSERT) + return "break" + case "w": + self.text.mark_set(tk.INSERT, motion.word_start_next(self.text)) + self.text.see(tk.INSERT) + return "break" + case "b": + self.text.mark_set(tk.INSERT, motion.word_start_prev(self.text)) + self.text.see(tk.INSERT) + return "break" + case "e": + self.text.mark_set(tk.INSERT, motion.word_end(self.text)) + self.text.see(tk.INSERT) + return "break" + case "0": + self.text.mark_set(tk.INSERT, motion.get_line_start(self.text)) + self.text.see(tk.INSERT) + return "break" + case "$": + self.text.mark_set(tk.INSERT, motion.get_line_end(self.text)) + self.text.see(tk.INSERT) + return "break" + case "^": + self.text.mark_set(tk.INSERT, motion.get_first_nonblank(self.text)) + self.text.see(tk.INSERT) + return "break" + case "G": + last = int(self.text.index(tk.END).split(".")[0]) - 1 + self.text.mark_set(tk.INSERT, f"{last}.0") + self.text.see(tk.INSERT) + return "break" + + # keysym-based movements + match keysym: + case "Up": + self.text.mark_set(tk.INSERT, motion.move_up(self.text)) + self.text.see(tk.INSERT) + return "break" + case "Down": + self.text.mark_set(tk.INSERT, motion.move_down(self.text)) + self.text.see(tk.INSERT) + return "break" + case "Left": + self.text.mark_set(tk.INSERT, motion.move_left(self.text)) + self.text.see(tk.INSERT) + return "break" + case "Right": + self.text.mark_set(tk.INSERT, motion.move_right(self.text)) + self.text.see(tk.INSERT) + return "break" + + # ---------------------------------------------------------- + # Insert-mode entry commands + # ---------------------------------------------------------- + match char: + case "i": + self._set_mode(INSERT) + return "break" + case "I": + self.text.mark_set(tk.INSERT, motion.get_first_nonblank(self.text)) + self._set_mode(INSERT) + return "break" + case "a": + new_idx = motion.move_right(self.text) + self.text.mark_set(tk.INSERT, new_idx) + self._set_mode(INSERT) + return "break" + case "A": + self.text.mark_set(tk.INSERT, motion.get_line_end(self.text)) + self._set_mode(INSERT) + return "break" + case "o": + line = int(self.text.index(tk.INSERT).split(".")[0]) + self.text.insert(f"{line}.end", "\n") + self.text.mark_set(tk.INSERT, f"{line + 1}.0") + self.text.see(tk.INSERT) + self._set_mode(INSERT) + return "break" + case "O": + line = int(self.text.index(tk.INSERT).split(".")[0]) + self.text.insert(f"{line}.0", "\n") + self.text.mark_set(tk.INSERT, f"{line}.0") + self.text.see(tk.INSERT) + self._set_mode(INSERT) + return "break" + + # ---------------------------------------------------------- + # Visual mode entry + # ---------------------------------------------------------- + if char == "v": + self._visual_anchor = self.text.index(tk.INSERT) + self._update_visual_selection() + self._set_mode(VISUAL) + return "break" + + if char == "V": + self._visual_anchor = self.text.index(tk.INSERT) + self._visual_line_anchor = int(self._visual_anchor.split(".")[0]) + self._update_visual_line_selection() + self._set_mode(VISUAL_LINE) + return "break" + + # ---------------------------------------------------------- + # Operators (first char of two-char sequences) + # ---------------------------------------------------------- + if char in ("d", "c", "g", "y"): + self._pending = char + return "break" + + # ---------------------------------------------------------- + # Single-char actions + # ---------------------------------------------------------- + match char: + case "x": + operator.delete_char(self.text) + return "break" + case "p": + operator.paste_after(self.text) + self.text.see(tk.INSERT) + return "break" + case "P": + operator.paste_before(self.text) + self.text.see(tk.INSERT) + return "break" + case "u": + self.text.stack_undo() + return "break" + case ":": + self._set_mode(COMMAND) + return "break" + + # Escape is a no-op in Normal (already in Normal) + if keysym == "Escape": + self.text.tag_remove(tk.SEL, "1.0", tk.END) + return "break" + + # Suppress all other printable characters in Normal mode + if char and char.isprintable(): + return "break" + + return None + + # ------------------------------------------------------------------ + # Pending two-char operator handler + # ------------------------------------------------------------------ + + def _handle_pending(self, keysym: str, char: str) -> bool: # noqa: C901 + """Route second char to the appropriate operator handler.""" + op = self._pending + self._pending = "" + if op == "g": + return self._pending_g(char) + if op == "d": + return self._pending_d(char) + if op == "di": + return self._pending_di(char) + if op == "c": + return self._pending_c(char) + if op == "ci": + return self._pending_ci(char) + if op == "y": + return self._pending_y(char) + return True + + def _pending_g(self, char: str) -> bool: + """g prefix: gg → go to first line.""" + if char == "g": + self.text.mark_set(tk.INSERT, "1.0") + self.text.see(tk.INSERT) + return True + + def _pending_d(self, char: str) -> bool: + """d prefix: dd, dw, di.""" + if char == "d": + operator.delete_line(self.text) + self.text.see(tk.INSERT) + self._vim_msg("1 line deleted") + elif char == "w": + operator.delete_to_word_end(self.text) + self._vim_msg("deleted to word end") + elif char == "i": + self._pending = "di" + return True + + def _pending_di(self, char: str) -> bool: + """di prefix: diw.""" + if char == "w": + operator.delete_inner_word(self.text) + self._vim_msg("deleted inner word") + return True + + def _pending_c(self, char: str) -> bool: + """c prefix: cw, ci.""" + if char == "w": + operator.change_to_word_end(self.text) + self._set_mode(INSERT) + elif char == "i": + self._pending = "ci" + return True + + def _pending_ci(self, char: str) -> bool: + """ci prefix: ciw.""" + if char == "w": + operator.change_inner_word(self.text) + self._set_mode(INSERT) + return True + + def _pending_y(self, char: str) -> bool: + """y prefix: yy.""" + if char == "y": + operator.yank_line(self.text) + self._vim_msg("1 line yanked") + return True + + # ------------------------------------------------------------------ + # VISUAL mode + # ------------------------------------------------------------------ + + def _handle_visual(self, keysym: str, char: str, event: tk.Event) -> str | None: + is_line_mode = self._mode == VISUAL_LINE + + # Escape → back to Normal + if keysym == "Escape": + self.text.tag_remove(tk.SEL, "1.0", tk.END) + self._set_mode(NORMAL) + return "break" + + # Motion keys extend the selection + moved = False + match char: + case "h": + self.text.mark_set(tk.INSERT, motion.move_left(self.text)) + moved = True + case "l": + self.text.mark_set(tk.INSERT, motion.move_right(self.text)) + moved = True + case "k": + self.text.mark_set(tk.INSERT, motion.move_up(self.text)) + moved = True + case "j": + self.text.mark_set(tk.INSERT, motion.move_down(self.text)) + moved = True + case "w": + self.text.mark_set(tk.INSERT, motion.word_start_next(self.text)) + moved = True + case "b": + self.text.mark_set(tk.INSERT, motion.word_start_prev(self.text)) + moved = True + case "G": + last = int(self.text.index(tk.END).split(".")[0]) - 1 + self.text.mark_set(tk.INSERT, f"{last}.0") + moved = True + + match keysym: + case "Up": + self.text.mark_set(tk.INSERT, motion.move_up(self.text)) + moved = True + case "Down": + self.text.mark_set(tk.INSERT, motion.move_down(self.text)) + moved = True + case "Left": + self.text.mark_set(tk.INSERT, motion.move_left(self.text)) + moved = True + case "Right": + self.text.mark_set(tk.INSERT, motion.move_right(self.text)) + moved = True + + if moved: + self.text.see(tk.INSERT) + if is_line_mode: + self._update_visual_line_selection() + else: + self._update_visual_selection() + return "break" + + # Actions on selection + match char: + case "d" | "x": + if is_line_mode: + n = self._visual_line_count() + operator.delete_lines_selection(self.text) + self._vim_msg(f"{n} line{'s' if n != 1 else ''} deleted") + else: + operator.delete_selection(self.text) + self._vim_msg("selection deleted") + self._set_mode(NORMAL) + return "break" + case "y": + if is_line_mode: + n = self._visual_line_count() + operator.yank_lines_selection(self.text) + self._vim_msg(f"{n} line{'s' if n != 1 else ''} yanked") + else: + operator.yank_selection(self.text) + self._vim_msg("selection yanked") + self._set_mode(NORMAL) + return "break" + case "c": + if is_line_mode: + operator.delete_lines_selection(self.text) + else: + operator.delete_selection(self.text) + self._set_mode(INSERT) + return "break" + + if keysym == "Escape": + self.text.tag_remove(tk.SEL, "1.0", tk.END) + self._set_mode(NORMAL) + return "break" + + # Suppress printable chars in visual mode (except handled above) + if char and char.isprintable(): + return "break" + + return None + + def _update_visual_selection(self) -> None: + """Update character-wise visual selection between anchor and cursor.""" + cursor = self.text.index(tk.INSERT) + anchor = self._visual_anchor + # Determine order + if self.text.compare(anchor, "<=", cursor): + sel_start, sel_end = anchor, f"{cursor}+1c" + else: + sel_start, sel_end = cursor, f"{anchor}+1c" + self.text.tag_remove(tk.SEL, "1.0", tk.END) + self.text.tag_add(tk.SEL, sel_start, sel_end) + + def _update_visual_line_selection(self) -> None: + """Update line-wise visual selection.""" + cursor_line = int(self.text.index(tk.INSERT).split(".")[0]) + anchor_line = self._visual_line_anchor + start_line = min(cursor_line, anchor_line) + end_line = max(cursor_line, anchor_line) + self.text.tag_remove(tk.SEL, "1.0", tk.END) + self.text.tag_add(tk.SEL, f"{start_line}.0", f"{end_line + 1}.0") + + def _visual_line_count(self) -> int: + """Return the number of lines currently selected in Visual Line mode.""" + return abs( + int(self.text.index(tk.INSERT).split(".")[0]) - self._visual_line_anchor + ) + 1 diff --git a/src/biscuit/gui.py b/src/biscuit/gui.py index 5f8538fb..57f9a65d 100644 --- a/src/biscuit/gui.py +++ b/src/biscuit/gui.py @@ -177,6 +177,12 @@ def late_setup(self) -> None: self.debugger_manager.register_actionsets() self.register_misc_palettes() + # Apply persistent vim_mode if enabled in config + if self.vim_mode: + for e in self.editorsmanager.active_editors: + if e.content and e.content.editable: + e.content.text.enable_vim_mode() + # force set focus on this window self.focus_set() diff --git a/src/biscuit/layout/root.py b/src/biscuit/layout/root.py index bd9d707f..bb75f3f4 100644 --- a/src/biscuit/layout/root.py +++ b/src/biscuit/layout/root.py @@ -21,9 +21,10 @@ import typing from biscuit.common.ui import Frame, PanedWindow -from biscuit.layout.statusbar import activitybar +from biscuit.common.ui.vimbar import VimBar +from biscuit.layout.statusbar import activitybar # noqa: F401 -from .content import * +from .content import * # noqa: F403 from .menubar import Menubar from .secondary_sidebar import SecondarySideBar from .sidebar import SideBar @@ -48,16 +49,20 @@ def __init__(self, base: App, *args, **kwargs) -> None: self.menubar = Menubar(container) self.statusbar = Statusbar(container) + self.vimbar = VimBar(container) - self.subcontainer = PanedWindow(container, orient=tk.HORIZONTAL, bg=self.base.theme.border, bd=0, sashwidth=3, sashpad=0, opaqueresize=False) - self.content = Content(self.subcontainer) + self.subcontainer = PanedWindow( + container, orient=tk.HORIZONTAL, bg=self.base.theme.border, + bd=0, sashwidth=3, sashpad=0, opaqueresize=False, + ) + self.content = Content(self.subcontainer) # noqa: F405 self.sidebar = SideBar(self.subcontainer, activitybar=self.statusbar.activitybar) self.secondary_sidebar = SecondarySideBar( self.subcontainer, activitybar=self.statusbar.secondary_activitybar ) container.pack(fill=tk.BOTH, expand=True) - + self.menubar.pack() self.subcontainer.pack(fill=tk.BOTH, expand=True) self.statusbar.pack() @@ -65,5 +70,8 @@ def __init__(self, base: App, *args, **kwargs) -> None: self.content.pack(padx=1) self.pack(fill=tk.BOTH, expand=True) + # Expose vimbar on base for global access + self.base.vimbar = self.vimbar + def toggle_sidebar(self) -> None: self.sidebar.toggle() diff --git a/src/biscuit/layout/statusbar/keypresslog.py b/src/biscuit/layout/statusbar/keypresslog.py new file mode 100644 index 00000000..f0e6eda1 --- /dev/null +++ b/src/biscuit/layout/statusbar/keypresslog.py @@ -0,0 +1,178 @@ +"""Keypress display for the Biscuit status bar. + +When enabled (e.g. for streaming), shows each key combination pressed +as a pill in the status bar that auto-clears after a short delay. + +Supports: + - Regular characters: 'a', '1', '$', etc. + - Modifier combos: 'Ctrl+S', 'Ctrl+Shift+P', 'Alt+F4' + - Special keys: 'Esc', 'Enter', 'Tab', '⌫', '⌦', arrows, F-keys + - Vim sequences: shows the pending command buffer (e.g. 'd', then 'dd') +""" + +from __future__ import annotations + +import tkinter as tk +import typing + +if typing.TYPE_CHECKING: + from biscuit.layout.statusbar.statusbar import Statusbar + +# Keys that have no printable character and need a display name +_SPECIAL = { + "Escape": "Esc", + "Return": "Enter", + "KP_Enter": "Enter", + "Tab": "Tab", + "BackSpace": "⌫", + "Delete": "⌦", + "space": "Space", + "Up": "↑", + "Down": "↓", + "Left": "←", + "Right": "→", + "Home": "Home", + "End": "End", + "Prior": "PgUp", + "Next": "PgDn", + "Insert": "Ins", +} + +# Modifier state bit-masks (Tk event.state) +_MOD_CTRL = 0x4 +_MOD_ALT = 0x8 +_MOD_SHIFT = 0x1 + + +def _resolve_key_label(keysym: str, char: str) -> str: + """Return the display label for the key portion (no modifiers).""" + if keysym in _SPECIAL: + return _SPECIAL[keysym] + if len(keysym) == 1 and keysym.isprintable(): + return char if char and char.isprintable() else keysym + if keysym.startswith("F") and keysym[1:].isdigit(): + return keysym # F1-F12 + if char and char.isprintable(): + return char + return keysym # fallback: raw name + + +def _is_modifier_only(keysym: str) -> bool: + """Return True if this key event is a bare modifier press (no label needed).""" + return keysym in ( + "Shift_L", + "Shift_R", + "shift_L", + "shift_R", + "Control_L", + "Control_R", + "Alt_L", + "Alt_R", + "Meta_L", + "Meta_R", + "Super_L", + "Super_R", + "Caps_Lock", + "Num_Lock", + "Scroll_Lock", + ) + + +def _format_key(event: tk.Event) -> str: + """Turn a tkinter KeyPress event into a human-readable label.""" + if _is_modifier_only(event.keysym): + return "" + + state = event.state + parts = [] + if state & _MOD_CTRL: + parts.append("Ctrl") + if state & _MOD_ALT: + parts.append("Alt") + if (state & _MOD_SHIFT) and not (event.char and event.char.isprintable()): + parts.insert(0, "Shift") + + parts.append(_resolve_key_label(event.keysym, event.char)) + return "+".join(parts) + + +class KeypressDisplay: + """A label inside the status bar that shows the most recently pressed key. + + Enabled/disabled via :meth:`enable` / :meth:`disable`. + When enabled it binds a ```` handler to the root Tk window so + every key press anywhere in the app is captured. + """ + + _CLEAR_DELAY_MS = 1500 + + def __init__(self, statusbar: "Statusbar") -> None: + self._sb = statusbar + self.base = statusbar.base + self._enabled = False + self._after_id = None + + # The visible pill label + self._label = tk.Label( + statusbar, + text="", + bg="#2a2a3a", # slightly distinct dark background + fg="#e0e0ff", + # font=(self.base.settings.uifont[0], self.base.settings.uifont[1]), + padx=8, + pady=1, + relief=tk.FLAT, + bd=0, + ) + + # ------------------------------------------------------------------ + # Enable / disable + # ------------------------------------------------------------------ + + def enable(self) -> None: + """Show the display and start capturing key events.""" + if self._enabled: + return + self._enabled = True + self._label.pack(side=tk.RIGHT, padx=(0, 6), pady=2) + # Bind to root so ALL key events are captured + self.base.bind("", self._on_key, add=True) + + def disable(self) -> None: + """Hide the display and stop capturing.""" + if not self._enabled: + return + self._enabled = False + try: + self.base.unbind("") + except Exception: + pass + self._cancel_timer() + self._label.pack_forget() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _on_key(self, event: tk.Event) -> None: + label = _format_key(event) + if not label: + return + self._show(label) + + def _show(self, text: str) -> None: + self._cancel_timer() + self._label.configure(text=f" {text} ") + self._after_id = self.base.after(self._CLEAR_DELAY_MS, self._clear) + + def _clear(self) -> None: + self._after_id = None + self._label.configure(text="") + + def _cancel_timer(self) -> None: + if self._after_id is not None: + try: + self.base.after_cancel(self._after_id) + except Exception: + pass + self._after_id = None diff --git a/src/biscuit/layout/statusbar/statusbar.py b/src/biscuit/layout/statusbar/statusbar.py index d9237688..af49e684 100644 --- a/src/biscuit/layout/statusbar/statusbar.py +++ b/src/biscuit/layout/statusbar/statusbar.py @@ -12,6 +12,7 @@ from .activitybar import ActivityBar from .button import SButton +from .keypresslog import KeypressDisplay if typing.TYPE_CHECKING: from biscuit.editor import Text @@ -30,6 +31,23 @@ def __init__(self, master: Frame, *args, **kwargs) -> None: self.activitybar = ActivityBar(self) self.activitybar.pack(side=tk.LEFT, padx=(10, 0)) + # --------------------------------------------------------------------- + # Vim mode indicator (hidden by default; shown when Vim mode is active) + self._vim_mode_colors = { + "NORMAL": ("#1a6ebd", "#ffffff"), # blue + "INSERT": ("#1a8a44", "#ffffff"), # green + "VISUAL": ("#7c4dbd", "#ffffff"), # purple + "VISUAL LINE": ("#b85c00", "#ffffff"), # orange + "COMMAND": ("#b8a000", "#ffffff"), # yellow-ish + } + self.vim_mode_btn = self.add_button( + text="NORMAL", + description="Vim mode (click to toggle)", + callback=self.base.commands.toggle_vim_mode, + side=tk.LEFT, + padx=(4, 2), + ) + # self.terminal_toggle = self.add_button( # icon="symbol-class", # callback=self.toggle_sidebar, @@ -151,9 +169,12 @@ def __init__(self, master: Frame, *args, **kwargs) -> None: icon2=Icons.LAYOUT_PANEL, ) self.panel_toggle.set_pack_data(side=tk.RIGHT, padx=(0, 10)) - self.panel_toggle.show() + # --------------------------------------------------------------------- + # Keypress display (streamer mode) — hidden by default + self.keypresslog = KeypressDisplay(self) + def add_button( self, text="", @@ -284,6 +305,33 @@ def set_spaces(self, spaces: int) -> None: self.indentation.change_text(text=f"{spaces}sp") + def set_vim_mode(self, mode: str) -> None: + """Update the Vim mode indicator in the status bar. + + Args: + mode (str): One of 'NORMAL', 'INSERT', 'VISUAL', 'VISUAL LINE', 'COMMAND'. + """ + bg, fg = self._vim_mode_colors.get(mode, ("#444444", "#ffffff")) + self.vim_mode_btn.config(bg=bg) + if hasattr(self.vim_mode_btn, "text_label"): + self.vim_mode_btn.text_label.config( + text=f" {mode} ", bg=bg, fg=fg + ) + if not self.vim_mode_btn.visible: + self.vim_mode_btn.show() + + def clear_vim_mode(self) -> None: + """Hide the Vim mode indicator (called when Vim mode is disabled).""" + self.vim_mode_btn.hide() + + def enable_keypress_display(self) -> None: + """Enable the streamer keypress display.""" + self.keypresslog.enable() + + def disable_keypress_display(self) -> None: + """Disable the streamer keypress display.""" + self.keypresslog.disable() + def pack(self): """Packs the status bar into the application.""" diff --git a/src/biscuit/settings/config.py b/src/biscuit/settings/config.py index 46873e0d..1cd6348c 100644 --- a/src/biscuit/settings/config.py +++ b/src/biscuit/settings/config.py @@ -69,7 +69,8 @@ def setup_properties(self) -> None: self.tab_size = self.get_value("tab_size", 4) self.cursor_style = self.get_value("cursor_style", "line") self.relative_line_numbers = self.get_value("relative_line_numbers", False) - + self.vim_mode = self.get_value("vim_mode", False) + # Display self.show_minimap = self.get_value("show_minimap", True) self.show_breadcrumbs = self.get_value("show_breadcrumbs", True) diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..1ee32d94 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "framework": "nextjs", + "buildCommand": "bun run --cwd docs build", + "outputDirectory": "docs/.next" +}