Files
Captioneer/src/gui/pages.go
T

268 lines
8.4 KiB
Go
Raw Normal View History

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