feat/ui-refactor #9
@@ -178,6 +178,19 @@
|
|||||||
- Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из
|
- Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из
|
||||||
горутины Hub.
|
горутины 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)
|
## Открытые пункты (TODO)
|
||||||
|
|||||||
78
internal/ui/desktop/task_list_panel.go
Normal file
78
internal/ui/desktop/task_list_panel.go
Normal file
@@ -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)
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
@@ -21,7 +20,6 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/events"
|
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/model"
|
"github.com/kamelion/ratatoskr-go/internal/model"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,13 +39,11 @@ type Window struct {
|
|||||||
|
|
||||||
handler chat.Handler
|
handler chat.Handler
|
||||||
|
|
||||||
// состояние списка
|
// состояние выбора (живёт в окне, спец 12.10)
|
||||||
mu sync.Mutex // защищает tasks
|
|
||||||
tasks []storage.Task
|
|
||||||
selected int64
|
selected int64
|
||||||
|
|
||||||
// виджеты
|
// виджеты
|
||||||
list *widget.List
|
taskList *TaskListPanel
|
||||||
titleLbl *widget.Label
|
titleLbl *widget.Label
|
||||||
statusLbl *widget.Label
|
statusLbl *widget.Label
|
||||||
detailLbl *widget.Label
|
detailLbl *widget.Label
|
||||||
@@ -103,31 +99,8 @@ func (w *Window) build() {
|
|||||||
w.stateLbl.Wrapping = fyne.TextWrapWord
|
w.stateLbl.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
// Список задач (слева)
|
// Список задач (слева)
|
||||||
w.list = widget.NewList(
|
w.taskList = NewTaskListPanel()
|
||||||
func() int { w.mu.Lock(); defer w.mu.Unlock(); return len(w.tasks) },
|
w.taskList.OnSelect(func(id int64) { w.selectTask(id) })
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
|
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
|
||||||
w.saveLayout()
|
w.saveLayout()
|
||||||
@@ -135,7 +108,7 @@ func (w *Window) build() {
|
|||||||
w.onQuit()
|
w.onQuit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
left := container.NewBorder(nil, quitBtn, nil, nil, w.list)
|
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
|
||||||
|
|
||||||
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
|
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
|
||||||
details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl)
|
details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl)
|
||||||
@@ -194,18 +167,10 @@ func (w *Window) saveLayout() {
|
|||||||
pref.SetFloat(splitVPref, w.vsplit.Offset)
|
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).
|
// selectTask загружает детали выбранной задачи (снимки из Store).
|
||||||
func (w *Window) selectTask(id int64) {
|
func (w *Window) selectTask(id int64) {
|
||||||
w.selected = id
|
w.selected = id
|
||||||
|
w.taskList.Select(id)
|
||||||
w.fyneApp.Preferences().SetInt(selectedPref, int(id))
|
w.fyneApp.Preferences().SetInt(selectedPref, int(id))
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -288,15 +253,8 @@ func (w *Window) refreshList() {
|
|||||||
log.Printf("ui: list tasks: %v", err)
|
log.Printf("ui: list tasks: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.mu.Lock()
|
w.taskList.SetTasks(tasks)
|
||||||
w.tasks = tasks
|
w.taskList.Select(w.selected)
|
||||||
selected := w.selected
|
|
||||||
w.mu.Unlock()
|
|
||||||
|
|
||||||
if w.list != nil {
|
|
||||||
w.list.Refresh()
|
|
||||||
}
|
|
||||||
_ = selected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendConv добавляет строку в диалог (буфер ограничен).
|
// appendConv добавляет строку в диалог (буфер ограничен).
|
||||||
|
|||||||
40
internal/ui/task_list_panel.go
Normal file
40
internal/ui/task_list_panel.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
40
internal/ui/task_list_panel_test.go
Normal file
40
internal/ui/task_list_panel_test.go
Normal file
@@ -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 не вызывает колбэки — нет виджета, но вызовы безопасны.
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user