feat(gui): enhance application UI with new status segments and improved layout
Tests / Go and GTK tests (push) Failing after 2m14s

- 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.
This commit is contained in:
2026-07-17 15:34:50 +03:00
parent 54fb100d3c
commit 04ad4eb3d9
5 changed files with 604 additions and 186 deletions
+38 -19
View File
@@ -61,14 +61,21 @@ type Application struct {
statusLabel *gtk.Label statusLabel *gtk.Label
statusDot *gtk.Label statusDot *gtk.Label
startStopButton *gtk.Button startStopButton *gtk.Button
statusbarLabel *gtk.Label statusSource *gtk.Label
statusSpeech *gtk.Label
statusOutputs *gtk.Label
captionBuffer *gtk.TextBuffer captionBuffer *gtk.TextBuffer
captionView *gtk.TextView captionView *gtk.TextView
captionScroll *gtk.ScrolledWindow captionScroll *gtk.ScrolledWindow
captionMetaTag *gtk.TextTag
captionHintTag *gtk.TextTag
provisionalLabel *gtk.Label provisionalLabel *gtk.Label
consoleBuffer *gtk.TextBuffer consoleBuffer *gtk.TextBuffer
consoleView *gtk.TextView consoleView *gtk.TextView
consoleScroll *gtk.ScrolledWindow consoleScroll *gtk.ScrolledWindow
consoleMetaTag *gtk.TextTag
consoleWarnTag *gtk.TextTag
consoleErrorTag *gtk.TextTag
settings settingsControls settings settingsControls
settingsError *gtk.Label settingsError *gtk.Label
applyButton *gtk.Button applyButton *gtk.Button
@@ -177,7 +184,7 @@ func (a *Application) buildWindow() {
brand := gtk.NewBox(gtk.OrientationHorizontal, 7) brand := gtk.NewBox(gtk.OrientationHorizontal, 7)
if iconPath := findIconPath(); iconPath != "" { if iconPath := findIconPath(); iconPath != "" {
icon := gtk.NewImageFromFile(iconPath) icon := gtk.NewImageFromFile(iconPath)
icon.SetPixelSize(27) icon.SetPixelSize(20)
brand.Append(icon) brand.Append(icon)
} }
title := gtk.NewLabel("CAPTIONEER") title := gtk.NewLabel("CAPTIONEER")
@@ -187,6 +194,7 @@ func (a *Application) buildWindow() {
statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6) statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6)
statusBox.SetHAlign(gtk.AlignCenter) statusBox.SetHAlign(gtk.AlignCenter)
statusBox.AddCSSClass("status-cell")
a.statusDot = gtk.NewLabel("●") a.statusDot = gtk.NewLabel("●")
a.statusDot.AddCSSClass("status-dot") a.statusDot.AddCSSClass("status-dot")
a.statusLabel = gtk.NewLabel("STOPPED · VAD") a.statusLabel = gtk.NewLabel("STOPPED · VAD")
@@ -211,21 +219,28 @@ func (a *Application) buildWindow() {
tabs := gtk.NewStackSwitcher() tabs := gtk.NewStackSwitcher()
tabs.SetStack(a.stack) tabs.SetStack(a.stack)
tabs.AddCSSClass("captioneer-tabs") tabs.SetHAlign(gtk.AlignStart)
tabs.SetHAlign(gtk.AlignFill) tabStrip := gtk.NewBox(gtk.OrientationHorizontal, 0)
root.Append(tabs) tabStrip.AddCSSClass("captioneer-tabs")
tabStrip.Append(tabs)
root.Append(tabStrip)
a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS") a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS")
a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE") a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE")
a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS") a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS")
root.Append(a.stack) root.Append(a.stack)
statusbar := gtk.NewBox(gtk.OrientationHorizontal, 8) statusbar := gtk.NewBox(gtk.OrientationHorizontal, 4)
statusbar.AddCSSClass("statusbar") statusbar.AddCSSClass("statusbar")
a.statusbarLabel = gtk.NewLabel("Source: not connected · Speech: quiet · Outputs: preparing") a.statusSource = statusSegment("Source: not connected")
a.statusbarLabel.SetXAlign(0) a.statusSource.SetHExpand(true)
a.statusbarLabel.SetEllipsize(3) a.statusSource.SetXAlign(0)
statusbar.Append(a.statusbarLabel) 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) root.Append(statusbar)
a.window.SetChild(root) a.window.SetChild(root)
} }
@@ -479,17 +494,15 @@ func (a *Application) renderStatus(status app.Status) {
if source == "" { if source == "" {
source = "not connected" source = "not connected"
} }
overload := ""
if overloaded { if overloaded {
overload = " · OVERLOADED" speech += " · OVERLOADED"
a.statusSpeech.AddCSSClass("warning-text")
} else {
a.statusSpeech.RemoveCSSClass("warning-text")
} }
a.statusbarLabel.SetText(fmt.Sprintf( a.statusSource.SetText("Source: " + source)
"Source: %s · Speech: %s · Outputs: %s%s", a.statusSpeech.SetText("Speech: " + speech)
source, a.statusOutputs.SetText("Outputs: " + a.outputSummary())
speech,
a.outputSummary(),
overload,
))
if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError { if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError {
a.log.Add(diagnostics.Error, "engine", status.LastError) a.log.Add(diagnostics.Error, "engine", status.LastError)
a.lastShownError = 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 { func joinError(current, next error) error {
if current == nil { if current == nil {
return next return next
+64 -5
View File
@@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
"tea.chunkbyte.com/kato/captioneer/src/output/history" "tea.chunkbyte.com/kato/captioneer/src/output/history"
) )
@@ -35,6 +37,8 @@ func (a *Application) buildCaptionsPage() *gtk.Box {
page.Append(toolbar) page.Append(toolbar)
a.captionBuffer = gtk.NewTextBuffer(nil) 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 = gtk.NewTextViewWithBuffer(a.captionBuffer)
a.captionView.SetEditable(false) a.captionView.SetEditable(false)
a.captionView.SetCursorVisible(true) a.captionView.SetCursorVisible(true)
@@ -54,8 +58,9 @@ func (a *Application) buildCaptionsPage() *gtk.Box {
provisional := gtk.NewBox(gtk.OrientationHorizontal, 8) provisional := gtk.NewBox(gtk.OrientationHorizontal, 8)
provisional.AddCSSClass("provisional-row") provisional.AddCSSClass("provisional-row")
caption := labelWithClass("LIVE", "section-title") caption := labelWithClass("LIVE", "live-tag")
caption.SetVAlign(gtk.AlignStart) caption.SetVAlign(gtk.AlignStart)
caption.SetMarginTop(2)
provisional.Append(caption) provisional.Append(caption)
a.provisionalLabel = gtk.NewLabel("Waiting for speech…") a.provisionalLabel = gtk.NewLabel("Waiting for speech…")
a.provisionalLabel.SetXAlign(0) a.provisionalLabel.SetXAlign(0)
@@ -93,6 +98,9 @@ func (a *Application) buildConsolePage() *gtk.Box {
page.Append(toolbar) page.Append(toolbar)
a.consoleBuffer = gtk.NewTextBuffer(nil) 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 = gtk.NewTextViewWithBuffer(a.consoleBuffer)
a.consoleView.SetEditable(false) a.consoleView.SetEditable(false)
a.consoleView.SetCursorVisible(true) a.consoleView.SetCursorVisible(true)
@@ -113,18 +121,44 @@ func (a *Application) buildConsolePage() *gtk.Box {
return page 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) { func (a *Application) renderHistory(snapshot history.Snapshot) {
follow := isAtBottom(a.captionScroll) follow := isAtBottom(a.captionScroll)
var text strings.Builder 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 { 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"), entry.CreatedAt.Format("15:04:05"),
formatCaptionTime(entry.StartedAt), formatCaptionTime(entry.StartedAt),
formatCaptionTime(entry.EndedAt), 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()) a.captionBuffer.SetText(text.String())
applyTagSpans(a.captionBuffer, spans)
if snapshot.Provisional == nil || strings.TrimSpace(snapshot.Provisional.Text) == "" { if snapshot.Provisional == nil || strings.TrimSpace(snapshot.Provisional.Text) == "" {
a.provisionalLabel.SetText("Waiting for speech…") a.provisionalLabel.SetText("Waiting for speech…")
} else { } else {
@@ -138,15 +172,33 @@ func (a *Application) renderHistory(snapshot history.Snapshot) {
func (a *Application) renderConsole() { func (a *Application) renderConsole() {
follow := isAtBottom(a.consoleScroll) follow := isAtBottom(a.consoleScroll)
var text strings.Builder var text strings.Builder
var spans []tagSpan
offset := 0
for _, entry := range a.log.Entries() { 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"), entry.At.Format("15:04:05.000"),
strings.ToUpper(string(entry.Severity)), strings.ToUpper(string(entry.Severity)),
"["+entry.Category+"]", "["+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()) a.consoleBuffer.SetText(text.String())
applyTagSpans(a.consoleBuffer, spans)
if follow { if follow {
scrollTextViewToEnd(a.consoleView, a.consoleScroll) scrollTextViewToEnd(a.consoleView, a.consoleScroll)
} }
@@ -193,6 +245,13 @@ func copyBuffer(buffer *gtk.TextBuffer) {
gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true)) 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 { func labelWithClass(text, class string) *gtk.Label {
label := gtk.NewLabel(text) label := gtk.NewLabel(text)
label.AddCSSClass(class) label.AddCSSClass(class)
+23 -25
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
"tea.chunkbyte.com/kato/captioneer/src/app" "tea.chunkbyte.com/kato/captioneer/src/app"
"tea.chunkbyte.com/kato/captioneer/src/config" "tea.chunkbyte.com/kato/captioneer/src/config"
@@ -17,8 +16,8 @@ import (
) )
type settingsControls struct { type settingsControls struct {
startCaptions *gtk.Switch startCaptions *gtk.CheckButton
alwaysOnTop *gtk.Switch alwaysOnTop *gtk.CheckButton
mode *gtk.ComboBoxText mode *gtk.ComboBoxText
chunkDuration *gtk.Entry chunkDuration *gtk.Entry
@@ -27,11 +26,11 @@ type settingsControls struct {
previewThresholdDB *gtk.SpinButton previewThresholdDB *gtk.SpinButton
vadThreshold *gtk.SpinButton vadThreshold *gtk.SpinButton
overlayEnabled *gtk.Switch overlayEnabled *gtk.CheckButton
historyEnabled *gtk.Switch historyEnabled *gtk.CheckButton
stdoutEnabled *gtk.Switch stdoutEnabled *gtk.CheckButton
stderrEnabled *gtk.Switch stderrEnabled *gtk.CheckButton
transcriptEnabled *gtk.Switch transcriptEnabled *gtk.CheckButton
transcriptDirectory *gtk.Entry transcriptDirectory *gtk.Entry
overlayOpacity *gtk.SpinButton overlayOpacity *gtk.SpinButton
@@ -41,10 +40,10 @@ type settingsControls struct {
overlayMaxWidth *gtk.SpinButton overlayMaxWidth *gtk.SpinButton
overlayMonitor *gtk.Entry overlayMonitor *gtk.Entry
overlayFinalTimeout *gtk.Entry overlayFinalTimeout *gtk.Entry
overlayClickThrough *gtk.Switch overlayClickThrough *gtk.CheckButton
overlayBackend *gtk.ComboBoxText overlayBackend *gtk.ComboBoxText
modelAutoRestart *gtk.Switch modelAutoRestart *gtk.CheckButton
modelTimeout *gtk.Entry modelTimeout *gtk.Entry
modelShutdownTimeout *gtk.Entry modelShutdownTimeout *gtk.Entry
} }
@@ -101,8 +100,8 @@ func (a *Application) buildSettingsPage() *gtk.Box {
} }
func (a *Application) createSettingsControls() { func (a *Application) createSettingsControls() {
a.settings.startCaptions = gtk.NewSwitch() a.settings.startCaptions = gtk.NewCheckButton()
a.settings.alwaysOnTop = gtk.NewSwitch() a.settings.alwaysOnTop = gtk.NewCheckButton()
a.settings.mode = combo([]option{{"vad", "Voice activity detection (VAD)"}, {"fixed", "Fixed chunks"}}) a.settings.mode = combo([]option{{"vad", "Voice activity detection (VAD)"}, {"fixed", "Fixed chunks"}})
a.settings.chunkDuration = gtk.NewEntry() a.settings.chunkDuration = gtk.NewEntry()
a.settings.modelsDirectory = 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.previewThresholdDB = spin(-100, 0, 1, 1)
a.settings.vadThreshold = spin(0, 1, 0.01, 2) a.settings.vadThreshold = spin(0, 1, 0.01, 2)
a.settings.overlayEnabled = gtk.NewSwitch() a.settings.overlayEnabled = gtk.NewCheckButton()
a.settings.historyEnabled = gtk.NewSwitch() a.settings.historyEnabled = gtk.NewCheckButton()
a.settings.stdoutEnabled = gtk.NewSwitch() a.settings.stdoutEnabled = gtk.NewCheckButton()
a.settings.stderrEnabled = gtk.NewSwitch() a.settings.stderrEnabled = gtk.NewCheckButton()
a.settings.transcriptEnabled = gtk.NewSwitch() a.settings.transcriptEnabled = gtk.NewCheckButton()
a.settings.transcriptDirectory = gtk.NewEntry() a.settings.transcriptDirectory = gtk.NewEntry()
a.settings.overlayOpacity = spin(0, 1, 0.01, 2) 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.overlayMaxWidth = spin(100, 5000, 10, 0)
a.settings.overlayMonitor = gtk.NewEntry() a.settings.overlayMonitor = gtk.NewEntry()
a.settings.overlayFinalTimeout = 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.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.modelTimeout = gtk.NewEntry()
a.settings.modelShutdownTimeout = gtk.NewEntry() a.settings.modelShutdownTimeout = gtk.NewEntry()
} }
@@ -368,16 +367,13 @@ func (a *Application) connectSettingsChanged() {
control.ConnectChanged(changed) control.ConnectChanged(changed)
} }
a.settings.overlayFont.ConnectFontSet(changed) a.settings.overlayFont.ConnectFontSet(changed)
switches := []*gtk.Switch{ checks := []*gtk.CheckButton{
a.settings.startCaptions, a.settings.alwaysOnTop, a.settings.overlayEnabled, a.settings.startCaptions, a.settings.alwaysOnTop, a.settings.overlayEnabled,
a.settings.historyEnabled, a.settings.stdoutEnabled, a.settings.stderrEnabled, a.settings.historyEnabled, a.settings.stdoutEnabled, a.settings.stderrEnabled,
a.settings.transcriptEnabled, a.settings.overlayClickThrough, a.settings.modelAutoRestart, a.settings.transcriptEnabled, a.settings.overlayClickThrough, a.settings.modelAutoRestart,
} }
for _, control := range switches { for _, control := range checks {
control.ConnectStateSet(func(bool) bool { control.ConnectToggled(changed)
glib.IdleAdd(changed)
return false
})
} }
} }
@@ -435,6 +431,8 @@ func settingRow(title, description string, control gtk.Widgetter) *gtk.Box {
detail.SetWrap(true) detail.SetWrap(true)
text.Append(detail) text.Append(detail)
row.Append(text) row.Append(text)
widget := gtk.BaseWidget(control)
widget.SetVAlign(gtk.AlignCenter)
row.Append(control) row.Append(control)
return row return row
} }
+455 -136
View File
@@ -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 { window.captioneer-main {
background: #30372f; background: #3E4637;
color: #e0e5d7; color: #D8DED3;
font-size: 9.5pt;
} }
/* ------------------------------------------------------------------ */
/* Titlebar chrome */
/* ------------------------------------------------------------------ */
headerbar.captioneer-header { 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; min-height: 34px;
padding: 3px 8px 0; padding: 2px 6px;
background: #353d33; background-image: linear-gradient(to bottom, #454E3C, #333A2D);
border-bottom: 1px solid #171d18; border-bottom: 1px solid #242921;
box-shadow: inset 0 1px #6A7862;
color: #D8DED3;
} }
.captioneer-tabs button { headerbar.captioneer-header windowcontrols button {
min-height: 26px; min-height: 17px;
padding: 3px 16px; min-width: 19px;
border-radius: 2px 2px 0 0; padding: 1px 3px;
background-image: linear-gradient(to bottom, #56604d, #414a3d); margin: 0 1px;
border: 1px solid #1d241e; border-radius: 0;
border-top-color: #748069; background-image: none;
border-left-color: #68745e; background: #4C5844;
color: #c9d0bf; border: 1px solid #242921;
border-top-color: #6A7862;
border-left-color: #6A7862;
color: #B9C2AD;
box-shadow: none; 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 { .captioneer-tabs button:checked {
background: #515b49; background: #4C5844;
color: #f0f2ea; color: #C4B550;
border-bottom-color: #515b49; border-bottom-color: #4C5844;
} }
.captioneer-content { .captioneer-content {
padding: 10px; padding: 8px;
background: #515b49; background: #4C5844;
} }
/* ------------------------------------------------------------------ */
/* Sunken wells and text views */
/* ------------------------------------------------------------------ */
.inset-panel, .inset-panel,
textview, textview,
entry, entry,
spinbutton, spinbutton,
combobox, combobox button.combo,
fontbutton { fontbutton button {
background: #252c26; background-image: none;
color: #e0e5d7; background: #1E221B;
border: 1px solid #171d18; color: #D8DED3;
border-bottom-color: #748069; border: 1px solid #191D16;
border-right-color: #68745e; border-bottom-color: #6A7862;
border-right-color: #58644E;
border-radius: 1px; 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; 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 { 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:active,
button:checked { button:checked {
background: #354033; background: #3A4233;
border-top-color: #171d18; border-top-color: #242921;
border-left-color: #171d18; border-left-color: #242921;
border-bottom-color: #748069; border-bottom-color: #6A7862;
border-right-color: #68745e; border-right-color: #6A7862;
} }
button:disabled, button:disabled,
switch:disabled, checkbutton:disabled,
entry:disabled, entry:disabled,
spinbutton:disabled, spinbutton:disabled,
combobox:disabled { combobox:disabled {
color: #7f8977; color: #79826F;
background: #363d35; background: #414A38;
} }
button:focus-visible, button:focus-visible,
entry:focus-visible, entry:focus-visible,
spinbutton:focus-visible, spinbutton:focus-visible,
combobox:focus-visible, combobox:focus-visible,
switch:focus-visible, checkbutton:focus-visible,
textview:focus-visible { textview:focus-visible {
outline: 2px solid #d5dea7; outline: 1px dotted #C4B550;
outline-offset: -2px; outline-offset: -2px;
} }
.primary-action { .primary-action {
background-image: linear-gradient(to bottom, #899b5f, #637344); color: #C4B550;
color: #ffffff;
font-weight: 700; font-weight: 700;
letter-spacing: 1px;
border-top-color: #83917A;
border-left-color: #7E8C74;
}
.primary-action:hover {
color: #DED08A;
} }
.danger-action { .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 { .section-title {
color: #eef1e8; color: #C4B550;
font-size: 8.5pt;
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 1px;
} }
.description { .description {
color: #adb7a1; color: #8A9384;
font-size: 9.5pt; font-size: 8.5pt;
} }
.validation-error { .validation-error {
color: #e5a192; color: #D98D7A;
font-weight: 600; font-weight: 600;
font-size: 8.5pt;
} }
.warning-text { .warning-text {
color: #dfc078; color: #C79C4E;
} }
/* ------------------------------------------------------------------ */
/* Statusbar */
/* ------------------------------------------------------------------ */
.statusbar { .statusbar {
min-height: 25px; min-height: 23px;
padding: 3px 8px; padding: 3px 6px;
background: #343c32; background: #3E4637;
color: #adb7a1; border-top: 1px solid #242921;
border-top: 1px solid #748069; 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 { .settings-sidebar row {
min-width: 130px; min-width: 130px;
padding: 7px 10px; min-height: 24px;
border-radius: 1px; 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 { .settings-panel {
background: #454f41; background: #46513D;
border: 1px solid #1d241e; border: 1px solid #242921;
border-bottom-color: #58644E;
border-right-color: #58644E;
} }
.setting-row { .setting-row {
min-height: 48px; min-height: 36px;
padding: 6px 8px; padding: 4px 8px;
background: #4b5546; background: #4E5A45;
border: 1px solid #252c26; 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 { scrollbar slider {
min-width: 12px; min-width: 11px;
min-height: 12px; min-height: 24px;
margin: 1px;
border-radius: 1px; border-radius: 1px;
background: #68745e; background: #5C6850;
border: 1px solid #222921; 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;
} }
+24 -1
View File
@@ -13,6 +13,7 @@ package overlay
#include <gdk/wayland/gdkwayland.h> #include <gdk/wayland/gdkwayland.h>
#include <X11/Xlib.h> #include <X11/Xlib.h>
#include <X11/Xatom.h> #include <X11/Xatom.h>
#include <X11/Xutil.h>
#include <errno.h> #include <errno.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -188,10 +189,24 @@ static gboolean captioneer_reposition_x11(gpointer data) {
gdk_monitor_get_geometry(overlay->monitor, &geometry); gdk_monitor_get_geometry(overlay->monitor, &geometry);
int width = gdk_surface_get_width(surface); int width = gdk_surface_get_width(surface);
int height = gdk_surface_get_height(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 bottom_margin = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(overlay->window), "bottom-margin"));
int x = geometry.x + (geometry.width - width) / 2; int x = geometry.x + (geometry.width - width) / 2;
int y = geometry.y + geometry.height - height - bottom_margin; 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; return G_SOURCE_REMOVE;
} }
@@ -327,6 +342,11 @@ static CaptioneerOverlay *captioneer_overlay_new(
gtk_layer_init_for_window(overlay->window); gtk_layer_init_for_window(overlay->window);
gtk_layer_set_namespace(overlay->window, "captioneer"); gtk_layer_set_namespace(overlay->window, "captioneer");
gtk_layer_set_layer(overlay->window, GTK_LAYER_SHELL_LAYER_OVERLAY); 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_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_margin(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, bottom_margin);
gtk_layer_set_exclusive_zone(overlay->window, 0); gtk_layer_set_exclusive_zone(overlay->window, 0);
@@ -363,6 +383,9 @@ static CaptioneerOverlay *captioneer_overlay_new(
overlay->bubble = box; overlay->bubble = box;
gtk_widget_add_css_class(box, "captioneer-bubble"); gtk_widget_add_css_class(box, "captioneer-bubble");
gtk_widget_set_halign(box, GTK_ALIGN_CENTER); 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); gtk_widget_set_size_request(box, effective_width, -1);
overlay->final_label = gtk_label_new(""); overlay->final_label = gtk_label_new("");