//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 }