From 6274aad3696cd07fc36d0d6f20cf2b9d1a2a4b14 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:31:08 +0500 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=C2=BB=20(TaskListPanel)=20=D0=B8=D0=B7=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BD=D0=BE=D0=BB=D0=B8=D1=82=D0=B0=20=D0=BE=D0=BA=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеца 12.10: интерфейс + Nil + TaskTitle в internal/ui/task_list_panel.go, Fyne-реализация в internal/ui/desktop/task_list_panel.go. Состояние выбора живёт в окне; выбранная строка подсвечивается (Select/Unselect). --- docs/ui-spec.md | 13 +++++ internal/ui/desktop/task_list_panel.go | 78 ++++++++++++++++++++++++++ internal/ui/desktop/window.go | 58 +++---------------- internal/ui/task_list_panel.go | 40 +++++++++++++ internal/ui/task_list_panel_test.go | 40 +++++++++++++ 5 files changed, 179 insertions(+), 50 deletions(-) create mode 100644 internal/ui/desktop/task_list_panel.go create mode 100644 internal/ui/task_list_panel.go create mode 100644 internal/ui/task_list_panel_test.go diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 2bfefe6..117b8ee 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -178,6 +178,19 @@ - Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из горутины Hub. +### 12.10. Контракт виджета «Список задач» (TaskListPanel) + +Список задач — левая панель окна: все задачи (`storage.Task`) строками. + +- `SetTasks(tasks)` — заменить снимок списка и перерисовать виджет. Данные — + копии из Store (спец 12.5). +- `OnSelect(fn)` — колбэк клика по строке; окно грузит детали выбранной задачи + (это `task_detail`, отдельный компонент). Состояние выбора живёт в окне. +- Выбранная строка подсвечивается (`Select`/`Unselect`), окно управляет ею. +- Строка = `taskTitle(t)` (pure-функция, тестируется без Fyne). +- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`, панель сама + виджет не трогает из горутин Hub. + --- ## Открытые пункты (TODO) diff --git a/internal/ui/desktop/task_list_panel.go b/internal/ui/desktop/task_list_panel.go new file mode 100644 index 0000000..1daed2d --- /dev/null +++ b/internal/ui/desktop/task_list_panel.go @@ -0,0 +1,78 @@ +//go:build cgo + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/storage" + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// TaskListPanel — Fyne-реализация ui.TaskListPanel (спец 12.8, 12.10): список +// задач (левая панель окна). +// +// Внутри — widget.List над собственным буфером []storage.Task (снимки из Store). +// Состояние выбора живёт в окне: окно зовёт Select, чтобы подсветить строку. +type TaskListPanel struct { + list *widget.List + tasks []storage.Task +} + +// NewTaskListPanel создаёт TaskListPanel. +func NewTaskListPanel() *TaskListPanel { + p := &TaskListPanel{} + p.list = widget.NewList( + func() int { return len(p.tasks) }, + func() fyne.CanvasObject { + return widget.NewLabel("loading…") + }, + func(id widget.ListItemID, o fyne.CanvasObject) { + if id < 0 || id >= len(p.tasks) { + return + } + o.(*widget.Label).SetText(ui.TaskTitle(p.tasks[id])) + }, + ) + return p +} + +// Widget возвращает виджет списка для встраивания в окно (левая панель). +func (p *TaskListPanel) Widget() fyne.CanvasObject { + return p.list +} + +// SetTasks заменяет снимок списка и перерисовывает виджет (спец 12.10). +func (p *TaskListPanel) SetTasks(tasks []storage.Task) { + p.tasks = tasks + p.list.Refresh() +} + +// Select подсвечивает строку задачи id; если её нет — снимает выделение. +func (p *TaskListPanel) Select(id int64) { + idx := -1 + for i, t := range p.tasks { + if t.ID == id { + idx = i + break + } + } + if idx < 0 { + p.list.UnselectAll() + return + } + p.list.Select(widget.ListItemID(idx)) +} + +// OnSelect регистрирует колбэк клика по строке (спец 12.10). +func (p *TaskListPanel) OnSelect(fn func(id int64)) { + p.list.OnSelected = func(id widget.ListItemID) { + if fn != nil && id >= 0 && id < len(p.tasks) { + fn(p.tasks[id].ID) + } + } +} + +// compile-time проверка реализации контракта. +var _ ui.TaskListPanel = (*TaskListPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index 089cbe2..417d7fd 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -10,7 +10,6 @@ import ( "context" "log" "strings" - "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -21,7 +20,6 @@ import ( "github.com/kamelion/ratatoskr-go/internal/chat" "github.com/kamelion/ratatoskr-go/internal/events" "github.com/kamelion/ratatoskr-go/internal/model" - "github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/ui" ) @@ -41,13 +39,11 @@ type Window struct { handler chat.Handler - // состояние списка - mu sync.Mutex // защищает tasks - tasks []storage.Task + // состояние выбора (живёт в окне, спец 12.10) selected int64 // виджеты - list *widget.List + taskList *TaskListPanel titleLbl *widget.Label statusLbl *widget.Label detailLbl *widget.Label @@ -103,31 +99,8 @@ func (w *Window) build() { w.stateLbl.Wrapping = fyne.TextWrapWord // Список задач (слева) - w.list = widget.NewList( - func() int { w.mu.Lock(); defer w.mu.Unlock(); return len(w.tasks) }, - func() fyne.CanvasObject { - return widget.NewLabel("loading…") - }, - func(id widget.ListItemID, o fyne.CanvasObject) { - w.mu.Lock() - defer w.mu.Unlock() - if id < 0 || id >= len(w.tasks) { - return - } - t := w.tasks[id] - o.(*widget.Label).SetText(taskTitle(t)) - }, - ) - w.list.OnSelected = func(id widget.ListItemID) { - w.mu.Lock() - if id < 0 || id >= len(w.tasks) { - w.mu.Unlock() - return - } - t := w.tasks[id] - w.mu.Unlock() - w.selectTask(t.ID) - } + w.taskList = NewTaskListPanel() + w.taskList.OnSelect(func(id int64) { w.selectTask(id) }) quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() { w.saveLayout() @@ -135,7 +108,7 @@ func (w *Window) build() { w.onQuit() } }) - left := container.NewBorder(nil, quitBtn, nil, nil, w.list) + left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget()) // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl) @@ -194,18 +167,10 @@ func (w *Window) saveLayout() { pref.SetFloat(splitVPref, w.vsplit.Offset) } -// taskTitle — строка в списке задач. -func taskTitle(t storage.Task) string { - title := t.Title - if title == "" { - title = "(без названия)" - } - return title + "\n" + string(t.Status) -} - // selectTask загружает детали выбранной задачи (снимки из Store). func (w *Window) selectTask(id int64) { w.selected = id + w.taskList.Select(id) w.fyneApp.Preferences().SetInt(selectedPref, int(id)) ctx := context.Background() @@ -288,15 +253,8 @@ func (w *Window) refreshList() { log.Printf("ui: list tasks: %v", err) return } - w.mu.Lock() - w.tasks = tasks - selected := w.selected - w.mu.Unlock() - - if w.list != nil { - w.list.Refresh() - } - _ = selected + w.taskList.SetTasks(tasks) + w.taskList.Select(w.selected) } // appendConv добавляет строку в диалог (буфер ограничен). diff --git a/internal/ui/task_list_panel.go b/internal/ui/task_list_panel.go new file mode 100644 index 0000000..a5abcdc --- /dev/null +++ b/internal/ui/task_list_panel.go @@ -0,0 +1,40 @@ +package ui + +import ( + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskListPanel — панель «Список задач» (спец 12.10): все задачи строками. +// +// Данные — снимки-копии из Store (спец 12.5), заменяются через SetTasks. +// Состояние выбора живёт в окне: окно зовёт Select, чтобы подсветить строку. +// Контейнер виджета отдаётся окну — реализация (Fyne) сама владеет виджетом. +type TaskListPanel interface { + // SetTasks заменяет снимок списка и перерисовывает виджет. + SetTasks(tasks []storage.Task) + // Select подсвечивает строку задачи id (управляется окном). + Select(id int64) + // OnSelect регистрирует колбэк клика по строке (окно грузит детали). + OnSelect(fn func(id int64)) +} + +// NilTaskListPanel — no-op реализация TaskListPanel для headless-режима и тестов. +type NilTaskListPanel struct { + onSelect func(id int64) +} + +// NewNilTaskListPanel создаёт NilTaskListPanel. +func NewNilTaskListPanel() *NilTaskListPanel { return &NilTaskListPanel{} } + +func (p *NilTaskListPanel) SetTasks([]storage.Task) {} +func (p *NilTaskListPanel) Select(int64) {} +func (p *NilTaskListPanel) OnSelect(fn func(int64)) { p.onSelect = fn } + +// TaskTitle — строка задачи в списке (pure-функция, спец 12.10). +func TaskTitle(t storage.Task) string { + title := t.Title + if title == "" { + title = "(без названия)" + } + return title + "\n" + string(t.Status) +} \ No newline at end of file diff --git a/internal/ui/task_list_panel_test.go b/internal/ui/task_list_panel_test.go new file mode 100644 index 0000000..471cbca --- /dev/null +++ b/internal/ui/task_list_panel_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskTitle — строка в списке задач (спец 12.10), pure-функция без Fyne. +func TestTaskTitle(t *testing.T) { + empty := storage.Task{Status: storage.StatusReady} + got := TaskTitle(empty) + if !strings.Contains(got, "(без названия)") { + t.Errorf("пустое название: got %q, want «(без названия)»", got) + } + if !strings.Contains(got, string(storage.StatusReady)) { + t.Errorf("строка должна содержать статус, got %q", got) + } + + named := storage.Task{Title: "Тест", Status: storage.StatusRunning} + got = TaskTitle(named) + if !strings.Contains(got, "Тест") { + t.Errorf("название не попало в строку: %q", got) + } + if !strings.Contains(got, string(storage.StatusRunning)) { + t.Errorf("статус не попал в строку: %q", got) + } +} + +// NilTaskListPanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilTaskListPanelNoop(t *testing.T) { + p := NewNilTaskListPanel() + p.SetTasks([]storage.Task{{ID: 1}}) + p.Select(1) + + var got []int64 + p.OnSelect(func(id int64) { got = append(got, id) }) + // Nil не вызывает колбэки — нет виджета, но вызовы безопасны. +} \ No newline at end of file