Спеца 12.13: интерфейс + Nil + чистые хелперы (StateText/TraceLine/ ActivityLine/TaskStatusText/StatusBadge/Itoa) в internal/ui/state_panel.go, Fyne-реализация в internal/ui/desktop/state_panel.go. window.go — чистая компоновка: все 5 панелей вынесены как контракты.
332 lines
8.5 KiB
Go
332 lines
8.5 KiB
Go
//go:build cgo
|
||
|
||
// Package desktop — Fyne-реализация окна UI (см. internal/ui).
|
||
//
|
||
// Сборка требует cgo (GLFW). На машинах без C-компилятора пакет не входит
|
||
// в сборку, и app работает в headless-режиме (newUIWindow → nil).
|
||
package desktop
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"strings"
|
||
|
||
"fyne.io/fyne/v2"
|
||
"fyne.io/fyne/v2/app"
|
||
"fyne.io/fyne/v2/container"
|
||
"fyne.io/fyne/v2/theme"
|
||
"fyne.io/fyne/v2/widget"
|
||
|
||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||
"github.com/kamelion/ratatoskr-go/internal/events"
|
||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||
)
|
||
|
||
const (
|
||
selectedPref = "task.selected"
|
||
splitHPref = "split.h"
|
||
splitVPref = "split.v"
|
||
)
|
||
|
||
// Window — Fyne-окно: chat.Channel + ui.View.
|
||
type Window struct {
|
||
fyneApp fyne.App
|
||
win fyne.Window
|
||
store ui.Store
|
||
commands *ui.Commands
|
||
|
||
handler chat.Handler
|
||
|
||
// состояние выбора (живёт в окне, спец 12.10)
|
||
selected int64
|
||
|
||
// виджеты
|
||
taskList *TaskListPanel
|
||
detail *TaskDetailPanel
|
||
chat *ChatPanel
|
||
state *StatePanel
|
||
logs *LogPanel
|
||
|
||
hsplit *container.Split
|
||
vsplit *container.Split
|
||
|
||
onQuit func()
|
||
}
|
||
|
||
// New создаёт окно. appID — идентификатор Fyne-приложения (Preferences).
|
||
func New(appID string, store ui.Store) *Window {
|
||
fa := app.NewWithID(appID)
|
||
w := &Window{
|
||
fyneApp: fa,
|
||
store: store,
|
||
}
|
||
w.win = fa.NewWindow("Ratatoskr")
|
||
w.win.SetFullScreen(true)
|
||
w.commands = ui.NewCommands(w.submitText)
|
||
w.build()
|
||
w.win.SetCloseIntercept(func() {
|
||
// Закрытие окна = сворачивание (спец 12.6): Core продолжает работать.
|
||
w.saveLayout()
|
||
w.win.Hide()
|
||
})
|
||
fa.Settings().SetTheme(theme.DarkTheme())
|
||
return w
|
||
}
|
||
|
||
// SetOnQuit задаёт колбэк полного выхода (кнопка «Завершить»).
|
||
func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
|
||
|
||
// build собирает виджеты и раскладку.
|
||
func (w *Window) build() {
|
||
w.detail = NewTaskDetailPanel()
|
||
|
||
w.chat = NewChatPanel()
|
||
w.chat.SetCommands(w.commands)
|
||
|
||
w.state = NewStatePanel()
|
||
|
||
w.logs = NewLogPanel()
|
||
|
||
// Список задач (слева)
|
||
w.taskList = NewTaskListPanel()
|
||
w.taskList.OnSelect(func(id int64) { w.selectTask(id) })
|
||
|
||
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
|
||
w.saveLayout()
|
||
if w.onQuit != nil {
|
||
w.onQuit()
|
||
}
|
||
})
|
||
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
|
||
|
||
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
|
||
details := w.detail.Widget()
|
||
right := container.NewVSplit(
|
||
container.NewScroll(details),
|
||
w.chat.Transcript(),
|
||
)
|
||
w.vsplit = right
|
||
|
||
// Нижняя панель: вкладки «Логи» + «Состояние».
|
||
bottomTabs := container.NewAppTabs(
|
||
container.NewTabItem("Логи", w.logs.Widget()),
|
||
container.NewTabItem("Состояние", w.state.Widget()),
|
||
)
|
||
|
||
bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs)
|
||
|
||
w.hsplit = container.NewHSplit(left, right)
|
||
|
||
root := container.NewVSplit(w.hsplit, bottom)
|
||
w.win.SetContent(root)
|
||
|
||
// Восстановление layout из Preferences.
|
||
pref := w.fyneApp.Preferences()
|
||
w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30))
|
||
w.vsplit.SetOffset(pref.FloatWithFallback(splitVPref, 0.45))
|
||
|
||
// Начальный снимок списка.
|
||
w.refreshList()
|
||
}
|
||
|
||
// saveLayout сохраняет текущие позиции сплитов в Preferences.
|
||
// Вызывается при сворачивании окна и перед полным выходом.
|
||
func (w *Window) saveLayout() {
|
||
pref := w.fyneApp.Preferences()
|
||
pref.SetFloat(splitHPref, w.hsplit.Offset)
|
||
pref.SetFloat(splitVPref, w.vsplit.Offset)
|
||
}
|
||
|
||
// 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()
|
||
t, err := w.store.GetTask(ctx, id)
|
||
if err != nil {
|
||
w.detail.ShowEmpty()
|
||
w.chat.Clear()
|
||
return
|
||
}
|
||
w.detail.ShowTask(t)
|
||
|
||
// Диалог: история + краткие трассы.
|
||
hist, err := w.store.GetHistory(ctx, id)
|
||
if err != nil {
|
||
hist = nil
|
||
}
|
||
var b strings.Builder
|
||
for _, h := range hist {
|
||
b.WriteString(ui.FormatRole(h.Role))
|
||
b.WriteString(h.Content)
|
||
b.WriteString("\n\n")
|
||
}
|
||
traces, err := w.store.GetTraces(ctx, id)
|
||
if err == nil {
|
||
for _, tr := range traces {
|
||
b.WriteString("— " + tr.Agent + " (" + string(tr.Status) + ")\n")
|
||
}
|
||
}
|
||
if b.Len() == 0 {
|
||
b.WriteString("Нет сообщений. /start — начать задачу.")
|
||
}
|
||
w.chat.SetTranscript(b.String())
|
||
|
||
// Состояние: сброс к снимку задач.
|
||
w.refreshState(ctx, id)
|
||
}
|
||
|
||
// refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы).
|
||
func (w *Window) refreshState(ctx context.Context, id int64) {
|
||
traces, err := w.store.GetTraces(ctx, id)
|
||
if err != nil {
|
||
return
|
||
}
|
||
w.state.SetText(ui.StateText(traces))
|
||
}
|
||
|
||
// refreshList перечитывает список задач из Store (снимок).
|
||
func (w *Window) refreshList() {
|
||
ctx := context.Background()
|
||
tasks, err := w.store.ListTasks(ctx)
|
||
if err != nil {
|
||
log.Printf("ui: list tasks: %v", err)
|
||
return
|
||
}
|
||
w.taskList.SetTasks(tasks)
|
||
w.taskList.Select(w.selected)
|
||
}
|
||
|
||
// appendLog добавляет строку в панель «Логи» (делегирует в LogPanel).
|
||
func (w *Window) appendLog(text string) {
|
||
w.logs.Append(text)
|
||
}
|
||
|
||
// submitText отправляет ввод пользователя в Router.
|
||
func (w *Window) submitText(text string) {
|
||
if w.handler != nil {
|
||
w.handler(chat.Incoming{
|
||
UserID: ui.UID,
|
||
Address: ui.Address,
|
||
Channel: w,
|
||
Msg: chat.Message{Text: text},
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---- chat.Channel ----
|
||
|
||
// OnMessage регистрирует обработчик входящих (Router).
|
||
func (w *Window) OnMessage(h chat.Handler) { w.handler = h }
|
||
|
||
// Run показывает окно и запускает Fyne-цикл. Блокирует до Quit.
|
||
func (w *Window) Run(ctx context.Context) error {
|
||
w.win.Show()
|
||
go func() {
|
||
<-ctx.Done()
|
||
w.fyneApp.Quit()
|
||
}()
|
||
w.fyneApp.Run()
|
||
return nil
|
||
}
|
||
|
||
// Send доставляет сообщение Core в диалог окна.
|
||
func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error {
|
||
text := m.Text
|
||
if len(m.Options) > 0 {
|
||
var sb strings.Builder
|
||
sb.WriteString(text)
|
||
for i, opt := range m.Options {
|
||
sb.WriteString("\n")
|
||
sb.WriteString(opt.Label)
|
||
_ = i
|
||
}
|
||
text = sb.String()
|
||
}
|
||
fyne.Do(func() { w.chat.Append("🤖 " + text) })
|
||
return nil
|
||
}
|
||
|
||
// Ask — вопрос с вариантами (отображается в диалоге).
|
||
func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
|
||
text := m.Text
|
||
for _, opt := range m.Options {
|
||
text += "\n" + opt.Label
|
||
}
|
||
fyne.Do(func() { w.chat.Append("❓ " + text) })
|
||
return nil
|
||
}
|
||
|
||
// Close завершает Fyne-цикл.
|
||
func (w *Window) Close() error {
|
||
w.fyneApp.Quit()
|
||
return nil
|
||
}
|
||
|
||
// ---- ui.View ----
|
||
|
||
// Все колбэки выполняются в горутинах events.Hub → обновляем UI через fyne.Do.
|
||
|
||
func (w *Window) OnTaskCreated(e events.TaskCreated) {
|
||
fyne.Do(func() {
|
||
w.refreshList()
|
||
if w.selected == 0 {
|
||
w.selectTask(e.ID)
|
||
}
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnTaskUpdated(e events.TaskUpdated) {
|
||
fyne.Do(func() {
|
||
w.refreshList()
|
||
if w.selected == e.ID {
|
||
w.selectTask(e.ID)
|
||
}
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnTaskDeleted(e events.TaskDeleted) {
|
||
fyne.Do(func() {
|
||
if w.selected == e.ID {
|
||
w.selected = 0
|
||
}
|
||
w.refreshList()
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) {
|
||
fyne.Do(func() {
|
||
w.refreshList()
|
||
if w.selected == e.ID {
|
||
w.selectTask(e.ID)
|
||
}
|
||
w.state.Append(ui.TaskStatusText(e))
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
|
||
fyne.Do(func() {
|
||
if w.selected == e.TaskID {
|
||
w.chat.Append(ui.FormatRole(e.Role) + e.Content)
|
||
}
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnTraceAppended(e events.TraceAppended) {
|
||
fyne.Do(func() {
|
||
w.state.Append(ui.TraceLine(e.Agent, e.Status))
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnAgentActivity(e events.AgentActivity) {
|
||
fyne.Do(func() {
|
||
w.state.Append(ui.ActivityLine(e.Agent, e.Stage))
|
||
})
|
||
}
|
||
|
||
func (w *Window) OnLog(e events.LogLine) {
|
||
w.appendLog(e.Text)
|
||
}
|