diff --git a/build.zig b/build.zig index 7a25b08..045139f 100644 --- a/build.zig +++ b/build.zig @@ -16,11 +16,15 @@ pub fn build(b: *std.Build) void { "qcoreapplication", "qguiapplication", "qaction", + "qbrush", "qcolor", "qicon", + "qpainter", "qpixmap", "qmenu", + "qrect", "qsystemtrayicon", + "qt6zigthreading", "qwidget", }; diff --git a/src/main.zig b/src/main.zig index e746348..86cc1f1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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.