After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package progress
import (
"slices"
"strings"
"time"
"git.stormux.org/storm/fenrir/internal/diagnostic"
"git.stormux.org/storm/fenrir/internal/screen"
)
// Monitor owns detection state on the reader loop. No goroutines, subprocesses,
// whole-screen strings, or regexp compilation are needed for an update.
type Monitor struct {
active bool
pending *Indicator
last Indicator
lastAt time.Time
}
func (m *Monitor) Reset() { *m = Monitor{} }
// Update ignores editingRow when a screen-confirmed input deletion changed it.
// Pass -1 when no row should be excluded. Other output remains eligible.
func (m *Monitor) Update(previous, next screen.Snapshot, editingRow int) {
reason := "session-change"
changedCount, retainedCount, candidateCount := 0, 0, 0
if diagnostic.Enabled() {
defer func() {
diagnostic.Record("progress detection", "reason", reason, "changed_rows", changedCount, "retained_rows", retainedCount, "candidate_rows", candidateCount)
}()
}
if !next.Active || !previous.Active || previous.Terminal != next.Terminal || previous.Generation != next.Generation || previous.Rows != next.Rows || previous.Cols != next.Cols || next.Rows == 0 || next.Cols == 0 {
m.Reset()
return
}
reason = "prompt"
if promptPattern.MatchString(next.Text(next.Y)) || promptPattern.MatchString(next.Text(next.Rows-1)) {
m.Reset()
return
}
reason = "cursor-jump"
if next.Y != previous.Y && (abs(next.Y-previous.Y) > 2 || abs(next.X-previous.X) > 20) {
m.Reset()
return
}
// A cursor move over unchanged text is navigation, not ongoing work.
reason = "unchanged"
if slices.Equal(previous.Cells, next.Cells) {
if previous.X != next.X || previous.Y != next.Y {
m.Reset()
}
return
}
// Scrolling moves old output through many rows. Remove the retained
// overlap before applying size/multiline filters to genuinely new output.
var changedRows []int
for row := range next.Rows {
if !slices.Equal(previous.Line(row), next.Line(row)) {
changedRows = append(changedRows, row)
}
}
changedCount = len(changedRows)
scrollStart, scrollEnd := 0, 0
if len(changedRows) > 1 {
scrollStart, scrollEnd = screen.Scrolled_overlap(previous, next)
}
var lines []string
length := 0
for _, row := range changedRows {
if row == editingRow {
continue
}
if row >= scrollStart && row < scrollEnd {
retainedCount++
continue
}
line := next.Text(row)
// Erased rows are not new multiline output (common after a scroll).
if strings.TrimSpace(line) == "" {
continue
}
lines = append(lines, line)
length += len([]rune(line))
}
reason = "no-pattern"
if editingRow >= 0 && len(lines) == 0 {
reason = "editing"
}
var selected Indicator
found := false
for _, line := range lines {
if (length > 200 || len(lines) > 1) && !explicit(line) {
reason = "repaint-filter"
continue
}
candidateCount++
if value, ok := Detect(strings.TrimSpace(line)); ok {
selected, found = value, true
}
}
if !found {
m.Reset()
return
}
// Replace pending output even when throttled. The final value is flushed by
// the loop's existing timer if the program finishes between two audio ticks.
reason = "measured"
if selected.Interval != 0 {
reason = "activity"
}
m.active = true
m.pending = &selected
}
func (m *Monitor) Next(now time.Time) (float64, bool) {
if m.pending == nil {
return 0, false
}
value := *m.pending
interval := value.Interval
if interval == 0 {
interval = 100 * time.Millisecond
}
if !m.lastAt.IsZero() && now.Sub(m.lastAt) < interval {
return 0, false
}
m.pending = nil
if value.Interval == 0 && !m.lastAt.IsZero() && value == m.last {
return 0, false
}
m.last, m.lastAt = value, now
return value.Percentage, true
}
func abs(value int) int {
if value < 0 {
return -value
}
return value
}
func (m *Monitor) Active() bool { return m.active }