diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 363fb09..2bfefe6 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -157,6 +157,27 @@ приложение работает headless (UI=nil). Реальное окно полноценно проверяется на машине с MinGW-w64/gcc (`go build -ldflags ...`), здесь — только typecheck. +### 12.8. Структура UI-кода: компоненты-виджеты (рефакторинг) + +`internal/ui` (без Fyne) — контракты + Nil-реализации + юнит-тесты. Fyne-код — +только в `internal/ui/desktop/*`, один файл — одна панель. `desktop/window.go` +становится чистой компоновкой. + +Каждый компонент: +- **интерфейс** в `internal/ui/.go` (контракт для тестов/headless); +- **Nil-реализация** в том же файле (headless/тесты); +- **Fyne-реализация** в `internal/ui/desktop/.go`. + +### 12.9. Контракт виджета «Логи» (LogPanel) + +Панель «Логи» — поток системных логов (сырые строки из `events.LogLine`). + +- `Append(text)` — добавить строку лога; при превышении лимита старые строки + отбрасываются (буфер ограничен, не расти бесконечно). +- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (Scroll). +- Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из + горутины Hub. + --- ## Открытые пункты (TODO) diff --git a/internal/ui/desktop/log_panel.go b/internal/ui/desktop/log_panel.go new file mode 100644 index 0000000..001571d --- /dev/null +++ b/internal/ui/desktop/log_panel.go @@ -0,0 +1,49 @@ +//go:build cgo + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных логов. +// +// Внутри — widget.Label с ограниченным буфером (maxLogLen); Append обрезает +// строку до лимита. Вызовы Append приходят из горутин Hub, поэтому обновление +// виджета выполняется через fyne.Do (на потоке Fyne). +type LogPanel struct { + label *widget.Label +} + +// maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно. +const maxLogLen = 200_000 + +// NewLogPanel создаёт LogPanel. +func NewLogPanel() *LogPanel { + l := widget.NewLabel("") + l.Wrapping = fyne.TextWrapWord + return &LogPanel{label: l} +} + +// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»). +func (p *LogPanel) Widget() fyne.CanvasObject { + return container.NewScroll(p.label) +} + +// Append добавляет строку лога (спец 12.9), с ограничением буфера. +func (p *LogPanel) Append(text string) { + fyne.Do(func() { + s := p.label.Text + text + "\n" + if len(s) > maxLogLen { + s = s[len(s)-maxLogLen:] + } + p.label.SetText(s) + }) +} + +// compile-time проверка реализации контракта. +var _ ui.LogPanel = (*LogPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index cd21481..089cbe2 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -26,7 +26,7 @@ import ( ) const ( - maxLogLen = 200_000 // обрезка буферов панелей, чтобы не расти бесконечно + bufLimit = 200_000 // обрезка буферов панелей окна (conv/state), чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" @@ -52,8 +52,8 @@ type Window struct { statusLbl *widget.Label detailLbl *widget.Label convLbl *widget.Label - logsLbl *widget.Label stateLbl *widget.Label + logs *LogPanel input *widget.Entry hsplit *container.Split @@ -97,8 +97,7 @@ func (w *Window) build() { w.convLbl = widget.NewLabel("Выберите задачу.") w.convLbl.Wrapping = fyne.TextWrapWord - w.logsLbl = widget.NewLabel("") - w.logsLbl.Wrapping = fyne.TextWrapWord + w.logs = NewLogPanel() w.stateLbl = widget.NewLabel("") w.stateLbl.Wrapping = fyne.TextWrapWord @@ -149,7 +148,7 @@ func (w *Window) build() { // Нижняя панель: вкладки «Логи» + «Состояние». bottomTabs := container.NewAppTabs( - container.NewTabItem("Логи", container.NewScroll(w.logsLbl)), + container.NewTabItem("Логи", w.logs.Widget()), container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), ) @@ -303,26 +302,22 @@ func (w *Window) refreshList() { // appendConv добавляет строку в диалог (буфер ограничен). func (w *Window) appendConv(text string) { s := w.convLbl.Text + text + "\n\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] + if len(s) > bufLimit { + s = s[len(s)-bufLimit:] } w.convLbl.SetText(s) } -// appendLog добавляет строку в панель «Логи». +// appendLog добавляет строку в панель «Логи» (делегирует в LogPanel). func (w *Window) appendLog(text string) { - s := w.logsLbl.Text + text + "\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] - } - w.logsLbl.SetText(s) + w.logs.Append(text) } // appendState добавляет строку в панель «Состояние». func (w *Window) appendState(text string) { s := w.stateLbl.Text + text + "\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] + if len(s) > bufLimit { + s = s[len(s)-bufLimit:] } w.stateLbl.SetText(s) } @@ -450,7 +445,7 @@ func (w *Window) OnAgentActivity(e events.AgentActivity) { } func (w *Window) OnLog(e events.LogLine) { - fyne.Do(func() { w.appendLog(e.Text) }) + w.appendLog(e.Text) } func taskStatusLine(e events.TaskStatusChanged) string { diff --git a/internal/ui/log_panel.go b/internal/ui/log_panel.go new file mode 100644 index 0000000..65fafb0 --- /dev/null +++ b/internal/ui/log_panel.go @@ -0,0 +1,29 @@ +// 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 + +// LogPanel — панель «Логи» (спец 12.9): поток системных логов (сырые строки). +// +// Содержит строки лога в буфере ограниченного размера; при превышении лимита +// старые строки отбрасываются. Контейнер виджета отдаётся окну для встраивания +// в таб «Логи» — реализация (Fyne) сама владеет своим виджетом. +type LogPanel interface { + // Append добавляет строку лога. Реализация обязана перекладывать работу + // на поток Fyne (fyne.Do / fyne.DoAndWait), т.к. вызывается из горутин Hub. + Append(text string) +} + +// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов. +type NilLogPanel struct{} + +// NewNilLogPanel создаёт NilLogPanel. +func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} } + +func (NilLogPanel) Append(string) {} \ No newline at end of file diff --git a/internal/ui/log_panel_test.go b/internal/ui/log_panel_test.go new file mode 100644 index 0000000..5a84ec5 --- /dev/null +++ b/internal/ui/log_panel_test.go @@ -0,0 +1,12 @@ +package ui + +import "testing" + +// NilLogPanel — no-op контракт (спец 12.8): Append ничего не делает, безопасно +// вызывается из горутин Hub и из тестов без Fyne. +func TestNilLogPanelAppendNoop(t *testing.T) { + p := NewNilLogPanel() + p.Append("line 1") + p.Append("") + p.Append("line 3") +} \ No newline at end of file