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