Pad/internal/editor/logic.go
Greg Pomerantz 6a43e3c0db Add e2e test harness for testing editor logic without Gio display
- internal/test/e2e/: FrameCapture, Harness, ElementAssertions, helpers
- internal/editor/logic.go: Add done channel and Done() method for graceful shutdown
- internal/editor/mock_setup.go: Mock filesystem for tests
- internal/browser/: Browser layout and search logic
- internal/io/: Worker pool for async tasks
- Update architecture docs and spec
2026-05-31 08:56:30 -04:00

214 lines
5.7 KiB
Go

package editor
import (
"sync"
"pad/internal/browser"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
"pad/internal/ui"
)
// ConfigEvent represents a window configuration change (resize, orientation).
// PixelWidth and PixelHeight are the raw pixel dimensions from Gio.
type ConfigEvent struct {
PixelWidth int
PixelHeight int
}
// ScaleEvent represents a metric change (HiDPI scale factor).
type ScaleEvent struct {
Scale float32
}
// ConfigUpdate is a common interface for all configuration updates.
// Both ConfigEvent and ScaleEvent implement this interface.
type ConfigUpdate interface {
apply(*State)
}
func (e ConfigEvent) apply(s *State) {
s.PixelWidth = e.PixelWidth
s.PixelHeight = e.PixelHeight
}
func (e ScaleEvent) apply(s *State) {
s.SetScale(e.Scale)
}
// ResultEvent represents a completed async task result.
type ResultEvent struct {
// Future: add result fields here
}
// Logic runs the logic goroutine and provides channels for communication.
type Logic struct {
state *State
configChan chan ConfigUpdate
frameChan chan []ui.Element
inputChan chan []ui.InputEvent
lastLineYChan chan int // last line Y (Dp, sent as int) feedback from renderer
resultChan chan ResultEvent
searchQueryChan chan string // search text updates from main goroutine
workerPool *pool.WorkerPool
mockFS *mock.FileSystem
mu sync.Mutex
done chan struct{}
}
// NewLogic creates a new Logic instance.
func NewLogic() *Logic {
state := NewState()
TheState = state
// Initialize mock filesystem with sample data
mockFS := mock.NewFileSystem()
populateMockFileSystem(mockFS)
// Initialize worker pool
wp := pool.NewWorkerPool(4)
wp.Start()
// Set browser initial path to mock root
state.Browser.CurrentPath = "/"
return &Logic{
state: state,
configChan: make(chan ConfigUpdate),
frameChan: make(chan []ui.Element),
inputChan: make(chan []ui.InputEvent),
lastLineYChan: make(chan int),
resultChan: make(chan ResultEvent),
searchQueryChan: make(chan string),
workerPool: wp,
mockFS: mockFS,
done: make(chan struct{}),
}
}
// ConfigChan returns the unified config channel for the logic goroutine.
// Accepts ConfigEvent (size) and ScaleEvent (scale factor).
func (l *Logic) ConfigChan() chan<- ConfigUpdate {
return l.configChan
}
// FrameChan returns the frame channel for the logic goroutine.
func (l *Logic) FrameChan() <-chan []ui.Element {
return l.frameChan
}
// InputChan returns the input channel for the logic goroutine.
func (l *Logic) InputChan() chan<- []ui.InputEvent {
return l.inputChan
}
// DisplayLineChan returns the last line Y feedback channel.
func (l *Logic) DisplayLineChan() chan<- int {
return l.lastLineYChan
}
// ResultChan returns the result channel for the logic goroutine.
func (l *Logic) ResultChan() chan<- ResultEvent {
return l.resultChan
}
// SearchQueryChan returns the search query channel for the logic goroutine.
// The main goroutine sends updated search text here when it detects a change.
func (l *Logic) SearchQueryChan() chan<- string {
return l.searchQueryChan
}
// TheState is the global editor state, set once at startup.
var TheState *State
// Scale returns the current scale factor (pixels per DP).
func (l *Logic) Scale() float32 {
return l.state.Scale()
}
// Run runs the logic goroutine loop.
func (l *Logic) Run() {
// Dispatch initial directory index build on startup
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
for {
select {
case <-l.done:
return
case update := <-l.configChan:
update.apply(l.state)
l.frameChan <- l.state.layout()
case y := <-l.lastLineYChan:
if ui.Dp(y) != l.state.LastLineY {
l.state.LastLineY = ui.Dp(y)
l.frameChan <- l.state.layout()
}
case events := <-l.inputChan:
for _, evt := range events {
evt.Handler(evt.Data)
}
l.frameChan <- l.state.layout()
case query := <-l.searchQueryChan:
if query != l.state.Browser.Query {
l.state.Browser.Query = query
if l.state.page == BrowserPage {
browser.HandleSearch(&l.state.Browser, query)
}
}
l.frameChan <- l.state.layout()
case res := <-l.workerPool.ResultChan():
l.handleWorkerResult(res)
case <-l.resultChan:
l.frameChan <- l.state.layout()
}
}
}
// handleWorkerResult processes results from the worker pool.
func (l *Logic) handleWorkerResult(res pool.Result) {
switch res.TaskType {
case pool.TypeBuildIndex:
l.applyBuildIndexResult(res)
case pool.TypeLoadPages:
l.applyLoadPagesResult(res)
case pool.TypeReadDir:
l.applyReadDirResult(res)
}
l.frameChan <- l.state.layout()
}
// applyBuildIndexResult applies a completed BuildIndexTask result to browser state.
func (l *Logic) applyBuildIndexResult(res pool.Result) {
if !res.Success {
return
}
// Convert mock DirEntry results to browser entries
if entries, ok := res.Data.([]mock.DirEntry); ok {
l.state.Browser.TotalEntries = len(entries)
// Build the in-memory index with position maps
l.state.Browser.SortIndex = buildBrowserIndex(entries)
// Load initial visible pages from the index into the Pages map
browser.LoadInitialPages(&l.state.Browser)
}
}
// applyLoadPagesResult applies a completed LoadPagesTask result to browser state.
func (l *Logic) applyLoadPagesResult(res pool.Result) {
_ = res // TODO: implement page loading from worker results
}
// applyReadDirResult applies a completed ReadDirTask result to browser state.
func (l *Logic) applyReadDirResult(res pool.Result) {
_ = res // TODO: implement directory read result handling
}
// State returns the current state.
func (l *Logic) State() *State {
return l.state
}
// Done signals the logic goroutine to stop.
func (l *Logic) Done() {
close(l.done)
}