Files

176 lines
7.6 KiB
Go

// 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>.
// Detection patterns adapted from Python Fenrir (Chrys, Storm Dragon, and contributors).
//
// Package progress recognizes terminal progress without performing I/O.
package progress
import (
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
type Indicator struct {
Percentage float64
Interval time.Duration // zero for measured progress, otherwise activity
}
var (
percentPattern = regexp.MustCompile(`(?:^|[^\d.\-])(\d+(?:\.\d+)?)\s*%`)
statsPattern = regexp.MustCompile(`(?i)\b(?:humidity|cpu|memory|disk|usage|temp|weather|forecast)\b`)
fractionPattern = regexp.MustCompile(`\b(\d+)\s*(?:of|/)\s*(\d+)\b`)
fractionNoise = regexp.MustCompile(`(?i)\b(?:page|chapter|section|line|row|column|year|month|day)\b|\d{1,2}/\d{1,2}/\d{2,4}`)
barPattern = regexp.MustCompile(`\[[#=*>█▉▊▋▌▍▎▏▒▓░. \t-]{2,}\]|\|[#=*>█▉▊▋▌▍▎▏▒▓░. \t-]{2,}\|`)
urlPattern = regexp.MustCompile(`(?i)\S+://\S+|www\.[^\s]+\.[a-z]{2,}`)
ratePattern = regexp.MustCompile(`(?i)\b\d+(?:\.\d+)?\s*[kmgt](?:i?b)?/s\b`)
fieldPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9_-]*)=\s*\S+`)
timeField = regexp.MustCompile(`(?i)\b(?:time|elapsed|eta)=\s*\d+(?::\d{2}){1,2}(?:\.\d+)?\b`)
rateField = regexp.MustCompile(`(?i)\b(?:speed|fps|bitrate)=\s*[\d.]+[a-z/]*\b`)
transferPattern = regexp.MustCompile(`(?i)(?:remaining|elapsed|left|eta)[:;\s]*\d+s|\d+s\s+(?:remaining|elapsed|left)|(?:processing|generating|used|consumed)\s+\d+\s+tokens|\d+\s+bytes.*copied.*\d+\s+s.*[kmgt]?b/s|\d+(?:\.\d+)?\s+[kmgt]ib\s+\d+(?:\.\d+)?\s+[kmgt]ib/s\s+\d+:\d+|\d+\s+\d+[kmgt]?\s+\d+\s+\d+[kmgt]?.*\d+\.\d+[kmgt].*\d+:\d+:\d+`)
activityWords = `thinking|working|processing|analyzing|searching|reading|writing|planning|running|executing|updating|building|installing|compiling|downloading|reviewing|generating|responding|applying|fixing|editing|creating|preparing|checking|opening|loading|fetching|retrieving|scanning|indexing|summarizing`
activityPattern = regexp.MustCompile(`(?i)\b(?:loading|processing|working|installing|downloading|compiling|building)\b.*\.{2,}`)
interruptPattern = regexp.MustCompile(`(?i)(?:[*✢✽✶✻·•◦○●◆]\s+\S+|^\s*(?:` + activityWords + `)\b).*\([^)]*(?:esc|ctrl\+c) to interrupt[^)]*\)`)
bulletPattern = regexp.MustCompile(`(?i)^\s*[•◦]\s+.*(?:…|\.{3,}|\b(?:` + activityWords + `)\b)`)
thinkingPattern = regexp.MustCompile(`(?i)🔄[^\pL\d_]*(?:thinking|processing|working|analyzing)[^\d]*(\d+)s?\b`)
taskPattern = regexp.MustCompile(`(?i)[●○◉]\s+(?:task\s+)?(?:output|running|pending|working|processing)\s+[a-z0-9]+`)
promptPattern = regexp.MustCompile(`(?i)^\s*(?:[$#>]|\[.*\]\s*[\\$#>]|[a-z0-9._-]+[\\$#>]|\S+@\S+.*[$#>])\s*$|\?\s*\[(?:y/n|yes/no)\]\s*$|(?:\[sudo\]\s*password for .*|password)\s*:\s*$|^\s*(?:press any key to continue|are you sure\?).*$`)
curlPattern = regexp.MustCompile(`^\s*(\d+)\s+` + sizeField + `\s+(\d+)\s+` + sizeField + `\s+(\d+)\s+` + sizeField + `\s+` + sizeField + `\s+` + sizeField + `\s+` + clockField + `\s+` + clockField + `\s+` + clockField + `\s+` + sizeField + `(?:\s|$)`)
)
const sizeField = `\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?`
const clockField = `(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)`
func curl_percentage(text string) (float64, bool) {
match := curlPattern.FindStringSubmatch(text)
if match == nil {
return 0, false
}
total, _ := strconv.ParseFloat(match[1], 64)
received, _ := strconv.ParseFloat(match[2], 64)
uploaded, _ := strconv.ParseFloat(match[3], 64)
return total, total <= 100 && total == received && uploaded <= 100
}
func status_fields(text string) bool {
names := make(map[string]bool)
for _, match := range fieldPattern.FindAllStringSubmatch(text, -1) {
switch name := strings.ToLower(match[1]); name {
case "frame", "fps", "size", "time", "bitrate", "speed", "elapsed", "dup", "drop":
names[name] = true
}
}
return len(names) >= 3 && timeField.MatchString(text) && rateField.MatchString(text)
}
// Explicit rows are safe to inspect in large/multiline repaints. This follows
// Python's structural filters instead of accepting arbitrary numeric tables.
func explicit(text string) bool {
if urlPattern.MatchString(text) {
return false
}
if _, ok := curl_percentage(text); ok {
return true
}
return status_fields(text) || percentPattern.MatchString(text) && (barPattern.MatchString(text) || ratePattern.MatchString(text))
}
// Detect ports Python's screen-update detector. Prefer measured progress, then
// activity. Indeterminate indicators use the same pitch as measured 50%.
func Detect(text string) (Indicator, bool) {
if strings.ContainsAny(text, "\r\n") || urlPattern.MatchString(text) {
return Indicator{}, false
}
if match := percentPattern.FindStringSubmatch(text); match != nil {
value, _ := strconv.ParseFloat(match[1], 64)
if value <= 100 && !statsPattern.MatchString(text) {
return Indicator{Percentage: value}, true
}
return Indicator{}, false
}
if value, ok := curl_percentage(text); ok {
return Indicator{Percentage: value}, true
}
if transferPattern.MatchString(text) || status_fields(text) {
return activity(2 * time.Second), true
}
if match := fractionPattern.FindStringSubmatch(text); match != nil && !fractionNoise.MatchString(text) {
current, _ := strconv.ParseFloat(match[1], 64)
total, _ := strconv.ParseFloat(match[2], 64)
if total > 0 && total <= 1000 && current <= total {
return Indicator{Percentage: 100 * current / total}, true
}
}
if bar := barPattern.FindString(text); bar != "" {
content := []rune(bar)[1 : utf8.RuneCountInString(bar)-1]
filled, empty := 0.0, false
valid := true
for _, char := range content {
weight := 1.0
switch char {
case ' ', '\t', '.', '-', '░':
weight = 0
empty = true
case '▏':
weight = .125
case '▎':
weight = .25
case '▍':
weight = .375
case '▌':
weight = .5
case '▋':
weight = .625
case '▊':
weight = .75
case '▉':
weight = .875
}
if weight > 0 && empty {
valid = false
}
filled += weight
}
if valid && filled > 0 {
return Indicator{Percentage: 100 * filled / float64(len(content))}, true
}
}
if activityPattern.MatchString(text) {
return activity(2 * time.Second), true
}
if strings.ContainsAny(text, "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⡿⣟⣯⣷⣾⣽⣻⢿🌑🌒🌓🌔🌕🌖🌗🌘◐◓◒◑") || interruptPattern.MatchString(text) || bulletPattern.MatchString(text) {
return activity(time.Second), true
}
if match := thinkingPattern.FindStringSubmatch(text); match != nil {
seconds, _ := strconv.Atoi(match[1])
interval := 1500 * time.Millisecond
if seconds >= 10 {
interval = 2 * time.Second
}
return activity(interval), true
}
if taskPattern.MatchString(text) {
return activity(1500 * time.Millisecond), true
}
return Indicator{}, false
}
func activity(interval time.Duration) Indicator { return Indicator{Percentage: 50, Interval: interval} }