Worker thread + tray icon repaint fix

- processUtterance runs on a detached std.Thread; Qt event loop stays
  responsive during whisper/letta/melo (icon repaints, menu works)
- setState marshals to main thread via threading.async with thread-id check
- rapid double-click race guarded: state claimed before spawn
- icon: QPainter-drawn antialiased circle (dark rim, transparent bg) —
  bare pixmap.fill rendered blank on the KDE SNI host
- new qt libs: qbrush, qpainter, qrect, qt6zigthreading

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
This commit is contained in:
pierreandLetta Code committed 2026-09-01 19:29:03 +03:00
1 parent f4c08cae74
commit 5b050c09e3
2 files changed
+60 -3

No files matched your search

+56 -3
View File
@@ -14,6 +14,10 @@ const QAction = qt6.QAction;
const QPixmap = qt6.QPixmap;
const QIcon = qt6.QIcon;
const QColor = qt6.QColor;
const QPainter = qt6.QPainter;
const QBrush = qt6.QBrush;
const QRectF = qt6.QRectF;
const threading = qt6.threading;
const letta = @import("letta.zig");
// ------------------------------------------------------------- constants ---
@@ -75,15 +79,39 @@ fn setLastUserText(text: []u8) void {
var current_icon: ?QIcon = null;
var current_pixmap: ?QPixmap = null;
fn setState(next: State) void {
var main_thread_id: std.Thread.Id = 0;
var pending_state: State = .idle;
/// Must run on the Qt main thread (touches QSystemTrayIcon / QPixmap).
fn setStateMainThread(next: State) void {
state = next;
// Circle icon drawn with QPainter — a bare pixmap.fill rendered blank
// on the KDE SNI host.
const c = next.color();
const color = QColor.new5(c[0], c[1], c[2]);
defer color.delete();
const pen_color = QColor.new5(20, 20, 26);
defer pen_color.delete();
const transparent = QColor.new8("transparent");
defer transparent.delete();
const pixmap = QPixmap.new2(128, 128);
pixmap.fill1(color);
pixmap.fill1(transparent);
const brush = QBrush.new3(color);
defer brush.delete();
const painter = QPainter.new();
_ = painter.begin(pixmap);
painter.setRenderHint(1); // Antialiasing
painter.setPen(pen_color);
painter.setBrush(brush);
const rect = QRectF.new6(16, 16, 96, 96);
defer rect.delete();
painter.drawEllipse(rect);
_ = painter.end();
painter.delete();
const icon = QIcon.new2(pixmap);
@@ -99,6 +127,21 @@ fn setState(next: State) void {
tray_icon.setToolTip(tip);
}
fn onStateFromWorker(_: ?*anyopaque) callconv(.c) void {
setStateMainThread(pending_state);
}
/// Thread-safe state transition: runs directly on the main thread, or
/// marshals through Qt's event loop when called from the pipeline worker.
fn setState(next: State) void {
if (std.Thread.getCurrentId() == main_thread_id) {
setStateMainThread(next);
} else {
pending_state = next;
threading.async(null, onStateFromWorker);
}
}
/// Play a WAV via pw-play (blocking; audio length). Fine for v1 — same
/// thread already blocks on whisper/letta. UI-thread decoupling comes later.
fn playWav(path: []const u8) void {
@@ -258,7 +301,16 @@ fn onTrayActivated(_: QSystemTrayIcon, reason: i32) callconv(.c) void {
},
.recording => {
stopRecording();
processUtterance(); // chains transcribe -> infer -> speak -> re-listen
setState(.transcribing); // claim the pipeline slot before spawning
// Pipeline runs on a worker thread; the Qt event loop stays
// responsive (icon repaints, menu works) while whisper/letta/melo
// block. setState marshals back to the main thread.
const worker = std.Thread.spawn(.{}, processUtterance, .{}) catch |e| {
std.debug.print("inferon: failed to spawn worker: {s}\n", .{@errorName(e)});
setState(.error_backend);
return;
};
worker.detach();
},
// Clicks during the pipeline are no-ops now — states advance on their own.
.transcribing, .thinking, .speaking => {},
@@ -347,6 +399,7 @@ pub fn main(init: std.process.Init) !void {
setState(.idle);
tray_icon.show();
main_thread_id = std.Thread.getCurrentId();
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
// touches tray_icon). Create the log dir first.