227 lines
11 KiB
Go
227 lines
11 KiB
Go
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
|
||
//
|
||
// Слой обмена с ядром: однонаправленный поток (спец 12.2–12.5).
|
||
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
|
||
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
|
||
// - Подписчик шины (Controller) получает события и перекладывает их в View
|
||
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
|
||
//
|
||
// Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация.
|
||
package ui
|
||
|
||
import "strings"
|
||
|
||
// Канонические уровни записей лога для фильтра панели «Логи» (спец 12.9).
|
||
const (
|
||
LogLevelError = "error"
|
||
LogLevelWarning = "warning"
|
||
LogLevelInfo = "info"
|
||
LogLevelDebug = "debug"
|
||
)
|
||
|
||
// LogLevels — уровни в порядке их вывода в фильтре виджета «Логи».
|
||
var LogLevels = []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
|
||
|
||
// NormalizeLogLevel приводит уровень записи лога к одному из канонических
|
||
// уровней (LogLevels). Синонимы маппятся на ближайший уровень (err/fatal →
|
||
// error, warn → warning, trace → debug); пустые и неизвестные значения
|
||
// ("log" от events.LogWriter и всё прочее) считаются информационными.
|
||
func NormalizeLogLevel(level string) string {
|
||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||
case LogLevelError, "err", "fatal":
|
||
return LogLevelError
|
||
case LogLevelWarning, "warn":
|
||
return LogLevelWarning
|
||
case LogLevelDebug, "trace":
|
||
return LogLevelDebug
|
||
default:
|
||
return LogLevelInfo
|
||
}
|
||
}
|
||
|
||
// LogPanel — панель «Логи» (спец 12.9): поток системных логов с уровнями.
|
||
//
|
||
// Хранит записи (уровень + строка) в буфере ограниченного размера; при
|
||
// превышении лимита старые записи отбрасываются. Реализация обязана
|
||
// поддерживать фильтр по уровням: выпадающий список с чекбоксами
|
||
// (error/warning/info/debug), по умолчанию отмечены все уровни; показываются
|
||
// только записи выбранных уровней, изменение набора сразу обновляет ленту.
|
||
// Контейнер виджета отдаётся окну для встраивания в таб «Логи» — реализация
|
||
// (Fyne) сама владеет своим виджетом.
|
||
type LogPanel interface {
|
||
// Append добавляет запись лога: level — уровень из events.LogLine
|
||
// (нормализуется через NormalizeLogLevel), text — строка лога.
|
||
// Реализация обязана перекладывать работу на поток Fyne (fyne.Do /
|
||
// fyne.DoAndWait), т.к. вызывается из горутин Hub.
|
||
Append(level, text string)
|
||
}
|
||
|
||
// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов.
|
||
type NilLogPanel struct{}
|
||
|
||
// NewNilLogPanel создаёт NilLogPanel.
|
||
func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} }
|
||
|
||
func (NilLogPanel) Append(string, string) {}
|
||
|
||
// LogEntry — запись буфера панели «Логи»: канонический уровень + строка.
|
||
type LogEntry struct {
|
||
Level string
|
||
Text string
|
||
}
|
||
|
||
// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом)
|
||
// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь
|
||
// инкрементирует start, поэтому не копирует хвост массива на каждую
|
||
// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко.
|
||
const compactThreshold = 256
|
||
|
||
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
|
||
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
|
||
// LogPanel и тестируется в internal/ui.
|
||
//
|
||
// Не синхронизирована: потокобезопасность обеспечивает владелец (все вызовы
|
||
// на потоке Fyne).
|
||
type LogBuffer struct {
|
||
entries []LogEntry
|
||
start int // индекс первого «живого» элемента (ленивое вытеснение)
|
||
total int // суммарный объём текста живых записей [start..], байты
|
||
maxLen int // лимит суммарного объёма текста записей, байты
|
||
|
||
// Инкрементальная отрисовка (RenderNew): rendered — сколько живых записей
|
||
// от начала уже выведено в текст ленты; stale — вытеснение задело уже
|
||
// выведенные записи, так что текст ленты нельзя дополнить дельтом и нужен
|
||
// полный пересбор. Эти поля поддерживаются только рендером.
|
||
rendered int
|
||
stale bool
|
||
}
|
||
|
||
// NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей.
|
||
func NewLogBuffer(maxLen int) *LogBuffer {
|
||
return &LogBuffer{maxLen: maxLen}
|
||
}
|
||
|
||
// Append добавляет запись (уровень нормализуется через NormalizeLogLevel,
|
||
// хвостовые переводы строк срезаются). При превышении лимита старые записи
|
||
// вытесняются. Сообщает, было ли вытеснение/обрезка — владелец должен
|
||
// перерисовать ленту, даже если новая запись не видима.
|
||
func (b *LogBuffer) Append(level, text string) bool {
|
||
b.entries = append(b.entries, LogEntry{
|
||
Level: NormalizeLogLevel(level),
|
||
Text: strings.TrimRight(text, "\r\n"),
|
||
})
|
||
b.total += len(b.entries[len(b.entries)-1].Text) + 1
|
||
return b.trim()
|
||
}
|
||
|
||
// trim удерживает суммарный объём текста живых записей в пределах maxLen,
|
||
// лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита
|
||
// обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка.
|
||
func (b *LogBuffer) trim() bool {
|
||
dropped := false
|
||
live := len(b.entries) - b.start
|
||
evicted := 0
|
||
for b.total > b.maxLen && live > 1 {
|
||
b.total -= len(b.entries[b.start].Text) + 1
|
||
b.start++
|
||
live--
|
||
evicted++
|
||
dropped = true
|
||
}
|
||
// Если вытеснена хотя бы одна уже выведенная запись (b.rendered), текст
|
||
// ленты устарел: дельта не может убрать верхние строки, нужен полный
|
||
// пересбор. rendered при этом уменьшается на число вытесненных.
|
||
if evicted > 0 && b.rendered > 0 {
|
||
b.stale = true
|
||
}
|
||
if evicted >= b.rendered {
|
||
b.rendered = 0
|
||
} else {
|
||
b.rendered -= evicted
|
||
}
|
||
// Компакция «мёртвых» записей пачкой, а не на каждом append.
|
||
if b.start >= compactThreshold {
|
||
b.entries = b.entries[b.start:]
|
||
b.start = 0
|
||
}
|
||
if n := len(b.entries); n > b.start && len(b.entries[n-1].Text) > b.maxLen {
|
||
// Обрезка последней записи меняет уже выведенный текст, если она
|
||
// была отрисована.
|
||
if b.rendered == n-b.start {
|
||
b.stale = true
|
||
}
|
||
b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:]
|
||
b.total = b.maxLen + 1
|
||
dropped = true
|
||
}
|
||
return dropped
|
||
}
|
||
|
||
// Entries возвращает копию списка живых записей буфера.
|
||
func (b *LogBuffer) Entries() []LogEntry {
|
||
out := make([]LogEntry, len(b.entries)-b.start)
|
||
copy(out, b.entries[b.start:])
|
||
return out
|
||
}
|
||
|
||
// Render строит текст ленты только из живых записей уровней, отмеченных в
|
||
// selected (уровень → показывать). Пустой или nil набор → пустая лента.
|
||
// Полный рендер: используется для первичной отрисовки и после смены фильтра.
|
||
func (b *LogBuffer) Render(selected map[string]bool) string {
|
||
if selected == nil {
|
||
return ""
|
||
}
|
||
return b.renderFrom(b.start, selected)
|
||
}
|
||
|
||
// ResetRendered помечает все живые записи как уже выведенные. Зовётся после
|
||
// полного рендера (SetText(Render)), чтобы следующий RenderNew вернул только
|
||
// новые записи.
|
||
func (b *LogBuffer) ResetRendered() {
|
||
b.rendered = len(b.entries) - b.start
|
||
b.stale = false
|
||
}
|
||
|
||
// RenderNew отдаёт текст ещё не выведенных видимых записей и флаг, требует
|
||
// ли слой полного пересбора ленты. Если вытеснение задело уже выведенные
|
||
// записи (stale), возвращаемый текст — полный рендер с нуля (и флаг true), а
|
||
// не дельта. Иначе — только текст новых записей, которым можно дополнить
|
||
// текущий текст ленты.
|
||
func (b *LogBuffer) RenderNew(selected map[string]bool) (text string, full bool) {
|
||
if selected == nil {
|
||
return "", false
|
||
}
|
||
live := len(b.entries) - b.start
|
||
if b.stale {
|
||
b.stale = false
|
||
b.rendered = live
|
||
return b.renderFrom(b.start, selected), true
|
||
}
|
||
from := b.start + b.rendered
|
||
var s strings.Builder
|
||
for i := from; i < len(b.entries); i++ {
|
||
e := b.entries[i]
|
||
if !selected[e.Level] {
|
||
continue
|
||
}
|
||
s.WriteString(e.Text)
|
||
s.WriteByte('\n')
|
||
}
|
||
b.rendered = live
|
||
return s.String(), false
|
||
}
|
||
|
||
// renderFrom строит текст записей, начиная с индекса from, с учётом selected.
|
||
func (b *LogBuffer) renderFrom(from int, selected map[string]bool) string {
|
||
var s strings.Builder
|
||
for i := from; i < len(b.entries); i++ {
|
||
e := b.entries[i]
|
||
if !selected[e.Level] {
|
||
continue
|
||
}
|
||
s.WriteString(e.Text)
|
||
s.WriteByte('\n')
|
||
}
|
||
return s.String()
|
||
}
|