// 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() }