Files
Captioneer/src/gui/pages.go
T
kato 04ad4eb3d9
Tests / Go and GTK tests (push) Failing after 2m14s
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.
2026-07-17 15:34:50 +03:00

268 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build gtk
package gui
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"
)
func (a *Application) buildCaptionsPage() *gtk.Box {
page := gtk.NewBox(gtk.OrientationVertical, 7)
page.AddCSSClass("captioneer-content")
toolbar := gtk.NewBox(gtk.OrientationHorizontal, 5)
toolbar.AddCSSClass("toolbar")
toolbar.Append(labelWithClass("CAPTION HISTORY", "section-title"))
spacer := gtk.NewBox(gtk.OrientationHorizontal, 0)
spacer.SetHExpand(true)
toolbar.Append(spacer)
toolbar.Append(toolButton("Copy Selected", "Copy the selected caption text", func() {
copyBufferSelection(a.captionBuffer)
}))
toolbar.Append(toolButton("Copy Transcript", "Copy all final captions", func() {
gdk.DisplayGetDefault().Clipboard().SetText(a.history.Transcript())
}))
toolbar.Append(toolButton("Clear", "Clear visible caption history", a.history.Clear))
toolbar.Append(toolButton("Jump to Latest", "Resume at the newest caption", func() {
scrollTextViewToEnd(a.captionView, a.captionScroll)
}))
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)
a.captionView.SetWrapMode(gtk.WrapWordChar)
a.captionView.SetTopMargin(9)
a.captionView.SetBottomMargin(9)
a.captionView.SetLeftMargin(10)
a.captionView.SetRightMargin(10)
a.captionView.AddCSSClass("caption-view")
a.captionScroll = gtk.NewScrolledWindow()
a.captionScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
a.captionScroll.SetHExpand(true)
a.captionScroll.SetVExpand(true)
a.captionScroll.SetChild(a.captionView)
a.captionScroll.AddCSSClass("inset-panel")
page.Append(a.captionScroll)
provisional := gtk.NewBox(gtk.OrientationHorizontal, 8)
provisional.AddCSSClass("provisional-row")
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)
a.provisionalLabel.SetWrap(true)
a.provisionalLabel.SetHExpand(true)
provisional.Append(a.provisionalLabel)
page.Append(provisional)
return page
}
func (a *Application) buildConsolePage() *gtk.Box {
page := gtk.NewBox(gtk.OrientationVertical, 7)
page.AddCSSClass("captioneer-content")
toolbar := gtk.NewBox(gtk.OrientationHorizontal, 5)
toolbar.AddCSSClass("toolbar")
toolbar.Append(labelWithClass("APPLICATION CONSOLE", "section-title"))
spacer := gtk.NewBox(gtk.OrientationHorizontal, 0)
spacer.SetHExpand(true)
toolbar.Append(spacer)
toolbar.Append(toolButton("Copy Selected", "Copy selected console output", func() {
copyBufferSelection(a.consoleBuffer)
}))
toolbar.Append(toolButton("Copy All", "Copy all buffered console output", func() {
copyBuffer(a.consoleBuffer)
}))
toolbar.Append(toolButton("Clear", "Clear the console display", func() {
a.log.Clear()
a.renderConsole()
}))
toolbar.Append(toolButton("Jump to Latest", "Resume at the newest message", func() {
scrollTextViewToEnd(a.consoleView, a.consoleScroll)
}))
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)
a.consoleView.SetWrapMode(gtk.WrapWordChar)
a.consoleView.SetTopMargin(8)
a.consoleView.SetBottomMargin(8)
a.consoleView.SetLeftMargin(9)
a.consoleView.SetRightMargin(9)
a.consoleView.AddCSSClass("console-view")
a.consoleScroll = gtk.NewScrolledWindow()
a.consoleScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
a.consoleScroll.SetHExpand(true)
a.consoleScroll.SetVExpand(true)
a.consoleScroll.SetChild(a.consoleView)
a.consoleScroll.AddCSSClass("inset-panel")
page.Append(a.consoleScroll)
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 {
meta := fmt.Sprintf("%s [%s%s] ",
entry.CreatedAt.Format("15:04:05"),
formatCaptionTime(entry.StartedAt),
formatCaptionTime(entry.EndedAt),
)
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 {
a.provisionalLabel.SetText(snapshot.Provisional.Text)
}
if follow {
scrollTextViewToEnd(a.captionView, a.captionScroll)
}
}
func (a *Application) renderConsole() {
follow := isAtBottom(a.consoleScroll)
var text strings.Builder
var spans []tagSpan
offset := 0
for _, entry := range a.log.Entries() {
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+"]",
)
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)
}
}
func formatCaptionTime(value time.Duration) string {
seconds := value.Seconds()
return fmt.Sprintf("%02d:%05.2f", int(seconds)/60, seconds-float64(int(seconds)/60*60))
}
func isAtBottom(scroll *gtk.ScrolledWindow) bool {
if scroll == nil {
return true
}
adjustment := scroll.VAdjustment()
return adjustment.Value()+adjustment.PageSize() >= adjustment.Upper()-4
}
func scrollTextViewToEnd(view *gtk.TextView, scroll *gtk.ScrolledWindow) {
if view == nil || scroll == nil {
return
}
view.ScrollToIter(view.Buffer().EndIter(), 0, false, 0, 1)
adjustment := scroll.VAdjustment()
adjustment.SetValue(adjustment.Upper())
}
func copyBufferSelection(buffer *gtk.TextBuffer) {
if buffer == nil {
return
}
start, end, ok := buffer.SelectionBounds()
if !ok {
return
}
gdk.DisplayGetDefault().Clipboard().SetText(buffer.Text(start, end, true))
}
func copyBuffer(buffer *gtk.TextBuffer) {
if buffer == nil {
return
}
start, end := buffer.Bounds()
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)
label.SetXAlign(0)
return label
}
func toolButton(text, tooltip string, callback func()) *gtk.Button {
button := gtk.NewButtonWithLabel(text)
button.SetTooltipText(tooltip)
button.ConnectClicked(callback)
return button
}