176 lines
7.6 KiB
Go
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} }
|