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:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -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 }
|
||||
Reference in new issue
Block a user