Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"framework": "nextjs"
}
1 change: 1 addition & 0 deletions src/biscuit/binder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<Control-Alt-v>", self.events.toggle_vim_mode)

def bind(self, this, to_this) -> None:
self.base.bind(this, to_this)
26 changes: 26 additions & 0 deletions src/biscuit/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/biscuit/common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
227 changes: 227 additions & 0 deletions src/biscuit/common/ui/vimbar.py
Original file line number Diff line number Diff line change
@@ -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 :<number> (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("<Return>", self._on_submit)
self._entry.bind("<Escape>", self._on_escape)
self._entry.bind("<KP_Enter>", 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 :<number> 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
6 changes: 6 additions & 0 deletions src/biscuit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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."""

Expand Down
20 changes: 20 additions & 0 deletions src/biscuit/editor/text/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/biscuit/editor/text/vim/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading