194 lines
8.1 KiB
Go
194 lines
8.1 KiB
Go
//go:build cgo
|
||
|
||
package desktop
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
|
||
"fyne.io/fyne/v2"
|
||
"fyne.io/fyne/v2/container"
|
||
"fyne.io/fyne/v2/widget"
|
||
|
||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||
)
|
||
|
||
// maxLogBytes — кап суммарного объёма текста буфера «Логи» (байты), чтобы
|
||
// не расти бесконечно и не копить мегабайты в памяти.
|
||
const maxLogBytes = 10 * 1024 * 1024
|
||
|
||
// renderFlushMs — дебаунс-интервал перерисовки ленты (мс): при всплеске
|
||
// логов рендер выполняется не на каждую запись, а раз в этот интервал.
|
||
const renderFlushMs = 200
|
||
|
||
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных
|
||
// логов с фильтром по уровням (error/warning/info/debug).
|
||
//
|
||
// Записи хранятся в ui.LogBuffer (ограниченный буфер); лента показывает
|
||
// только записи уровней, отмеченных в фильтре (по умолчанию отмечены все).
|
||
// Фильтр — выпадающий список с чекбоксами: кнопка над лентой открывает
|
||
// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу
|
||
// перерисовывает ленту — и по уже загруженным записям, и для новых.
|
||
//
|
||
// Состояние (buf/selected/pending/flushScheduled/lastFlush) доступается только
|
||
// с потока Fyne: Append приходит из горутин Hub и заворачивается в fyne.Do,
|
||
// колбэки чекбоксов Fyne вызывает сам на своём потоке. Перерисовка ленты
|
||
// коалесцируется (см. scheduleFlush), т.к. строка ленты пересобирается
|
||
// целиком и при капе в 10MB на каждый append была бы дорогой.
|
||
type LogPanel struct {
|
||
label *widget.Label
|
||
btn *widget.Button // кнопка-дропдаун фильтра уровней
|
||
|
||
buf *ui.LogBuffer // буфер записей, старые вытесняются
|
||
selected map[string]bool // уровень → показывать (по умолчанию все)
|
||
|
||
pending bool // буфер изменился, рендер отложен (поток Fyne)
|
||
flushScheduled bool // фоновый флаш уже запущен (поток Fyne)
|
||
lastFlush int64 // мс последнего рендера (поток Fyne)
|
||
}
|
||
|
||
// NewLogPanel создаёт LogPanel с уровнями error/warning/info (debug скрыт).
|
||
func NewLogPanel() *LogPanel {
|
||
p := &LogPanel{
|
||
buf: ui.NewLogBuffer(maxLogBytes),
|
||
selected: make(map[string]bool, len(ui.LogLevels)),
|
||
}
|
||
for _, lvl := range ui.LogLevels {
|
||
p.selected[lvl] = lvl != ui.LogLevelDebug
|
||
}
|
||
|
||
p.label = widget.NewLabel("")
|
||
p.label.Wrapping = fyne.TextWrapWord
|
||
p.btn = widget.NewButton(p.filterCaption(), p.showFilterPopUp)
|
||
return p
|
||
}
|
||
|
||
// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»):
|
||
// строка фильтра сверху, лента лога в скролле снизу.
|
||
func (p *LogPanel) Widget() fyne.CanvasObject {
|
||
return container.NewBorder(
|
||
container.NewHBox(p.btn), nil, nil, nil,
|
||
container.NewScroll(p.label),
|
||
)
|
||
}
|
||
|
||
// Append добавляет запись лога (спец 12.9), с ограничением буфера.
|
||
// Вызывается из горутин Hub → вся работа на потоке Fyne через fyne.Do.
|
||
func (p *LogPanel) Append(level, text string) {
|
||
fyne.Do(func() {
|
||
lvl := ui.NormalizeLogLevel(level)
|
||
dropped := p.buf.Append(lvl, text)
|
||
// Перерисовка нужна, если запись видима или вытеснение задело ленту.
|
||
if p.selected[lvl] || dropped {
|
||
p.pending = true
|
||
p.scheduleFlush()
|
||
}
|
||
})
|
||
}
|
||
|
||
// scheduleFlush планирует отложенный рендер через renderFlushMs (или сразу,
|
||
// если с прошлого рендера прошло больше интервала). Все вызовы — с потока
|
||
// Fyne; фоновый поток только спит и по таймеру отправляет flush через fyne.Do,
|
||
// поэтому защита от повторного планирования (flushScheduled) работает без
|
||
// блокировок.
|
||
func (p *LogPanel) scheduleFlush() {
|
||
if p.flushScheduled {
|
||
return
|
||
}
|
||
p.flushScheduled = true
|
||
delay := int64(renderFlushMs) - (time.Now().UnixMilli() - p.lastFlush)
|
||
if delay < 1 {
|
||
delay = 1
|
||
}
|
||
go func() {
|
||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||
fyne.Do(p.flush)
|
||
}()
|
||
}
|
||
|
||
// flush выполняет отложенную перерисовку ленты (на потоке Fyne).
|
||
func (p *LogPanel) flush() {
|
||
p.flushScheduled = false
|
||
if p.pending {
|
||
p.pending = false
|
||
p.lastFlush = time.Now().UnixMilli()
|
||
p.render()
|
||
}
|
||
}
|
||
|
||
// render перерисовывает ленту: дописывает в label только новые видимые
|
||
// записи (RenderNew). Если вытеснение задело уже выведенные записи (stale),
|
||
// RenderNew возвращает полный текст — пересобираем label целиком. Так при
|
||
// росте ленты мимо лимита флаш дешёвый (только дельта), а полная пересборка
|
||
// происходит лишь по вытеснению/смене фильтра.
|
||
func (p *LogPanel) render() {
|
||
delta, full := p.buf.RenderNew(p.selected)
|
||
if full {
|
||
p.label.SetText(delta)
|
||
return
|
||
}
|
||
if delta == "" {
|
||
return
|
||
}
|
||
cur := p.label.Text
|
||
if cur == "" {
|
||
p.label.SetText(delta)
|
||
} else {
|
||
p.label.SetText(cur + delta)
|
||
}
|
||
}
|
||
|
||
// setLevel включает/выключает показ уровня и сразу обновляет ленту.
|
||
// Вызывается только с потока Fyne (колбэки чекбоксов). Смена набора уровней
|
||
// меняет видимость уже выведенных записей, поэтому — полный пересбор.
|
||
func (p *LogPanel) setLevel(level string, on bool) {
|
||
if p.selected[level] == on {
|
||
return
|
||
}
|
||
p.selected[level] = on
|
||
p.btn.SetText(p.filterCaption())
|
||
p.label.SetText(p.buf.Render(p.selected))
|
||
p.buf.ResetRendered()
|
||
}
|
||
|
||
// filterCaption — подпись кнопки-дропдауна: текущий выбранный набор уровней.
|
||
func (p *LogPanel) filterCaption() string {
|
||
var chosen []string
|
||
for _, lvl := range ui.LogLevels {
|
||
if p.selected[lvl] {
|
||
chosen = append(chosen, lvl)
|
||
}
|
||
}
|
||
switch {
|
||
case len(chosen) == len(ui.LogLevels):
|
||
return "Уровни: все"
|
||
case len(chosen) == 0:
|
||
return "Уровни: нет"
|
||
default:
|
||
return "Уровни: " + strings.Join(chosen, "+")
|
||
}
|
||
}
|
||
|
||
// showFilterPopUp открывает выпадающий список с чекбоксами уровней под
|
||
// кнопкой фильтра. Список пересобирается при каждом открытии — чекбоксы
|
||
// всегда отражают актуальное состояние фильтра.
|
||
func (p *LogPanel) showFilterPopUp() {
|
||
c := fyne.CurrentApp().Driver().CanvasForObject(p.btn)
|
||
if c == nil {
|
||
return // виджет ещё не встроен в окно
|
||
}
|
||
rows := make([]fyne.CanvasObject, 0, len(ui.LogLevels))
|
||
for _, lvl := range ui.LogLevels {
|
||
lvl := lvl
|
||
cb := widget.NewCheck(lvl, func(on bool) { p.setLevel(lvl, on) })
|
||
cb.SetChecked(p.selected[lvl])
|
||
rows = append(rows, cb)
|
||
}
|
||
pop := widget.NewPopUp(container.NewPadded(container.NewVBox(rows...)), c)
|
||
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(p.btn)
|
||
pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+p.btn.Size().Height))
|
||
}
|
||
|
||
// compile-time проверка реализации контракта.
|
||
var _ ui.LogPanel = (*LogPanel)(nil)
|