summaryrefslogtreecommitdiffstats
path: root/llamachat/ui.py
diff options
context:
space:
mode:
Diffstat (limited to 'llamachat/ui.py')
-rw-r--r--llamachat/ui.py114
1 files changed, 112 insertions, 2 deletions
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()