diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 117b8ee..4b68738 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -191,6 +191,19 @@ - Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`, панель сама виджет не трогает из горутин Hub. +### 12.11. Контракт виджета «Детали задачи» (TaskDetailPanel) + +Детали выбранной задачи — верх `right`-сплита: заголовок, статус, детали +(цель + репозитории). + +- `ShowTask(t)` — рендер из снимка `storage.Task`: заголовок (bold), статус + (italic), детали = `TaskDetailText(t)`. +- `ShowEmpty()` — сброс к placeholder («—» / пусто) при недоступной задаче. +- Строка деталей = pure `TaskDetailText(t)` (цель + репо), тестируется без Fyne. +- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`. +- Диалог и «Состояние» — отдельные панели (chat_panel, state_panel), здесь + не участвуют; окно оркестрирует загрузку всех панелей по выбору задачи. + --- ## Открытые пункты (TODO) diff --git a/internal/ui/desktop/task_detail_panel.go b/internal/ui/desktop/task_detail_panel.go new file mode 100644 index 0000000..9931972 --- /dev/null +++ b/internal/ui/desktop/task_detail_panel.go @@ -0,0 +1,54 @@ +//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/storage" + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// TaskDetailPanel — Fyne-реализация ui.TaskDetailPanel (спец 12.8, 12.11): +// детали выбранной задачи (заголовок, статус, детали) — верх right-сплита. +type TaskDetailPanel struct { + titleLbl *widget.Label + statusLbl *widget.Label + detailLbl *widget.Label +} + +// NewTaskDetailPanel создаёт TaskDetailPanel. +func NewTaskDetailPanel() *TaskDetailPanel { + p := &TaskDetailPanel{} + p.titleLbl = widget.NewLabel("—") + p.titleLbl.TextStyle = fyne.TextStyle{Bold: true} + p.statusLbl = widget.NewLabel("") + p.statusLbl.TextStyle = fyne.TextStyle{Italic: true} + p.detailLbl = widget.NewLabel("") + p.detailLbl.Wrapping = fyne.TextWrapWord + return p +} + +// Widget возвращает контейнер панели для встраивания в окно (верх right-сплита). +func (p *TaskDetailPanel) Widget() fyne.CanvasObject { + return container.NewVBox(p.titleLbl, p.statusLbl, p.detailLbl) +} + +// ShowTask рендерит детали из снимка задачи (спец 12.11). +func (p *TaskDetailPanel) ShowTask(t storage.Task) { + p.titleLbl.SetText(t.Title) + p.statusLbl.SetText(string(t.Status)) + p.detailLbl.SetText(ui.TaskDetailText(t)) +} + +// ShowEmpty сбрасывает к placeholder при недоступной задаче. +func (p *TaskDetailPanel) ShowEmpty() { + p.titleLbl.SetText("—") + p.statusLbl.SetText("") + p.detailLbl.SetText("") +} + +// compile-time проверка реализации контракта. +var _ ui.TaskDetailPanel = (*TaskDetailPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index 417d7fd..ebedeca 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -44,9 +44,7 @@ type Window struct { // виджеты taskList *TaskListPanel - titleLbl *widget.Label - statusLbl *widget.Label - detailLbl *widget.Label + detail *TaskDetailPanel convLbl *widget.Label stateLbl *widget.Label logs *LogPanel @@ -83,12 +81,7 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn } // build собирает виджеты и раскладку. func (w *Window) build() { - w.titleLbl = widget.NewLabel("—") - w.titleLbl.TextStyle = fyne.TextStyle{Bold: true} - w.statusLbl = widget.NewLabel("") - w.statusLbl.TextStyle = fyne.TextStyle{Italic: true} - w.detailLbl = widget.NewLabel("") - w.detailLbl.Wrapping = fyne.TextWrapWord + w.detail = NewTaskDetailPanel() w.convLbl = widget.NewLabel("Выберите задачу.") w.convLbl.Wrapping = fyne.TextWrapWord @@ -111,7 +104,7 @@ func (w *Window) build() { left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget()) // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). - details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl) + details := w.detail.Widget() convScroll := container.NewScroll(w.convLbl) right := container.NewVSplit( container.NewScroll(details), @@ -176,19 +169,11 @@ func (w *Window) selectTask(id int64) { ctx := context.Background() t, err := w.store.GetTask(ctx, id) if err != nil { - w.titleLbl.SetText("—") - w.statusLbl.SetText("") - w.detailLbl.SetText("") + w.detail.ShowEmpty() w.convLbl.SetText("") return } - w.titleLbl.SetText(t.Title) - w.statusLbl.SetText(string(t.Status)) - detail := "Цель: " + t.Goal - if len(t.Repos) > 0 { - detail += "\nРепозитории: " + strings.Join(t.Repos, ", ") - } - w.detailLbl.SetText(detail) + w.detail.ShowTask(t) // Диалог: история + краткие трассы. hist, err := w.store.GetHistory(ctx, id) diff --git a/internal/ui/task_detail.go b/internal/ui/task_detail.go new file mode 100644 index 0000000..be37e26 --- /dev/null +++ b/internal/ui/task_detail.go @@ -0,0 +1,37 @@ +package ui + +import ( + "strings" + + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskDetailPanel — панель «Детали задачи» (спец 12.11): заголовок, статус, +// детали (цель + репозитории) выбранной задачи. +// +// Данные — снимки-копии из Store (спец 12.5). Обновление — на потоке Fyne; +// вызывающий уже внутри fyne.Do. Диалог и «Состояние» — отдельные панели. +type TaskDetailPanel interface { + // ShowTask рендерит детали из снимка задачи. + ShowTask(t storage.Task) + // ShowEmpty сбрасывает к placeholder («—» / пусто) при недоступной задаче. + ShowEmpty() +} + +// NilTaskDetailPanel — no-op реализация TaskDetailPanel для headless-режима и тестов. +type NilTaskDetailPanel struct{} + +// NewNilTaskDetailPanel создаёт NilTaskDetailPanel. +func NewNilTaskDetailPanel() *NilTaskDetailPanel { return &NilTaskDetailPanel{} } + +func (NilTaskDetailPanel) ShowTask(storage.Task) {} +func (NilTaskDetailPanel) ShowEmpty() {} + +// TaskDetailText — строка деталей задачи (pure-функция, спец 12.11). +func TaskDetailText(t storage.Task) string { + detail := "Цель: " + t.Goal + if len(t.Repos) > 0 { + detail += "\nРепозитории: " + strings.Join(t.Repos, ", ") + } + return detail +} \ No newline at end of file diff --git a/internal/ui/task_detail_test.go b/internal/ui/task_detail_test.go new file mode 100644 index 0000000..fad7a10 --- /dev/null +++ b/internal/ui/task_detail_test.go @@ -0,0 +1,36 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskDetailText — строка деталей задачи (спец 12.11), pure-функция без Fyne. +func TestTaskDetailText(t *testing.T) { + noRepos := storage.Task{Goal: "Понять код"} + got := TaskDetailText(noRepos) + if got != "Цель: Понять код" { + t.Errorf("без repos: got %q", got) + } + if strings.Contains(got, "Репозитории") { + t.Errorf("без repos не должно быть репозиториев: %q", got) + } + + withRepos := storage.Task{Goal: "Рефакторинг", Repos: []string{"a", "b"}} + got = TaskDetailText(withRepos) + if !strings.Contains(got, "Цель: Рефакторинг") { + t.Errorf("цель не попала: %q", got) + } + if !strings.Contains(got, "Репозитории: a, b") { + t.Errorf("репозитории не попали: %q", got) + } +} + +// NilTaskDetailPanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilTaskDetailPanelNoop(t *testing.T) { + p := NewNilTaskDetailPanel() + p.ShowTask(storage.Task{ID: 1, Goal: "x"}) + p.ShowEmpty() +} \ No newline at end of file