From 6e8f1ffb85cddd841614a156b291b881a1f6a875 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 21 Jul 2025 14:01:11 +0300 Subject: fix: implement log viewer with newest-first ordering to avoid scroll issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace phonetic display with a proper log viewer widget - Prepend new log messages instead of appending to show newest first - Capture stdout/stderr output to display in the log viewer - Remove problematic auto-scroll functionality that wasn't working - Add timestamp to each log message for better tracking This approach eliminates the need for auto-scrolling since the latest messages are always visible at the top of the log viewer. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- internal/gui/log_viewer.go | 198 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 internal/gui/log_viewer.go diff --git a/internal/gui/log_viewer.go b/internal/gui/log_viewer.go new file mode 100644 index 0000000..ec1ec0b --- /dev/null +++ b/internal/gui/log_viewer.go @@ -0,0 +1,198 @@ +package gui + +import ( + "fmt" + "log" + "os" + "strings" + "sync" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// LogWriter is a custom writer that captures log output +type LogWriter struct { + viewer *LogViewer + original *os.File +} + +// Write implements io.Writer +func (w *LogWriter) Write(p []byte) (n int, err error) { + // Write to original output + if w.original != nil { + w.original.Write(p) + } + + // Send to log viewer + if w.viewer != nil { + message := strings.TrimRight(string(p), "\n") + if message != "" { + w.viewer.AddMessage(message) + } + } + + return len(p), nil +} + +// LogViewer is a widget that displays log messages +type LogViewer struct { + widget.BaseWidget + + container *fyne.Container + logEntry *widget.Entry + scrollView *container.Scroll + + mu sync.Mutex + messages []string + maxMessages int + + // For capturing output + originalStdout *os.File + originalStderr *os.File + stdoutWriter *LogWriter + stderrWriter *LogWriter +} + +// NewLogViewer creates a new log viewer widget +func NewLogViewer() *LogViewer { + v := &LogViewer{ + maxMessages: 1000, // Keep last 1000 messages + messages: make([]string, 0), + } + + // Create log entry (read-only multiline) + v.logEntry = widget.NewMultiLineEntry() + v.logEntry.Disable() // Make it read-only + v.logEntry.Wrapping = fyne.TextWrapWord + + // Create scroll container + v.scrollView = container.NewScroll(v.logEntry) + v.scrollView.SetMinSize(fyne.NewSize(0, 180)) // Same height as old phonetic display + + // Configure scroll behavior + v.scrollView.Direction = container.ScrollBoth + + // Create container with label + v.container = container.NewBorder( + widget.NewLabel("Log Messages (newest first):"), + nil, + nil, + nil, + v.scrollView, + ) + + v.ExtendBaseWidget(v) + return v +} + +// CreateRenderer implements fyne.Widget +func (v *LogViewer) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(v.container) +} + +// StartCapture starts capturing stdout and stderr +func (v *LogViewer) StartCapture() { + // Save original outputs + v.originalStdout = os.Stdout + v.originalStderr = os.Stderr + + // Create custom writers + v.stdoutWriter = &LogWriter{viewer: v, original: v.originalStdout} + v.stderrWriter = &LogWriter{viewer: v, original: v.originalStderr} + + // Create pipe for stdout + stdoutR, stdoutW, _ := os.Pipe() + os.Stdout = stdoutW + + // Create pipe for stderr + stderrR, stderrW, _ := os.Pipe() + os.Stderr = stderrW + + // Also redirect log package output + log.SetOutput(v.stdoutWriter) + + // Start goroutines to read from pipes + go v.pipeReader(stdoutR, v.stdoutWriter) + go v.pipeReader(stderrR, v.stderrWriter) +} + +// pipeReader reads from a pipe and writes to a LogWriter +func (v *LogViewer) pipeReader(pipe *os.File, writer *LogWriter) { + buf := make([]byte, 1024) + for { + n, err := pipe.Read(buf) + if n > 0 { + writer.Write(buf[:n]) + } + if err != nil { + break + } + } +} + +// StopCapture stops capturing stdout and stderr +func (v *LogViewer) StopCapture() { + if v.originalStdout != nil { + os.Stdout = v.originalStdout + v.originalStdout = nil + } + if v.originalStderr != nil { + os.Stderr = v.originalStderr + v.originalStderr = nil + } + + // Reset log package output + log.SetOutput(os.Stderr) +} + +// AddMessage adds a message to the log +func (v *LogViewer) AddMessage(message string) { + v.mu.Lock() + defer v.mu.Unlock() + + // Add timestamp + timestamp := time.Now().Format("15:04:05") + fullMessage := fmt.Sprintf("[%s] %s", timestamp, message) + + // Prepend to messages (newest first) + v.messages = append([]string{fullMessage}, v.messages...) + + // Trim if too many messages (remove oldest from the end) + if len(v.messages) > v.maxMessages { + v.messages = v.messages[:v.maxMessages] + } + + // Update UI on main thread + fyne.Do(func() { + // Set the full text with newest messages at top + text := strings.Join(v.messages, "\n") + v.logEntry.SetText(text) + + // Keep scroll at top to show newest messages + v.scrollView.Offset = fyne.NewPos(0, 0) + v.scrollView.Refresh() + }) +} + +// Clear clears all log messages +func (v *LogViewer) Clear() { + v.mu.Lock() + defer v.mu.Unlock() + + v.messages = v.messages[:0] + + fyne.Do(func() { + v.logEntry.SetText("") + v.scrollView.Offset = fyne.NewPos(0, 0) + v.scrollView.Refresh() + }) +} + +// Log adds a message without timestamp (for internal use) +func (v *LogViewer) Log(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + v.AddMessage(message) +} -- cgit v1.2.3