//go:build gtk // Package gui implements Captioneer's GTK4 desktop application. package gui import ( "context" _ "embed" "fmt" "io" "os" "os/exec" "os/signal" "path/filepath" "strings" "sync" "syscall" "time" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "tea.chunkbyte.com/kato/captioneer/src/app" "tea.chunkbyte.com/kato/captioneer/src/config" "tea.chunkbyte.com/kato/captioneer/src/diagnostics" "tea.chunkbyte.com/kato/captioneer/src/output" "tea.chunkbyte.com/kato/captioneer/src/output/history" "tea.chunkbyte.com/kato/captioneer/src/output/overlay" "tea.chunkbyte.com/kato/captioneer/src/output/terminal" "tea.chunkbyte.com/kato/captioneer/src/output/transcript" ) const ( applicationID = "com.chunkbyte.captioneer" historyCapacity = 500 consoleCapacity = 2000 ) //go:embed style.css var applicationCSS string type Application struct { launch config.LaunchConfig config config.AppConfig applied config.AppConfig stdout io.Writer stderr io.Writer gtkApp *gtk.Application window *gtk.ApplicationWindow stack *gtk.Stack router *output.Router history *history.Sink log *diagnostics.Log controller *app.Controller overlay *overlay.Sink transcript *transcript.Sink statusLabel *gtk.Label statusDot *gtk.Label startStopButton *gtk.Button statusSource *gtk.Label statusSpeech *gtk.Label statusOutputs *gtk.Label captionBuffer *gtk.TextBuffer captionView *gtk.TextView captionScroll *gtk.ScrolledWindow captionMetaTag *gtk.TextTag captionHintTag *gtk.TextTag provisionalLabel *gtk.Label consoleBuffer *gtk.TextBuffer consoleView *gtk.TextView consoleScroll *gtk.ScrolledWindow consoleMetaTag *gtk.TextTag consoleWarnTag *gtk.TextTag consoleErrorTag *gtk.TextTag settings settingsControls settingsError *gtk.Label applyButton *gtk.Button lastShownError string statusUpdates <-chan app.Status stopStatus func() historyUpdates <-chan history.Snapshot stopHistory func() logUpdates <-chan diagnostics.Entry stopLog func() uiDone chan struct{} closingMu sync.Mutex closing bool } func Run(launch config.LaunchConfig, stdout, stderr io.Writer) int { application := newApplication(launch, stdout, stderr) application.gtkApp.ConnectActivate(application.activate) signals := make(chan os.Signal, 1) signal.Notify(signals, os.Interrupt, syscall.SIGTERM) defer signal.Stop(signals) go func() { select { case <-signals: glib.IdleAdd(application.shutdown) case <-application.uiDone: } }() return application.gtkApp.Run([]string{os.Args[0]}) } func newApplication(launch config.LaunchConfig, stdout, stderr io.Writer) *Application { mirror := io.Discard if launch.Config.Outputs.StderrDiagnostics { mirror = stderr } log := diagnostics.New(consoleCapacity, mirror) router := output.NewRouter() historySink := history.New(historyCapacity, launch.Config.Outputs.History) _ = router.Set("history", historySink) controller := app.NewController(router, log.Writer(diagnostics.Info, "engine")) return &Application{ launch: launch, config: launch.Config, applied: launch.Config, stdout: stdout, stderr: stderr, gtkApp: gtk.NewApplication(applicationID, gio.ApplicationFlagsNone), router: router, history: historySink, log: log, controller: controller, uiDone: make(chan struct{}), } } func (a *Application) activate() { if a.window != nil { a.window.Present() return } a.installStyle() a.buildWindow() a.installActions() a.subscribeModels() overlay.SetPositionSaver(a.saveOverlayPosition) if a.launch.Warning != nil { a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning)) } // Show the main window before creating the overlay so a Layer Shell / // display failure cannot leave the user with a silent no-GUI launch. a.window.Present() if err := a.configureOutputs(a.config, true); err != nil { a.log.Add(diagnostics.Error, "outputs", err.Error()) } a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop) if a.config.GUI.StartCaptions { a.startCaptions() } } func (a *Application) installStyle() { provider := gtk.NewCSSProvider() provider.LoadFromString(applicationCSS) gtk.StyleContextAddProviderForDisplay( gdk.DisplayGetDefault(), provider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) } func (a *Application) buildWindow() { a.window = gtk.NewApplicationWindow(a.gtkApp) a.window.SetTitle("Captioneer") a.window.SetDefaultSize(980, 680) a.window.SetSizeRequest(720, 480) a.window.AddCSSClass("captioneer-main") a.window.ConnectCloseRequest(func() bool { a.shutdown() return true }) header := gtk.NewHeaderBar() header.AddCSSClass("captioneer-header") header.SetShowTitleButtons(true) brand := gtk.NewBox(gtk.OrientationHorizontal, 7) if iconPath := findIconPath(); iconPath != "" { icon := gtk.NewImageFromFile(iconPath) icon.SetPixelSize(20) brand.Append(icon) } title := gtk.NewLabel("CAPTIONEER") title.AddCSSClass("captioneer-brand") brand.Append(title) header.PackStart(brand) statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6) statusBox.SetHAlign(gtk.AlignCenter) statusBox.AddCSSClass("status-cell") a.statusDot = gtk.NewLabel("●") a.statusDot.AddCSSClass("status-dot") a.statusLabel = gtk.NewLabel("STOPPED · VAD") a.statusLabel.AddCSSClass("status-text") statusBox.Append(a.statusDot) statusBox.Append(a.statusLabel) header.SetTitleWidget(statusBox) a.startStopButton = gtk.NewButtonWithLabel("START") a.startStopButton.AddCSSClass("primary-action") a.startStopButton.SetTooltipText("Start or stop caption processing") a.startStopButton.ConnectClicked(a.toggleCaptions) header.PackEnd(a.startStopButton) a.window.SetTitlebar(header) root := gtk.NewBox(gtk.OrientationVertical, 0) root.Append(a.buildMenuBar()) a.stack = gtk.NewStack() a.stack.SetTransitionType(gtk.StackTransitionTypeNone) a.stack.SetHExpand(true) a.stack.SetVExpand(true) tabs := gtk.NewStackSwitcher() tabs.SetStack(a.stack) tabs.SetHAlign(gtk.AlignStart) tabStrip := gtk.NewBox(gtk.OrientationHorizontal, 0) tabStrip.AddCSSClass("captioneer-tabs") tabStrip.Append(tabs) root.Append(tabStrip) a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS") a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE") a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS") root.Append(a.stack) statusbar := gtk.NewBox(gtk.OrientationHorizontal, 4) statusbar.AddCSSClass("statusbar") a.statusSource = statusSegment("Source: not connected") a.statusSource.SetHExpand(true) a.statusSource.SetXAlign(0) a.statusSource.SetEllipsize(3) a.statusSpeech = statusSegment("Speech: quiet") a.statusOutputs = statusSegment("Outputs: preparing") statusbar.Append(a.statusSource) statusbar.Append(a.statusSpeech) statusbar.Append(a.statusOutputs) root.Append(statusbar) a.window.SetChild(root) } func (a *Application) buildMenuBar() *gtk.PopoverMenuBar { root := gio.NewMenu() file := gio.NewMenu() file.Append("Start Captions", "app.start") file.Append("Stop Captions", "app.stop") file.Append("Restart Captions", "app.restart") file.Append("Clear Caption History", "app.clear-history") file.Append("Exit", "app.exit") root.AppendSubmenu("File", file) view := gio.NewMenu() view.Append("Captions", "app.view-captions") view.Append("Console", "app.view-console") view.Append("Settings", "app.view-settings") root.AppendSubmenu("View", view) help := gio.NewMenu() help.Append("Copy Diagnostics", "app.copy-diagnostics") help.Append("Open Configuration Folder", "app.open-config-folder") help.Append("About Captioneer", "app.about") root.AppendSubmenu("Help", help) bar := gtk.NewPopoverMenuBarFromModel(root) bar.AddCSSClass("captioneer-menu") return bar } func (a *Application) installActions() { a.addAction("start", a.startCaptions) a.addAction("stop", a.stopCaptions) a.addAction("restart", a.restartCaptions) a.addAction("clear-history", a.history.Clear) a.addAction("exit", a.shutdown) a.addAction("view-captions", func() { a.stack.SetVisibleChildName("captions") }) a.addAction("view-console", func() { a.stack.SetVisibleChildName("console") }) a.addAction("view-settings", func() { a.stack.SetVisibleChildName("settings") }) a.addAction("copy-diagnostics", a.copyDiagnostics) a.addAction("open-config-folder", a.openConfigFolder) a.addAction("about", a.showAbout) a.gtkApp.SetAccelsForAction("app.start", []string{"F5"}) a.gtkApp.SetAccelsForAction("app.stop", []string{"F5"}) a.gtkApp.SetAccelsForAction("app.restart", []string{"r"}) a.gtkApp.SetAccelsForAction("app.view-captions", []string{"1"}) a.gtkApp.SetAccelsForAction("app.view-console", []string{"2"}) a.gtkApp.SetAccelsForAction("app.view-settings", []string{"3"}) a.gtkApp.SetAccelsForAction("app.exit", []string{"q"}) } func (a *Application) addAction(name string, callback func()) { action := gio.NewSimpleAction(name, nil) action.ConnectActivate(func(*glib.Variant) { callback() }) a.gtkApp.AddAction(action) } func (a *Application) subscribeModels() { a.statusUpdates, a.stopStatus = a.controller.Subscribe(32) a.historyUpdates, a.stopHistory = a.history.Subscribe(8) a.logUpdates, a.stopLog = a.log.Subscribe(128) go a.forwardStatus() go a.forwardHistory() go a.forwardLog() } func (a *Application) forwardStatus() { for { select { case <-a.uiDone: return case status, ok := <-a.statusUpdates: if !ok { return } copy := status glib.IdleAdd(func() { a.renderStatus(copy) }) } } } func (a *Application) forwardHistory() { for { select { case <-a.uiDone: return case snapshot, ok := <-a.historyUpdates: if !ok { return } copy := snapshot glib.IdleAdd(func() { a.renderHistory(copy) }) } } } func (a *Application) forwardLog() { for { select { case <-a.uiDone: return case _, ok := <-a.logUpdates: if !ok { return } glib.IdleAdd(a.renderConsole) } } } func (a *Application) toggleCaptions() { status := a.controller.Status() switch status.State { case app.StateStopped, app.StateError: a.startCaptions() default: a.stopCaptions() } } func (a *Application) startCaptions() { if err := a.controller.Start(a.applied.Settings); err != nil { a.log.Add(diagnostics.Error, "lifecycle", err.Error()) return } a.log.Add(diagnostics.Info, "lifecycle", "Starting caption processing") } func (a *Application) stopCaptions() { go func() { timeout := a.applied.Settings.ModelShutdownTimeout + time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := a.controller.Stop(ctx); err != nil { a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Stop captions: %v", err)) return } a.log.Add(diagnostics.Info, "lifecycle", "Caption processing stopped") }() } func (a *Application) restartCaptions() { go func() { timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := a.controller.Restart(ctx, a.applied.Settings); err != nil { a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Restart captions: %v", err)) return } a.log.Add(diagnostics.Info, "lifecycle", "Caption processing restarted") }() } func (a *Application) saveOverlayPosition(x, y int) { a.config.Overlay.PositionX = x a.config.Overlay.PositionY = y a.applied.Overlay.PositionX = x a.applied.Overlay.PositionY = y if err := config.Save(a.launch.Path, a.config); err != nil { a.log.Add(diagnostics.Error, "configuration", fmt.Sprintf("Save overlay position: %v", err)) return } a.log.Add(diagnostics.Info, "overlay", fmt.Sprintf("Saved caption position (%d, %d)", x, y)) } func (a *Application) configureOutputs(next config.AppConfig, initial bool) error { var outputErr error a.history.SetEnabled(next.Outputs.History) if next.Outputs.StdoutCaptions { outputErr = joinError(outputErr, a.router.Set("stdout", terminal.New(a.stdout))) } else { outputErr = joinError(outputErr, a.router.Remove("stdout")) } if next.TranscriptLog.Enabled { if initial || a.transcript == nil || next.TranscriptLog != a.applied.TranscriptLog { sink, err := transcript.New(next.TranscriptLog.Directory) if err != nil { outputErr = joinError(outputErr, err) } else { a.transcript = sink outputErr = joinError(outputErr, a.router.Set("transcript", sink)) a.log.Add(diagnostics.Info, "transcript", "Saving captions to "+sink.Path()) } } } else { a.transcript = nil outputErr = joinError(outputErr, a.router.Remove("transcript")) } if next.Outputs.Overlay { settings := next.Overlay if !initial && settings.Backend != a.applied.Overlay.Backend { settings.Backend = a.applied.Overlay.Backend } if initial || a.overlay == nil || settings != a.applied.Overlay { sink, warning, err := overlay.New(settings) if warning != "" { a.log.Add(diagnostics.Warning, "overlay", warning) } if err != nil { outputErr = joinError(outputErr, err) } else { a.overlay = sink outputErr = joinError(outputErr, a.router.Set("overlay", sink)) } } } else { a.overlay = nil outputErr = joinError(outputErr, a.router.Remove("overlay")) } if next.Outputs.StderrDiagnostics { a.log.SetMirror(a.stderr) } else { a.log.SetMirror(io.Discard) } return outputErr } func (a *Application) renderStatus(status app.Status) { a.updateApplyButton() state := strings.ToUpper(string(status.State)) overloaded := !status.OverloadedAt.IsZero() && time.Since(status.OverloadedAt) < 5*time.Second if overloaded { state = "OVERLOADED" } mode := strings.ToUpper(string(status.Mode)) if mode == "" { mode = strings.ToUpper(string(a.applied.Settings.Mode)) } a.statusLabel.SetText(state + " · " + mode) a.statusDot.RemoveCSSClass("status-stopped") a.statusDot.RemoveCSSClass("status-running") a.statusDot.RemoveCSSClass("status-warning") a.statusDot.RemoveCSSClass("status-error") active := status.State != app.StateStopped && status.State != app.StateError switch { case status.State == app.StateError: a.statusDot.AddCSSClass("status-error") case overloaded: a.statusDot.AddCSSClass("status-warning") case active: a.statusDot.AddCSSClass("status-running") default: a.statusDot.AddCSSClass("status-stopped") } if active { a.startStopButton.SetLabel("STOP") a.startStopButton.RemoveCSSClass("primary-action") a.startStopButton.AddCSSClass("danger-action") } else { a.startStopButton.SetLabel("START") a.startStopButton.RemoveCSSClass("danger-action") a.startStopButton.AddCSSClass("primary-action") } speech := "quiet" if status.SpeechActive { speech = "detected" } source := status.Source if source == "" { source = "not connected" } if overloaded { speech += " · OVERLOADED" a.statusSpeech.AddCSSClass("warning-text") } else { a.statusSpeech.RemoveCSSClass("warning-text") } a.statusSource.SetText("Source: " + source) a.statusSpeech.SetText("Speech: " + speech) a.statusOutputs.SetText("Outputs: " + a.outputSummary()) if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError { a.log.Add(diagnostics.Error, "engine", status.LastError) a.lastShownError = status.LastError } else if status.LastError == "" { a.lastShownError = "" } } func (a *Application) outputSummary() string { var active []string if a.config.Outputs.Overlay { active = append(active, "overlay") } if a.config.Outputs.History { active = append(active, "history") } if a.config.Outputs.StdoutCaptions { active = append(active, "stdout") } if a.config.TranscriptLog.Enabled { active = append(active, "log file") } if len(active) == 0 { return "none" } return strings.Join(active, ", ") } func (a *Application) copyDiagnostics() { var text strings.Builder status := a.controller.Status() fmt.Fprintf(&text, "Captioneer diagnostics\nState: %s\nMode: %s\nSource: %s\nConfig: %s\n\n", status.State, status.Mode, status.Source, a.launch.Path) for _, entry := range a.log.Entries() { fmt.Fprintf(&text, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(entry.Severity)), entry.Category, entry.Message) } gdk.DisplayGetDefault().Clipboard().SetText(text.String()) a.log.Add(diagnostics.Info, "gui", "Diagnostics copied to clipboard") } func (a *Application) openConfigFolder() { if err := exec.Command("xdg-open", filepath.Dir(a.launch.Path)).Start(); err != nil { a.log.Add(diagnostics.Error, "gui", fmt.Sprintf("Open configuration folder: %v", err)) } } func (a *Application) showAbout() { dialog := gtk.NewAboutDialog() dialog.SetTransientFor(&a.window.Window) dialog.SetModal(true) dialog.SetProgramName("Captioneer") dialog.SetComments("Local live captions for Linux") dialog.SetLicenseType(gtk.LicenseApache20) dialog.SetWebsite("https://tea.chunkbyte.com/kato/captioneer") dialog.Present() } func (a *Application) shutdown() { a.closingMu.Lock() if a.closing { a.closingMu.Unlock() return } a.closing = true a.closingMu.Unlock() a.log.Add(diagnostics.Info, "lifecycle", "Shutting down Captioneer") go func() { timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) _ = a.controller.Stop(ctx) cancel() glib.IdleAdd(func() { close(a.uiDone) if a.stopStatus != nil { a.stopStatus() a.stopHistory() a.stopLog() } _ = a.router.Close() a.gtkApp.Quit() }) }() } func statusSegment(text string) *gtk.Label { label := gtk.NewLabel(text) label.AddCSSClass("status-segment") return label } func joinError(current, next error) error { if current == nil { return next } if next == nil { return current } return fmt.Errorf("%v; %w", current, next) } func findIconPath() string { candidates := []string{filepath.Join("assets", "icon", "icon.png")} if executable, err := os.Executable(); err == nil { candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "icon", "icon.png")) } for _, candidate := range candidates { if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } } return "" }