aboutsummaryrefslogtreecommitdiffstats
path: root/llamachat/ui.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-19 10:44:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-19 10:44:48 +0200
commitc10c750c83a3cde13f7ca574dac673e2358df522 (patch)
tree11d3694da330091155e9c066399ce39c790000fc /llamachat/ui.py
parent309c0dd8b0fc294f67af8a82b78830a7610b700f (diff)
downloadllamachat-master.tar.gz
llamachat-master.zip
feat: icon-only toolbar and audio input follow-upsHEADmaster
Make the chat toolbar icon-only (tooltips retained) and add an icon_size config key, default 28, so the drawn glyphs can be scaled. Qt was drawing the 24px pixmaps at its ~16px default; the explicit icon size fixes that. Fix the deferred audio minors: AudioRecorder.start() now halts a prior source and checks the source error, so a failed capture no longer claims to be recording. Send now validates every pending modality in one gate, so a mixed image+audio clip cannot reach a model without vision. Sub-second clips no longer name voice-note-0s.wav, and non-WAV audio containers classify as unknown, guarded by a test.
Diffstat (limited to 'llamachat/ui.py')
-rw-r--r--llamachat/ui.py147
1 files changed, 104 insertions, 43 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 91321e4..b45daad 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -22,7 +22,8 @@ import re
from pathlib import Path
from PySide6.QtCore import (
- QObject, QPointF, QRect, QRectF, QSettings, Qt, QThread, QTimer, Signal, Slot,
+ QObject, QPointF, QRect, QRectF, QSettings, QSize, Qt, QThread, QTimer,
+ Signal, Slot,
)
from PySide6.QtGui import (
QAction, QColor, QDesktopServices, QIcon, QKeySequence, QPainter,
@@ -58,12 +59,17 @@ COPY_SCHEME = "x-llamachat-copy:"
# The buttons get their glyphs drawn at runtime, so there is no icon file
# to install. All the marks share the brand blue, which also makes each
# button an unambiguous control rather than a bare text label.
-def _button_icon(kind: str) -> QIcon:
- """A small monochrome glyph for one of the chat buttons."""
- pixmap = QPixmap(24, 24)
+def _button_icon(kind: str, size: int = 24) -> QIcon:
+ """A small monochrome glyph for one of the chat buttons.
+
+ The glyph coordinates below are authored on a 24x24 grid and the painter
+ is scaled, so one call renders crisply at any configured icon size.
+ """
+ pixmap = QPixmap(size, size)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
+ painter.scale(size / 24.0, size / 24.0)
colour = QColor("#3F5EFB")
pen = QPen(colour, 2.2)
pen.setCapStyle(Qt.RoundCap)
@@ -124,6 +130,16 @@ def _button_icon(kind: str) -> QIcon:
return QIcon(pixmap)
+def _set_button_icon(button, kind: str, size: int) -> None:
+ """Give a toolbar button its glyph and match the icon box to it.
+
+ Without the explicit icon size, Qt draws the pixmap at the style's small
+ default (about 16px) regardless of how large it was rendered.
+ """
+ button.setIcon(_button_icon(kind, size))
+ button.setIconSize(QSize(size, size))
+
+
class ContextMeter(QWidget):
"""A bar showing how much of the model's context the next request uses.
@@ -694,10 +710,10 @@ class ChatWindow(QMainWindow):
compose.addWidget(self.input, 1)
# A square send control beside the entry box, like a chat app.
+ size = self.cfg.icon_size
self.send_button = QToolButton()
- self.send_button.setIcon(_button_icon("send"))
- self.send_button.setText("Send")
- self.send_button.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
+ _set_button_icon(self.send_button, "send", size)
+ self.send_button.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.send_button.setFixedSize(44, 44)
self.send_button.setToolTip("Send (Ctrl+Enter)")
self.send_button.clicked.connect(self.send)
@@ -750,33 +766,36 @@ class ChatWindow(QMainWindow):
buttons.addWidget(self.prompt_button)
buttons.addStretch()
- self.clear_attach_button = QPushButton("Clear files")
- self.clear_attach_button.setIcon(_button_icon("clear"))
+ self.clear_attach_button = QPushButton()
+ _set_button_icon(self.clear_attach_button, "clear", size)
+ self.clear_attach_button.setToolTip("Clear attached files")
self.clear_attach_button.clicked.connect(self.clear_attachments)
self.clear_attach_button.hide()
buttons.addWidget(self.clear_attach_button)
- self.stop_button = QPushButton("Stop")
- self.stop_button.setIcon(_button_icon("stop"))
+ self.stop_button = QPushButton()
+ _set_button_icon(self.stop_button, "stop", size)
+ self.stop_button.setToolTip("Stop generating")
self.stop_button.clicked.connect(self.stop_stream)
self.stop_button.hide()
buttons.addWidget(self.stop_button)
- self.new_button = QPushButton("New")
- self.new_button.setIcon(_button_icon("new"))
+ self.new_button = QPushButton()
+ _set_button_icon(self.new_button, "new", size)
self.new_button.setToolTip("Start a fresh conversation (Ctrl+N)")
self.new_button.clicked.connect(self.new_session)
buttons.addWidget(self.new_button)
- self.record_button = QPushButton("Record")
- self.record_button.setIcon(_button_icon("record"))
+ self.record_button = QPushButton()
+ _set_button_icon(self.record_button, "record", size)
self.record_button.setToolTip("Record audio for an audio-capable model")
self.record_button.clicked.connect(self._toggle_record)
self.record_button.hide()
buttons.addWidget(self.record_button)
- self.attach_button = QPushButton("Attach…")
- self.attach_button.setIcon(_button_icon("attach"))
+ self.attach_button = QPushButton()
+ _set_button_icon(self.attach_button, "attach", size)
+ self.attach_button.setToolTip("Attach files")
self.attach_button.clicked.connect(self._on_attach_clicked)
buttons.addWidget(self.attach_button)
@@ -994,39 +1013,73 @@ class ChatWindow(QMainWindow):
names.append(name)
return names
- def audio_models(self) -> list[str]:
+ def _modality_models(self, audio: bool, vision: bool) -> list[str]:
+ """Model ids that satisfy every required modality.
+
+ Audio is strict, vision lenient: only a model that reports audio can
+ receive a recording, while an unknown vision status gets the benefit
+ of the doubt. Same asymmetry as the attach-time gate.
+ """
names = []
for i in range(self.model_box.count()):
name = self.model_box.itemData(i)
- if self.model_info(name).audio is True:
- names.append(name)
+ info = self.model_info(name)
+ if audio and info.audio is not True:
+ continue
+ if vision and info.vision is False:
+ continue
+ names.append(name)
return names
- def _ensure_audio_model(self) -> bool:
- """Offer to switch to an audio-capable model. True when one is active.
+ def _ensure_modalities(self, *, audio: bool, vision: bool) -> bool:
+ """Offer a single switch to a model that accepts everything pending.
- Unlike vision, an unknown model is not given the benefit of the
- doubt: audio input is rare and explicitly reported, so only a model
- known to accept it is allowed to receive a recording.
+ Returns True when the current model can receive the attachments. One
+ combined switch, rather than a vision switch then an audio switch,
+ stops a mixed clip ping-ponging between a vision-only and an
+ audio-only model.
"""
- if self.current_info().audio is True:
+ info = self.current_info()
+ if (not audio or info.audio is True) and (
+ not vision or info.vision is not False
+ ):
return True
- candidates = self.audio_models()
+ candidates = self._modality_models(audio, vision)
if not candidates:
- QMessageBox.warning(
- self,
- "No audio model",
- "No model on the router reports audio input, so a recording "
- "cannot be sent.",
- )
+ if audio and vision:
+ title = "No compatible model"
+ body = (
+ "No model accepts both audio and images, so this clip "
+ "cannot be sent."
+ )
+ elif audio:
+ title = "No audio model"
+ body = (
+ "No model on the router reports audio input, so a "
+ "recording cannot be sent."
+ )
+ else:
+ title = "No vision model"
+ body = (
+ "No model on the router has an mmproj file configured, so "
+ "images cannot be sent."
+ )
+ QMessageBox.warning(self, title, body)
return False
+ if audio and vision:
+ need = "Audio and images need a model that accepts both."
+ elif audio:
+ need = "Audio needs a model that accepts voice input."
+ else:
+ need = "Images need a vision-capable model."
+
target = candidates[0]
answer = QMessageBox.question(
self,
"Switch model?",
- f"Audio needs a model that accepts voice input.\n\n"
+ f"{need}\n\n"
f"Switch to {target}?\n\n"
"The router unloads the current model to do this, so the next "
"reply will take several seconds to start.",
@@ -1267,9 +1320,15 @@ class ChatWindow(QMainWindow):
self.recorder is not None and self.current_info().audio is True
)
self.record_button.setVisible(can)
- self.record_button.setText("Stop" if self.recording else "Record")
- self.record_button.setIcon(
- _button_icon("stop" if self.recording else "record")
+ _set_button_icon(
+ self.record_button,
+ "stop" if self.recording else "record",
+ self.cfg.icon_size,
+ )
+ self.record_button.setToolTip(
+ "Stop recording"
+ if self.recording
+ else "Record audio for an audio-capable model"
)
def _toggle_record(self) -> None:
@@ -1280,7 +1339,7 @@ class ChatWindow(QMainWindow):
if self.recorder is None or self.current_info().audio is not True:
return
if not self.recorder.start():
- self.show_status("No microphone available.", error=True)
+ self.show_status("Could not start recording.", error=True)
return
self.recording = True
self.show_status("Recording. Click Stop when done.")
@@ -1306,7 +1365,7 @@ class ChatWindow(QMainWindow):
else:
self.hide_status()
self.attachments.append(
- backend.audio_attachment(audio.to_wav(pcm), round(seconds))
+ backend.audio_attachment(audio.to_wav(pcm), max(1, round(seconds)))
)
self.update_attach_label()
@@ -1346,12 +1405,14 @@ class ChatWindow(QMainWindow):
if not text and not self.attachments:
return
- if any(a.kind == "audio" for a in self.attachments):
+ needs_audio = any(a.kind == "audio" for a in self.attachments)
+ needs_vision = any(a.kind == "image" for a in self.attachments)
+ if needs_audio or needs_vision:
# A switch here changes the model, so this must run before the
- # model is read below.
- if not self._ensure_audio_model():
+ # model is read below. One combined gate covers a mixed clip.
+ if not self._ensure_modalities(audio=needs_audio, vision=needs_vision):
return
- if not text:
+ if needs_audio and not text:
text = backend.AUDIO_PROMPT
model = self.current_model()