diff --git a/internal/ui/desktop/log_panel.go b/internal/ui/desktop/log_panel.go index 89fffc5..4ad485c 100644 --- a/internal/ui/desktop/log_panel.go +++ b/internal/ui/desktop/log_panel.go @@ -116,21 +116,39 @@ func (p *LogPanel) flush() { } } -// render перестраивает текст ленты из буфера с учётом выбранного набора -// уровней. Пустой набор → пустая лента (без ошибок). +// render перерисовывает ленту: дописывает в label только новые видимые +// записи (RenderNew). Если вытеснение задело уже выведенные записи (stale), +// RenderNew возвращает полный текст — пересобираем label целиком. Так при +// росте ленты мимо лимита флаш дешёвый (только дельта), а полная пересборка +// происходит лишь по вытеснению/смене фильтра. func (p *LogPanel) render() { - p.label.SetText(p.buf.Render(p.selected)) + 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 (колбэки чекбоксов). +// Вызывается только с потока 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.render() + p.label.SetText(p.buf.Render(p.selected)) + p.buf.ResetRendered() } // filterCaption — подпись кнопки-дропдауна: текущий выбранный набор уровней. diff --git a/internal/ui/log_panel.go b/internal/ui/log_panel.go index d2b21ad..953552b 100644 --- a/internal/ui/log_panel.go +++ b/internal/ui/log_panel.go @@ -87,6 +87,13 @@ type LogBuffer struct { start int // индекс первого «живого» элемента (ленивое вытеснение) total int // суммарный объём текста живых записей [start..], байты maxLen int // лимит суммарного объёма текста записей, байты + + // Инкрементальная отрисовка (RenderNew): rendered — сколько живых записей + // от начала уже выведено в текст ленты; stale — вытеснение задело уже + // выведенные записи, так что текст ленты нельзя дополнить дельтом и нужен + // полный пересбор. Эти поля поддерживаются только рендером. + rendered int + stale bool } // NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей. @@ -113,18 +120,36 @@ func (b *LogBuffer) Append(level, text string) bool { 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 @@ -141,12 +166,55 @@ func (b *LogBuffer) Entries() []LogEntry { // 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 := b.start; i < len(b.entries); i++ { + 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 diff --git a/internal/ui/log_panel_test.go b/internal/ui/log_panel_test.go index cc078bf..6a60f41 100644 --- a/internal/ui/log_panel_test.go +++ b/internal/ui/log_panel_test.go @@ -70,9 +70,9 @@ func allLevels() map[string]bool { func TestLogBufferRenderAllByDefault(t *testing.T) { b := NewLogBuffer(1000) b.Append("error", "e1") - b.Append("warn", "w1") // синоним → warning - b.Append("log", "i1") // неизвестный уровень → info - b.Append("trace", "d1") // синоним → debug + b.Append("warn", "w1") // синоним → warning + b.Append("log", "i1") // неизвестный уровень → info + b.Append("trace", "d1") // синоним → debug got := b.Render(allLevels()) want := "e1\nw1\ni1\nd1\n" @@ -229,3 +229,92 @@ func TestLogBufferBytesCap(t *testing.T) { t.Fatalf("oversized entry text = %q, want %q", entries[0].Text, "longtext") } } + +// Первый RenderNew отдаёт весь видимый текст, последующие — только новые +// записи (инкрементальная отрисовка: повторный вызов без Append пуст). +func TestLogBufferRenderNewDelta(t *testing.T) { + b := NewLogBuffer(1000) + sel := allLevels() + + b.Append(LogLevelError, "e1") + b.Append(LogLevelInfo, "i1") + text, full := b.RenderNew(sel) + if full { + t.Fatalf("первый RenderNew не должен требовать полного пересбора, got full") + } + if text != "e1\ni1\n" { + t.Fatalf("первый RenderNew = %q, want %q", text, "e1\ni1\n") + } + + // Без новых записей — пустой дельта. + text, full = b.RenderNew(sel) + if full || text != "" { + t.Fatalf("повторный RenderNew без новых записей = (%q, %v), want (\"\", false)", text, full) + } + + // Новые записи — только их текст, без повторной отдачи старых. + b.Append(LogLevelInfo, "i2") + text, _ = b.RenderNew(sel) + if text != "i2\n" { + t.Fatalf("дельта RenderNew = %q, want %q", text, "i2\n") + } +} + +// RenderNew применяет фильтр: невидимые уровни не попадают в дельту. +func TestLogBufferRenderNewFiltered(t *testing.T) { + b := NewLogBuffer(1000) + sel := map[string]bool{LogLevelError: true} + b.Append(LogLevelError, "e1") + b.Append(LogLevelInfo, "i1") + b.Append(LogLevelError, "e2") + + text, _ := b.RenderNew(sel) + if text != "e1\ne2\n" { + t.Fatalf("RenderNew(error only) = %q, want %q", text, "e1\ne2\n") + } +} + +// RenderNew уважает ResetRendered: после полного рендера новые записи +// добавляются дельтом, а не вытесняют уже выведенный текст. +func TestLogBufferRenderNewAfterReset(t *testing.T) { + b := NewLogBuffer(1000) + sel := allLevels() + b.Append(LogLevelInfo, "old") + b.Append(LogLevelInfo, "base") + + text, full := b.RenderNew(sel) + if full || text != "old\nbase\n" { + t.Fatalf("RenderNew = (%q, %v), want (\"old\\nbase\\n\", false)", text, full) + } + b.ResetRendered() // имитация полной пересборки ленты + + b.Append(LogLevelInfo, "new") + text, full = b.RenderNew(sel) + if full || text != "new\n" { + t.Fatalf("RenderNew после Reset = (%q, %v), want (\"new\\n\", false)", text, full) + } +} + +// Вытеснение уже выведенных записей помечает рендер как требующий полного +// пересбора (stale): дельта не может убрать верхние строки. +func TestLogBufferRenderNewEvictionStale(t *testing.T) { + b := NewLogBuffer(25) // записи по 10 байт + \n: помещаются 2, 3-я вытесняет 1-ю + sel := allLevels() + + b.Append(LogLevelInfo, "0123456789") + b.Append(LogLevelInfo, "0123456789") + text, full := b.RenderNew(sel) + if full || text != "0123456789\n0123456789\n" { + t.Fatalf("RenderNew = (%q, %v), want two lines", text, full) + } + + // Третья запись вытесняет первую (уже отрисованную) — stale. + b.Append(LogLevelInfo, "0123456789") + text, full = b.RenderNew(sel) + if !full { + t.Fatalf("RenderNew после вытеснения = (full=%v), want full=true", full) + } + if text != "0123456789\n0123456789\n" { + t.Fatalf("полный текст после вытеснения = %q, want последние две записи", text) + } +}