docs: update README with detailed desktop features and setup requirements

This commit is contained in:
2026-07-17 14:41:10 +03:00
parent 55fbd357b0
commit 54fb100d3c
35 changed files with 3469 additions and 162 deletions
+597
View File
@@ -0,0 +1,597 @@
//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
statusbarLabel *gtk.Label
captionBuffer *gtk.TextBuffer
captionView *gtk.TextView
captionScroll *gtk.ScrolledWindow
provisionalLabel *gtk.Label
consoleBuffer *gtk.TextBuffer
consoleView *gtk.TextView
consoleScroll *gtk.ScrolledWindow
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()
if a.launch.Warning != nil {
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
}
if err := a.configureOutputs(a.config, true); err != nil {
a.log.Add(diagnostics.Error, "outputs", err.Error())
}
a.window.Present()
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(27)
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)
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.AddCSSClass("captioneer-tabs")
tabs.SetHAlign(gtk.AlignFill)
root.Append(tabs)
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, 8)
statusbar.AddCSSClass("statusbar")
a.statusbarLabel = gtk.NewLabel("Source: not connected · Speech: quiet · Outputs: preparing")
a.statusbarLabel.SetXAlign(0)
a.statusbarLabel.SetEllipsize(3)
statusbar.Append(a.statusbarLabel)
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{"<Shift>F5"})
a.gtkApp.SetAccelsForAction("app.restart", []string{"<Primary>r"})
a.gtkApp.SetAccelsForAction("app.view-captions", []string{"<Alt>1"})
a.gtkApp.SetAccelsForAction("app.view-console", []string{"<Alt>2"})
a.gtkApp.SetAccelsForAction("app.view-settings", []string{"<Alt>3"})
a.gtkApp.SetAccelsForAction("app.exit", []string{"<Primary>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) 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"
}
overload := ""
if overloaded {
overload = " · OVERLOADED"
}
a.statusbarLabel.SetText(fmt.Sprintf(
"Source: %s · Speech: %s · Outputs: %s%s",
source,
speech,
a.outputSummary(),
overload,
))
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 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 ""
}