From 04ad4eb3d9a49dd1cd95e95049e064a49e11558e Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 17 Jul 2026 15:34:50 +0300 Subject: [PATCH] feat(gui): enhance application UI with new status segments and improved layout - Refactor status bar to include separate labels for source, speech, and outputs. - Update buildWindow method to adjust layout and styling of status elements. - Introduce new text tags for console and caption buffers to improve text rendering. - Modify settings controls to use CheckButton instead of Switch for better clarity. - Revise CSS styles for improved visual consistency and responsiveness across UI elements. --- src/gui/app.go | 57 ++- src/gui/pages.go | 69 +++- src/gui/settings.go | 48 ++- src/gui/style.css | 591 +++++++++++++++++++++++------- src/output/overlay/overlay_gtk.go | 25 +- 5 files changed, 604 insertions(+), 186 deletions(-) diff --git a/src/gui/app.go b/src/gui/app.go index fc7c37f..c170ebb 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -61,14 +61,21 @@ type Application struct { statusLabel *gtk.Label statusDot *gtk.Label startStopButton *gtk.Button - statusbarLabel *gtk.Label + 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 @@ -177,7 +184,7 @@ func (a *Application) buildWindow() { brand := gtk.NewBox(gtk.OrientationHorizontal, 7) if iconPath := findIconPath(); iconPath != "" { icon := gtk.NewImageFromFile(iconPath) - icon.SetPixelSize(27) + icon.SetPixelSize(20) brand.Append(icon) } title := gtk.NewLabel("CAPTIONEER") @@ -187,6 +194,7 @@ func (a *Application) buildWindow() { 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") @@ -211,21 +219,28 @@ func (a *Application) buildWindow() { tabs := gtk.NewStackSwitcher() tabs.SetStack(a.stack) - tabs.AddCSSClass("captioneer-tabs") - tabs.SetHAlign(gtk.AlignFill) - root.Append(tabs) + 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, 8) + statusbar := gtk.NewBox(gtk.OrientationHorizontal, 4) 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) + 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) } @@ -479,17 +494,15 @@ func (a *Application) renderStatus(status app.Status) { if source == "" { source = "not connected" } - overload := "" if overloaded { - overload = " · OVERLOADED" + speech += " · OVERLOADED" + a.statusSpeech.AddCSSClass("warning-text") + } else { + a.statusSpeech.RemoveCSSClass("warning-text") } - a.statusbarLabel.SetText(fmt.Sprintf( - "Source: %s · Speech: %s · Outputs: %s%s", - source, - speech, - a.outputSummary(), - overload, - )) + 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 @@ -573,6 +586,12 @@ func (a *Application) shutdown() { }() } +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 diff --git a/src/gui/pages.go b/src/gui/pages.go index 7db98be..881d9f7 100644 --- a/src/gui/pages.go +++ b/src/gui/pages.go @@ -6,9 +6,11 @@ import ( "fmt" "strings" "time" + "unicode/utf8" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4" + "tea.chunkbyte.com/kato/captioneer/src/diagnostics" "tea.chunkbyte.com/kato/captioneer/src/output/history" ) @@ -35,6 +37,8 @@ func (a *Application) buildCaptionsPage() *gtk.Box { page.Append(toolbar) a.captionBuffer = gtk.NewTextBuffer(nil) + a.captionMetaTag = bufferTag(a.captionBuffer, "meta", "#8A9384") + a.captionHintTag = bufferTag(a.captionBuffer, "hint", "#6E7763") a.captionView = gtk.NewTextViewWithBuffer(a.captionBuffer) a.captionView.SetEditable(false) a.captionView.SetCursorVisible(true) @@ -54,8 +58,9 @@ func (a *Application) buildCaptionsPage() *gtk.Box { provisional := gtk.NewBox(gtk.OrientationHorizontal, 8) provisional.AddCSSClass("provisional-row") - caption := labelWithClass("LIVE", "section-title") + caption := labelWithClass("LIVE", "live-tag") caption.SetVAlign(gtk.AlignStart) + caption.SetMarginTop(2) provisional.Append(caption) a.provisionalLabel = gtk.NewLabel("Waiting for speech…") a.provisionalLabel.SetXAlign(0) @@ -93,6 +98,9 @@ func (a *Application) buildConsolePage() *gtk.Box { page.Append(toolbar) a.consoleBuffer = gtk.NewTextBuffer(nil) + a.consoleMetaTag = bufferTag(a.consoleBuffer, "meta", "#79826F") + a.consoleWarnTag = bufferTag(a.consoleBuffer, "warn", "#C79C4E") + a.consoleErrorTag = bufferTag(a.consoleBuffer, "error", "#CF826D") a.consoleView = gtk.NewTextViewWithBuffer(a.consoleBuffer) a.consoleView.SetEditable(false) a.consoleView.SetCursorVisible(true) @@ -113,18 +121,44 @@ func (a *Application) buildConsolePage() *gtk.Box { return page } +// tagSpan marks a character range of freshly rendered buffer text so a +// TextTag can be applied after SetText replaces the whole buffer. +type tagSpan struct { + tag *gtk.TextTag + start, end int +} + +func applyTagSpans(buffer *gtk.TextBuffer, spans []tagSpan) { + for _, span := range spans { + buffer.ApplyTag(span.tag, buffer.IterAtOffset(span.start), buffer.IterAtOffset(span.end)) + } +} + func (a *Application) renderHistory(snapshot history.Snapshot) { follow := isAtBottom(a.captionScroll) var text strings.Builder + var spans []tagSpan + offset := 0 + if len(snapshot.Finals) == 0 { + hint := "No captions yet. Press START to begin listening." + text.WriteString(hint) + spans = append(spans, tagSpan{a.captionHintTag, 0, utf8.RuneCountInString(hint)}) + } for _, entry := range snapshot.Finals { - fmt.Fprintf(&text, "%s [%s–%s] %s\n", + meta := fmt.Sprintf("%s [%s–%s] ", entry.CreatedAt.Format("15:04:05"), formatCaptionTime(entry.StartedAt), formatCaptionTime(entry.EndedAt), - entry.Text, ) + text.WriteString(meta) + spans = append(spans, tagSpan{a.captionMetaTag, offset, offset + utf8.RuneCountInString(meta)}) + offset += utf8.RuneCountInString(meta) + line := entry.Text + "\n" + text.WriteString(line) + offset += utf8.RuneCountInString(line) } a.captionBuffer.SetText(text.String()) + applyTagSpans(a.captionBuffer, spans) if snapshot.Provisional == nil || strings.TrimSpace(snapshot.Provisional.Text) == "" { a.provisionalLabel.SetText("Waiting for speech…") } else { @@ -138,15 +172,33 @@ func (a *Application) renderHistory(snapshot history.Snapshot) { func (a *Application) renderConsole() { follow := isAtBottom(a.consoleScroll) var text strings.Builder + var spans []tagSpan + offset := 0 for _, entry := range a.log.Entries() { - fmt.Fprintf(&text, "%s %-7s %-14s %s\n", + var severityTag *gtk.TextTag + switch entry.Severity { + case diagnostics.Warning: + severityTag = a.consoleWarnTag + case diagnostics.Error: + severityTag = a.consoleErrorTag + } + meta := fmt.Sprintf("%s %-7s %-14s ", entry.At.Format("15:04:05.000"), strings.ToUpper(string(entry.Severity)), "["+entry.Category+"]", - entry.Message, ) + text.WriteString(meta) + spans = append(spans, tagSpan{a.consoleMetaTag, offset, offset + utf8.RuneCountInString(meta)}) + offset += utf8.RuneCountInString(meta) + line := entry.Message + "\n" + text.WriteString(line) + if severityTag != nil { + spans = append(spans, tagSpan{severityTag, offset, offset + utf8.RuneCountInString(entry.Message)}) + } + offset += utf8.RuneCountInString(line) } a.consoleBuffer.SetText(text.String()) + applyTagSpans(a.consoleBuffer, spans) if follow { scrollTextViewToEnd(a.consoleView, a.consoleScroll) } @@ -193,6 +245,13 @@ func copyBuffer(buffer *gtk.TextBuffer) { gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true)) } +func bufferTag(buffer *gtk.TextBuffer, name, foreground string) *gtk.TextTag { + tag := gtk.NewTextTag(name) + tag.SetObjectProperty("foreground", foreground) + buffer.TagTable().Add(tag) + return tag +} + func labelWithClass(text, class string) *gtk.Label { label := gtk.NewLabel(text) label.AddCSSClass(class) diff --git a/src/gui/settings.go b/src/gui/settings.go index 3ad0e25..3905e76 100644 --- a/src/gui/settings.go +++ b/src/gui/settings.go @@ -9,7 +9,6 @@ import ( "time" "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" @@ -17,8 +16,8 @@ import ( ) type settingsControls struct { - startCaptions *gtk.Switch - alwaysOnTop *gtk.Switch + startCaptions *gtk.CheckButton + alwaysOnTop *gtk.CheckButton mode *gtk.ComboBoxText chunkDuration *gtk.Entry @@ -27,11 +26,11 @@ type settingsControls struct { previewThresholdDB *gtk.SpinButton vadThreshold *gtk.SpinButton - overlayEnabled *gtk.Switch - historyEnabled *gtk.Switch - stdoutEnabled *gtk.Switch - stderrEnabled *gtk.Switch - transcriptEnabled *gtk.Switch + overlayEnabled *gtk.CheckButton + historyEnabled *gtk.CheckButton + stdoutEnabled *gtk.CheckButton + stderrEnabled *gtk.CheckButton + transcriptEnabled *gtk.CheckButton transcriptDirectory *gtk.Entry overlayOpacity *gtk.SpinButton @@ -41,10 +40,10 @@ type settingsControls struct { overlayMaxWidth *gtk.SpinButton overlayMonitor *gtk.Entry overlayFinalTimeout *gtk.Entry - overlayClickThrough *gtk.Switch + overlayClickThrough *gtk.CheckButton overlayBackend *gtk.ComboBoxText - modelAutoRestart *gtk.Switch + modelAutoRestart *gtk.CheckButton modelTimeout *gtk.Entry modelShutdownTimeout *gtk.Entry } @@ -101,8 +100,8 @@ func (a *Application) buildSettingsPage() *gtk.Box { } func (a *Application) createSettingsControls() { - a.settings.startCaptions = gtk.NewSwitch() - a.settings.alwaysOnTop = gtk.NewSwitch() + a.settings.startCaptions = gtk.NewCheckButton() + a.settings.alwaysOnTop = gtk.NewCheckButton() a.settings.mode = combo([]option{{"vad", "Voice activity detection (VAD)"}, {"fixed", "Fixed chunks"}}) a.settings.chunkDuration = gtk.NewEntry() a.settings.modelsDirectory = gtk.NewEntry() @@ -110,11 +109,11 @@ func (a *Application) createSettingsControls() { a.settings.previewThresholdDB = spin(-100, 0, 1, 1) a.settings.vadThreshold = spin(0, 1, 0.01, 2) - a.settings.overlayEnabled = gtk.NewSwitch() - a.settings.historyEnabled = gtk.NewSwitch() - a.settings.stdoutEnabled = gtk.NewSwitch() - a.settings.stderrEnabled = gtk.NewSwitch() - a.settings.transcriptEnabled = gtk.NewSwitch() + a.settings.overlayEnabled = gtk.NewCheckButton() + a.settings.historyEnabled = gtk.NewCheckButton() + a.settings.stdoutEnabled = gtk.NewCheckButton() + a.settings.stderrEnabled = gtk.NewCheckButton() + a.settings.transcriptEnabled = gtk.NewCheckButton() a.settings.transcriptDirectory = gtk.NewEntry() a.settings.overlayOpacity = spin(0, 1, 0.01, 2) @@ -125,10 +124,10 @@ func (a *Application) createSettingsControls() { a.settings.overlayMaxWidth = spin(100, 5000, 10, 0) a.settings.overlayMonitor = gtk.NewEntry() a.settings.overlayFinalTimeout = gtk.NewEntry() - a.settings.overlayClickThrough = gtk.NewSwitch() + a.settings.overlayClickThrough = gtk.NewCheckButton() a.settings.overlayBackend = combo([]option{{"auto", "Automatic"}, {"wayland", "Wayland"}, {"x11", "X11"}}) - a.settings.modelAutoRestart = gtk.NewSwitch() + a.settings.modelAutoRestart = gtk.NewCheckButton() a.settings.modelTimeout = gtk.NewEntry() a.settings.modelShutdownTimeout = gtk.NewEntry() } @@ -368,16 +367,13 @@ func (a *Application) connectSettingsChanged() { control.ConnectChanged(changed) } a.settings.overlayFont.ConnectFontSet(changed) - switches := []*gtk.Switch{ + checks := []*gtk.CheckButton{ a.settings.startCaptions, a.settings.alwaysOnTop, a.settings.overlayEnabled, a.settings.historyEnabled, a.settings.stdoutEnabled, a.settings.stderrEnabled, a.settings.transcriptEnabled, a.settings.overlayClickThrough, a.settings.modelAutoRestart, } - for _, control := range switches { - control.ConnectStateSet(func(bool) bool { - glib.IdleAdd(changed) - return false - }) + for _, control := range checks { + control.ConnectToggled(changed) } } @@ -435,6 +431,8 @@ func settingRow(title, description string, control gtk.Widgetter) *gtk.Box { detail.SetWrap(true) text.Append(detail) row.Append(text) + widget := gtk.BaseWidget(control) + widget.SetVAlign(gtk.AlignCenter) row.Append(control) return row } diff --git a/src/gui/style.css b/src/gui/style.css index d7010c1..b8d40c3 100644 --- a/src/gui/style.css +++ b/src/gui/style.css @@ -1,218 +1,537 @@ +/* + * Captioneer visual theme, modeled on the 2003-2005 Steam client: + * olive-gray raised chrome, sunken near-black wells, khaki/gold accents, + * 1px bevels (light top/left, dark bottom/right), no soft modern gradients. + * + * Palette: + * chrome #3E4637 (gradient stop #333A2D) + * raised panel #4C5844 + * sunken well #1E221B + * body text #D8DED3 dim text #8A9384 + * khaki accent #C4B550 selection #958831 + * error #B05C4A warning #C79C4E + * bevel light #6A7862 bevel dark #242921 + */ + window.captioneer-main { - background: #30372f; - color: #e0e5d7; + background: #3E4637; + color: #D8DED3; + font-size: 9.5pt; } +/* ------------------------------------------------------------------ */ +/* Titlebar chrome */ +/* ------------------------------------------------------------------ */ + headerbar.captioneer-header { - min-height: 40px; - padding: 4px 7px; - background-image: linear-gradient(to bottom, #4a5543, #2b332c); - border-bottom: 1px solid #171d18; - box-shadow: inset 0 1px #748069; -} - -.captioneer-brand { - color: #e8ebdf; - font-weight: 700; - letter-spacing: 1px; -} - -.status-text { - color: #cbd3bd; - font-weight: 600; -} - -.status-dot { - font-size: 16px; -} - -.status-dot.status-running { color: #9eb96b; } -.status-dot.status-stopped { color: #7f8977; } -.status-dot.status-warning { color: #dfc078; } -.status-dot.status-error { color: #e58c7c; } - -.captioneer-menu { - min-height: 24px; - padding: 0 4px; - background: #3a4337; - border-bottom: 1px solid #171d18; -} - -.captioneer-tabs { min-height: 34px; - padding: 3px 8px 0; - background: #353d33; - border-bottom: 1px solid #171d18; + padding: 2px 6px; + background-image: linear-gradient(to bottom, #454E3C, #333A2D); + border-bottom: 1px solid #242921; + box-shadow: inset 0 1px #6A7862; + color: #D8DED3; } -.captioneer-tabs button { - min-height: 26px; - padding: 3px 16px; - border-radius: 2px 2px 0 0; - background-image: linear-gradient(to bottom, #56604d, #414a3d); - border: 1px solid #1d241e; - border-top-color: #748069; - border-left-color: #68745e; - color: #c9d0bf; +headerbar.captioneer-header windowcontrols button { + min-height: 17px; + min-width: 19px; + padding: 1px 3px; + margin: 0 1px; + border-radius: 0; + background-image: none; + background: #4C5844; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; + color: #B9C2AD; box-shadow: none; } +headerbar.captioneer-header windowcontrols button:hover { + background: #5B684D; + color: #E8EDD8; +} + +headerbar.captioneer-header windowcontrols button:active { + background: #333A2D; + border-top-color: #242921; + border-left-color: #242921; + border-bottom-color: #6A7862; + border-right-color: #6A7862; +} + +.captioneer-brand { + color: #C4B550; + font-weight: 700; + font-size: 10pt; + letter-spacing: 2px; +} + +.status-cell { + min-height: 20px; + padding: 1px 12px; + background: #2C3227; + border: 1px solid #242921; + border-bottom-color: #58644E; + border-right-color: #58644E; + border-radius: 1px; +} + +.status-text { + color: #B9C2AD; + font-weight: 600; + font-size: 8.5pt; + letter-spacing: 1px; +} + +.status-dot { + font-size: 11px; +} + +.status-dot.status-running { color: #C4B550; } +.status-dot.status-stopped { color: #79826F; } +.status-dot.status-warning { color: #C79C4E; } +.status-dot.status-error { color: #B05C4A; } + +/* ------------------------------------------------------------------ */ +/* Menu bar */ +/* ------------------------------------------------------------------ */ + +.captioneer-menu { + min-height: 22px; + padding: 0 4px; + background: #3E4637; + border-bottom: 1px solid #242921; +} + +.captioneer-menu > item { + padding: 2px 9px; + border-radius: 0; + color: #C3CBB6; +} + +.captioneer-menu > item:hover, +.captioneer-menu > item:selected { + background: #958831; + color: #1E221B; +} + +popover.menu contents { + background: #3E4637; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; + border-radius: 0; + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35); + color: #D8DED3; + padding: 2px; +} + +popover.menu modelbutton { + min-height: 22px; + padding: 2px 10px; + border-radius: 0; + color: #D8DED3; +} + +popover.menu modelbutton:hover { + background: #958831; + color: #1E221B; +} + +/* ------------------------------------------------------------------ */ +/* Tab strip */ +/* ------------------------------------------------------------------ */ + +.captioneer-tabs { + min-height: 27px; + padding: 5px 8px 0; + background: #383F30; + border-bottom: 1px solid #242921; +} + +.captioneer-tabs button { + min-height: 22px; + padding: 3px 16px; + margin-right: 2px; + margin-bottom: -1px; + border-radius: 1px 1px 0 0; + background-image: none; + background: #434D3B; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; + color: #A8B29B; + font-size: 8.5pt; + font-weight: 600; + letter-spacing: 1px; + box-shadow: none; +} + +.captioneer-tabs button:hover { + color: #D8DED3; + background: #4A553F; +} + .captioneer-tabs button:checked { - background: #515b49; - color: #f0f2ea; - border-bottom-color: #515b49; + background: #4C5844; + color: #C4B550; + border-bottom-color: #4C5844; } .captioneer-content { - padding: 10px; - background: #515b49; + padding: 8px; + background: #4C5844; } +/* ------------------------------------------------------------------ */ +/* Sunken wells and text views */ +/* ------------------------------------------------------------------ */ + .inset-panel, textview, entry, spinbutton, -combobox, -fontbutton { - background: #252c26; - color: #e0e5d7; - border: 1px solid #171d18; - border-bottom-color: #748069; - border-right-color: #68745e; +combobox button.combo, +fontbutton button { + background-image: none; + background: #1E221B; + color: #D8DED3; + border: 1px solid #191D16; + border-bottom-color: #6A7862; + border-right-color: #58644E; border-radius: 1px; -} - -textview text { - background: #252c26; - color: #e0e5d7; -} - -.console-view text { - background: #202621; - color: #c5cfb9; - font-family: Monospace; - font-size: 10pt; -} - -.provisional-row { - min-height: 34px; - padding: 6px 10px; - background: #394237; - color: #cbd5b8; - border: 1px solid #1a201b; - font-style: italic; -} - -.toolbar { - padding: 4px; - background: #424b3e; - border: 1px solid #202720; -} - -button { - min-height: 24px; - padding: 3px 9px; - border-radius: 2px; - background-image: linear-gradient(to bottom, #657058, #46503f); - color: #edf0e6; - border: 1px solid #1a201b; - border-top-color: #7d896f; - border-left-color: #748069; box-shadow: none; } +textview text { + background: #1E221B; + color: #D8DED3; +} + +textview text selection { + background: #958831; + color: #1E221B; +} + +.console-view text { + background: #191D16; + color: #B9C4AC; + font-family: Monospace; + font-size: 9pt; +} + +.provisional-row { + min-height: 30px; + padding: 5px 8px; + background: #2C3227; + color: #D3DCBF; + border: 1px solid #191D16; + border-bottom-color: #58644E; + border-right-color: #58644E; + border-radius: 1px; +} + +.live-tag { + padding: 1px 7px; + background: #958831; + color: #1E221B; + font-size: 7.5pt; + font-weight: 700; + letter-spacing: 1px; + border-radius: 1px; +} + +/* ------------------------------------------------------------------ */ +/* Toolbars and buttons */ +/* ------------------------------------------------------------------ */ + +.toolbar { + min-height: 28px; + padding: 3px 5px; + background: #525E48; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; + border-radius: 1px; +} + +button { + min-height: 21px; + min-width: 0; + padding: 2px 10px; + border-radius: 1px; + background-image: none; + background: #57634B; + color: #D8DED3; + font-size: 8.5pt; + font-weight: 600; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; + box-shadow: none; + text-shadow: none; +} + button:hover { - background-image: linear-gradient(to bottom, #758064, #515c48); + background: #63704F; + color: #EDF1DD; + border-top-color: #83917A; + border-left-color: #83917A; } button:active, button:checked { - background: #354033; - border-top-color: #171d18; - border-left-color: #171d18; - border-bottom-color: #748069; - border-right-color: #68745e; + background: #3A4233; + border-top-color: #242921; + border-left-color: #242921; + border-bottom-color: #6A7862; + border-right-color: #6A7862; } button:disabled, -switch:disabled, +checkbutton:disabled, entry:disabled, spinbutton:disabled, combobox:disabled { - color: #7f8977; - background: #363d35; + color: #79826F; + background: #414A38; } button:focus-visible, entry:focus-visible, spinbutton:focus-visible, combobox:focus-visible, -switch:focus-visible, +checkbutton:focus-visible, textview:focus-visible { - outline: 2px solid #d5dea7; + outline: 1px dotted #C4B550; outline-offset: -2px; } .primary-action { - background-image: linear-gradient(to bottom, #899b5f, #637344); - color: #ffffff; + color: #C4B550; font-weight: 700; + letter-spacing: 1px; + border-top-color: #83917A; + border-left-color: #7E8C74; +} + +.primary-action:hover { + color: #DED08A; } .danger-action { - background-image: linear-gradient(to bottom, #9e6556, #71463e); + color: #E0A18E; + font-weight: 700; + letter-spacing: 1px; } +.danger-action:hover { + color: #F0BCAB; +} + +/* ------------------------------------------------------------------ */ +/* Typography helpers */ +/* ------------------------------------------------------------------ */ + .section-title { - color: #eef1e8; + color: #C4B550; + font-size: 8.5pt; font-weight: 700; - letter-spacing: 0.5px; + letter-spacing: 1px; } .description { - color: #adb7a1; - font-size: 9.5pt; + color: #8A9384; + font-size: 8.5pt; } .validation-error { - color: #e5a192; + color: #D98D7A; font-weight: 600; + font-size: 8.5pt; } .warning-text { - color: #dfc078; + color: #C79C4E; } +/* ------------------------------------------------------------------ */ +/* Statusbar */ +/* ------------------------------------------------------------------ */ + .statusbar { - min-height: 25px; - padding: 3px 8px; - background: #343c32; - color: #adb7a1; - border-top: 1px solid #748069; + min-height: 23px; + padding: 3px 6px; + background: #3E4637; + border-top: 1px solid #242921; + box-shadow: inset 0 1px #58644E; +} + +.status-segment { + min-height: 17px; + padding: 0 9px; + background: #343B2D; + color: #98A28C; + font-size: 8.5pt; + border: 1px solid #242921; + border-bottom-color: #58644E; + border-right-color: #58644E; + border-radius: 1px; +} + +/* ------------------------------------------------------------------ */ +/* Settings */ +/* ------------------------------------------------------------------ */ + +.settings-sidebar { + background: #434D3B; + border: 1px solid #242921; + border-bottom-color: #58644E; + border-right-color: #58644E; +} + +.settings-sidebar list { + background: #434D3B; } .settings-sidebar row { min-width: 130px; - padding: 7px 10px; - border-radius: 1px; + min-height: 24px; + padding: 4px 10px; + border-radius: 0; + color: #B4BEA6; + font-size: 9pt; +} + +.settings-sidebar row:hover { + background: #4E5943; + color: #D8DED3; +} + +.settings-sidebar row:selected { + background: #958831; + color: #1E221B; + font-weight: 700; } .settings-panel { - background: #454f41; - border: 1px solid #1d241e; + background: #46513D; + border: 1px solid #242921; + border-bottom-color: #58644E; + border-right-color: #58644E; } .setting-row { - min-height: 48px; - padding: 6px 8px; - background: #4b5546; - border: 1px solid #252c26; + min-height: 36px; + padding: 4px 8px; + background: #4E5A45; + border: 1px solid #3A4233; + border-top-color: #5C6950; + border-left-color: #5C6950; + border-radius: 1px; +} + +/* ------------------------------------------------------------------ */ +/* Form controls */ +/* ------------------------------------------------------------------ */ + +checkbutton check { + min-height: 13px; + min-width: 13px; + margin: 2px; + border-radius: 1px; + background-image: none; + background: #1E221B; + border: 1px solid #191D16; + border-bottom-color: #6A7862; + border-right-color: #58644E; + color: #C4B550; + box-shadow: none; + -gtk-icon-size: 11px; +} + +checkbutton check:hover { + background: #262B22; +} + +checkbutton check:checked { + background: #1E221B; + color: #C4B550; +} + +entry, +spinbutton { + min-height: 22px; + padding: 1px 6px; + caret-color: #C4B550; +} + +entry selection, +spinbutton selection { + background: #958831; + color: #1E221B; +} + +spinbutton button { + min-height: 0; + min-width: 18px; + padding: 0 3px; + margin: 1px; + border-radius: 0; + background: #4C5844; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; +} + +spinbutton button:hover { + background: #5B684D; +} + +combobox button.combo, +fontbutton button { + min-height: 22px; + padding: 1px 8px; + font-weight: 400; +} + +combobox arrow { + color: #C4B550; + min-width: 14px; +} + +/* ------------------------------------------------------------------ */ +/* Scrollbars */ +/* ------------------------------------------------------------------ */ + +scrollbar { + background: #2C3227; + border: none; +} + +scrollbar trough { + background: #2C3227; + border-radius: 0; } scrollbar slider { - min-width: 12px; - min-height: 12px; + min-width: 11px; + min-height: 24px; + margin: 1px; border-radius: 1px; - background: #68745e; - border: 1px solid #222921; + background: #5C6850; + border: 1px solid #242921; + border-top-color: #6A7862; + border-left-color: #6A7862; +} + +scrollbar slider:hover { + background: #68755A; +} + +separator { + background: #242921; + min-height: 1px; + min-width: 1px; } diff --git a/src/output/overlay/overlay_gtk.go b/src/output/overlay/overlay_gtk.go index 08fb51d..359bf73 100644 --- a/src/output/overlay/overlay_gtk.go +++ b/src/output/overlay/overlay_gtk.go @@ -13,6 +13,7 @@ package overlay #include #include #include +#include #include #include #include @@ -188,10 +189,24 @@ static gboolean captioneer_reposition_x11(gpointer data) { gdk_monitor_get_geometry(overlay->monitor, &geometry); int width = gdk_surface_get_width(surface); int height = gdk_surface_get_height(surface); + if (width <= 1 || height <= 1) { + return G_SOURCE_REMOVE; + } int bottom_margin = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(overlay->window), "bottom-margin")); int x = geometry.x + (geometry.width - width) / 2; int y = geometry.y + geometry.height - height - bottom_margin; - XMoveWindow(GDK_SURFACE_XDISPLAY(surface), GDK_SURFACE_XID(surface), x, y); + Display *display = GDK_SURFACE_XDISPLAY(surface); + Window xid = GDK_SURFACE_XID(surface); + XSizeHints *size_hints = XAllocSizeHints(); + if (size_hints != NULL) { + size_hints->flags = USPosition | PPosition; + size_hints->x = x; + size_hints->y = y; + XSetWMNormalHints(display, xid, size_hints); + XFree(size_hints); + } + XMoveWindow(display, xid, x, y); + XFlush(display); return G_SOURCE_REMOVE; } @@ -327,6 +342,11 @@ static CaptioneerOverlay *captioneer_overlay_new( gtk_layer_init_for_window(overlay->window); gtk_layer_set_namespace(overlay->window, "captioneer"); gtk_layer_set_layer(overlay->window, GTK_LAYER_SHELL_LAYER_OVERLAY); + // Stretch across the bottom edge so the compositor pins the surface + // there (single-edge anchoring can leave it floating mid-screen on + // Plasma). The bubble itself stays content-width and centered. + gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_LEFT, TRUE); + gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_RIGHT, TRUE); gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, TRUE); gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, bottom_margin); gtk_layer_set_exclusive_zone(overlay->window, 0); @@ -363,6 +383,9 @@ static CaptioneerOverlay *captioneer_overlay_new( overlay->bubble = box; gtk_widget_add_css_class(box, "captioneer-bubble"); gtk_widget_set_halign(box, GTK_ALIGN_CENTER); + gtk_widget_set_valign(box, GTK_ALIGN_END); + gtk_widget_set_hexpand(box, FALSE); + gtk_widget_set_vexpand(box, FALSE); gtk_widget_set_size_request(box, effective_width, -1); overlay->final_label = gtk_label_new("");