diff options
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | llamachat/config.py | 4 | ||||
| -rw-r--r-- | llamachat/ui.py | 114 | ||||
| -rwxr-xr-x | test_llamachat.py | 76 |
5 files changed, 202 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b71e76..5b8e099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with. - Streaming requests now ask for usage statistics, which is what makes the meter exact without an extra round trip. +- A toggle for the history panel, on the `☰` button and `Ctrl+\`. The + panel's width and hidden state are remembered between runs in + `~/.config/llamachat/state.ini`. ### Changed @@ -158,8 +158,13 @@ llamachat --quit stop the running instance The control commands do not import Qt, so they return in well under a tenth of a second, which is what makes them usable behind a keybind. -In the window: **Ctrl+Enter** sends, **Esc** hides, **New** starts a fresh -conversation, right-clicking a history entry offers to delete it. +In the window: **Ctrl+Enter** sends, **Ctrl+\\** shows or hides the history +panel (the `☰` button does the same), **Esc** hides the window, **New** +starts a fresh conversation, and right-clicking a history entry offers to +delete it. + +The panel's width and whether it was hidden are remembered in +`~/.config/llamachat/state.ini` between runs. ## Hyprland configuration diff --git a/llamachat/config.py b/llamachat/config.py index 5c5af14..dec2ba6 100644 --- a/llamachat/config.py +++ b/llamachat/config.py @@ -66,6 +66,7 @@ class Config: chars_per_token: float default_prompt: str prompts_dir: Path + state_path: Path def _runtime_dir() -> Path: @@ -104,6 +105,9 @@ def load(path: Path = CONFIG_PATH) -> Config: chars_per_token=float(values["chars_per_token"]), default_prompt=str(values["default_prompt"]), prompts_dir=path.parent / "prompts", + # Window layout, remembered between runs. Not user-editable config, + # so it sits beside it rather than inside config.toml. + state_path=path.parent / "state.ini", ) diff --git a/llamachat/ui.py b/llamachat/ui.py index f398510..feab3f2 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -18,7 +18,9 @@ import json import re from pathlib import Path -from PySide6.QtCore import QObject, QRect, Qt, QThread, Signal, Slot +from PySide6.QtCore import ( + QObject, QRect, QSettings, Qt, QThread, Signal, Slot, +) from PySide6.QtGui import ( QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument, ) @@ -319,6 +321,7 @@ class ChatWindow(QMainWindow): self.prompt_name = cfg.default_prompt self.prompt_custom = "" self.exact_tokens = 0 + self.sidebar_width = 240 self.session_id: int | None = None self.mode = MODE_ONESHOT @@ -338,6 +341,7 @@ class ChatWindow(QMainWindow): self.resize(1000, 700) self.setAcceptDrops(True) self._build_ui() + self._restore_layout() self.refresh_models() self.refresh_prompts() self.refresh_history() @@ -351,6 +355,14 @@ class ChatWindow(QMainWindow): # Top bar: model, mode, search. top = QHBoxLayout() + self.sidebar_button = QPushButton("☰") + self.sidebar_button.setCheckable(True) + self.sidebar_button.setChecked(True) + self.sidebar_button.setFixedWidth(32) + self.sidebar_button.setToolTip("Show or hide the history panel (Ctrl+\\)") + self.sidebar_button.toggled.connect(self.set_sidebar_visible) + top.addWidget(self.sidebar_button) + self.model_box = QComboBox() self.model_box.setMinimumWidth(220) self.model_box.currentTextChanged.connect(self._on_model_changed) @@ -400,7 +412,8 @@ class ChatWindow(QMainWindow): outer.addLayout(top) # Body: history list beside the transcript and input. - splitter = QSplitter(Qt.Horizontal) + self.splitter = QSplitter(Qt.Horizontal) + splitter = self.splitter self.history_list = QListWidget() self.history_list.setMinimumWidth(200) @@ -484,6 +497,97 @@ class ChatWindow(QMainWindow): send_enter.triggered.connect(self.send) self.addAction(send_enter) + sidebar_action = QAction(self) + sidebar_action.setShortcut(QKeySequence("Ctrl+\\")) + sidebar_action.triggered.connect(self.toggle_sidebar) + self.addAction(sidebar_action) + + # -- sidebar ---------------------------------------------------------- + + def _settings(self) -> QSettings: + """Where window layout is remembered. + + Kept beside the config rather than left to QSettings' default so it + is obvious where it lives, and so tests can point it elsewhere. + """ + return QSettings(str(self.cfg.state_path), QSettings.IniFormat) + + def _restore_layout(self) -> None: + """Bring back the sidebar width and visibility from last time.""" + settings = self._settings() + self.sidebar_width = int(settings.value("sidebar/width", 240)) + visible = settings.value("sidebar/visible", True) + # QSettings round-trips booleans as strings on some backends. + if isinstance(visible, str): + visible = visible.lower() not in ("false", "0") + + # setChecked only emits when the value changes, so apply directly + # rather than relying on the signal to do it. capture=False keeps + # the width just read from disk: the splitter has not been laid out + # yet, so asking it now would overwrite that with a default. + self.sidebar_button.blockSignals(True) + self.sidebar_button.setChecked(bool(visible)) + self.sidebar_button.blockSignals(False) + self.set_sidebar_visible(bool(visible), capture=False) + + def _save_layout(self) -> None: + # Hiding already captured the width, so only refresh it while the + # panel is up and the splitter still has something to report. + if self.sidebar_visible(): + self._capture_width() + + settings = self._settings() + settings.setValue("sidebar/width", self.sidebar_width) + settings.setValue("sidebar/visible", self.sidebar_visible()) + settings.sync() + + def toggle_sidebar(self) -> None: + self.sidebar_button.setChecked(not self.sidebar_button.isChecked()) + + def sidebar_visible(self) -> bool: + """Whether the panel is shown. + + isVisible() is False for every child of a window that has not been + mapped yet, so the button's own state is the authority before the + window first appears. + """ + return self.sidebar_button.isChecked() + + def _capture_width(self) -> None: + """Remember the panel's width while the splitter still reports it. + + Read unconditionally rather than gated on visibility: by the time a + toggle reaches here the button has already flipped, so asking + whether the sidebar is shown would skip the capture. + """ + sizes = self.splitter.sizes() + # A zero first pane means it is already collapsed, so there is no + # meaningful width to keep. + if sizes and sizes[0] > 0: + self.sidebar_width = sizes[0] + + def set_sidebar_visible(self, visible: bool, capture: bool = True) -> None: + """Show or hide the history panel, keeping its width across toggles. + + `capture` is False when restoring saved state, where the width comes + from disk and the splitter has nothing meaningful to report yet. + """ + if not visible and capture: + self._capture_width() + + if self.sidebar_button.isChecked() != visible: + self.sidebar_button.blockSignals(True) + self.sidebar_button.setChecked(visible) + self.sidebar_button.blockSignals(False) + self.history_list.setVisible(visible) + self.sidebar_button.setToolTip( + f"{'Hide' if visible else 'Show'} the history panel (Ctrl+\\)" + ) + if visible: + total = sum(self.splitter.sizes()) or self.width() + width = min(self.sidebar_width, max(total - 200, 0)) + self.splitter.setSizes([width, total - width]) + # -- model handling --------------------------------------------------- def refresh_models(self) -> None: @@ -1127,8 +1231,14 @@ class ChatWindow(QMainWindow): return super().keyPressEvent(event) + def hideEvent(self, event) -> None: + """The daemon rarely exits, so persist layout whenever it hides.""" + self._save_layout() + super().hideEvent(event) + def closeEvent(self, event) -> None: """Closing hides; the daemon keeps running for the next toggle.""" + self._save_layout() event.ignore() self.hide() diff --git a/test_llamachat.py b/test_llamachat.py index 7707b7b..ae2b478 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -579,6 +579,81 @@ def test_prompt_column_migration(): print("ok prompt column migration") +def test_sidebar_toggle(): + """The history panel hides, restores its width, and persists.""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from llamachat import backend as _backend + from llamachat import config as _config + from llamachat.ui import ChatWindow + + app = QApplication.instance() or QApplication([]) + assert app is not None + + with tempfile.TemporaryDirectory() as tmp: + cfg = _config.load(Path(tmp) / "config.toml") + cfg.prompts_dir = Path(tmp) / "prompts" + cfg.db_path = Path(tmp) / "t.db" + # state_path already points inside tmp, since it is derived from the + # config path, so these checks cannot touch the real saved layout. + assert cfg.state_path.parent == Path(tmp), cfg.state_path + + history = db.History(cfg.db_path) + client = _backend.Client(cfg.base_url) + presets = _config.parse_presets(cfg.presets_path) + + window = ChatWindow(cfg, history, client, presets) + window.resize(1000, 700) + + # The shortcut must be registered on the window itself. + shortcuts = [ + a.shortcut().toString() + for a in window.actions() + if not a.shortcut().isEmpty() + ] + assert "Ctrl+\\" in shortcuts, shortcuts + + window.show() + assert window.sidebar_visible() + assert window.sidebar_button.isChecked() + + window.toggle_sidebar() + assert not window.sidebar_visible() + assert not window.sidebar_button.isChecked() + + window.toggle_sidebar() + assert window.sidebar_visible() + + # The width survives a hide/show rather than snapping to a default. + window.splitter.setSizes([333, 667]) + app.processEvents() + window.toggle_sidebar() + window.toggle_sidebar() + assert window.sidebar_width == 333, window.sidebar_width + + # Driving the button directly must take the same path. + window.sidebar_button.setChecked(False) + app.processEvents() + assert not window.sidebar_visible() + window.sidebar_button.setChecked(True) + app.processEvents() + assert window.sidebar_visible() + assert window.sidebar_width == 333, window.sidebar_width + + # Hidden state and width must come back on the next start. + window.sidebar_button.setChecked(False) + window._save_layout() + restored = ChatWindow(cfg, history, client, presets) + assert not restored.sidebar_button.isChecked() + assert restored.sidebar_width == 333, restored.sidebar_width + + restored.close() + window.close() + history.close() + print("ok sidebar toggle") + + def test_version_matches_changelog(): """The package version must be the newest release in the changelog.""" import re @@ -682,6 +757,7 @@ if __name__ == "__main__": test_session_prompt_storage() test_prompt_column_migration() test_markdown_rendering() + test_sidebar_toggle() test_system_qt_theme_guard() test_version_matches_changelog() test_venv_discovery() |
