Pad/internal/editor/state.go

597 lines
17 KiB
Go

package editor
import (
"log"
"sort"
"time"
"unicode/utf8"
"pad/internal/browser"
"pad/internal/ui"
"gioui.org/io/key"
)
func init() {
ui.OpenFile = OpenFile
}
// EditorFontSize is the font size used for editor text.
const EditorFontSize = 14 // unit.Sp
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
const EditorLineHeightScale = 1.2
// EditorLineHeight returns the fixed line height in Dp for the editor font.
func EditorLineHeight() ui.Dp {
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
}
// Page identifies which page the app is showing.
type Page int
const (
BrowserPage Page = iota
EditorPage
)
// SortMode controls how the browser list is sorted.
type SortMode int
const (
SortByDateDesc SortMode = iota // default: newest first
SortByDateAsc
SortByNameAsc
SortByNameDesc
)
// EditorState holds all editor-specific state.
type EditorState struct {
Buffer string
CursorPosition int
GlyphLayout ui.GlyphLayout
SelectionStart int
SelectionEnd int
CursorVisible bool
Filename string
fileVersion map[string]int
lastWriteVersion map[string]int
saveTimer *time.Timer
writeFailed map[string]bool // Added: tracks failed writes for UI
retryAttempts map[string]int // Added: tracks retry attempts
}
// IsDirty, WriteFailed, and RetryAttempts are computed:
func (e *EditorState) IsDirty() bool {
bufVer := e.fileVersion[e.Filename]
writeVer := e.lastWriteVersion[e.Filename]
return bufVer > writeVer
}
func (e *EditorState) WriteFailed() bool {
return e.writeFailed[e.Filename]
}
// SetWriteFailed is used by the logic goroutine to update status.
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
e.writeFailed[filename] = failed
if !failed {
e.retryAttempts[filename] = 0 // Reset attempts on success
}
}
// IncrementRetryAttempts increments the attempt counter for a file.
func (e *EditorState) IncrementRetryAttempts(filename string) int {
e.retryAttempts[filename]++
return e.retryAttempts[filename]
}
// State holds all application state owned by the logic goroutine.
type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
page Page // current page (Browser or Editor)
WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
FocusedElementID string // ID of the currently focused element
Elems []ui.Element
// Browser state (directly embedded per architecture §8)
Browser browser.BrowserState // Embedded, not a pointer
// Editor state
Editor EditorState // New field
}
func NewState() *State {
return &State{
scale: 1.0,
page: BrowserPage, // Reverted to BrowserPage
Browser: *browser.NewBrowserState(),
Editor: EditorState{
CursorPosition: 0,
SelectionStart: -1,
SelectionEnd: -1,
fileVersion: make(map[string]int),
lastWriteVersion: make(map[string]int),
writeFailed: make(map[string]bool),
retryAttempts: make(map[string]int),
},
}
}
func (s *State) SetScale(scale float32) {
s.scale = scale
}
func (s *State) Scale() float32 {
return s.scale
}
// layout converts stored pixel dimensions to Dp using the current scale
// and computes the element tree. Called only when a frame is needed.
// Search query sync is handled by the logic goroutine via searchQueryChan,
// not here, to ensure proper channel-based state flow.
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
// Calculate VisibleCount before laying out the browser page.
// This ensures the browser shows entries based on the current viewport.
if s.PixelHeight > 0 && s.scale > 0 {
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
rowHeight := ui.Dp(48)
newVisibleCount := int(listAreaHeight / rowHeight)
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
s.Browser.VisibleCount = newVisibleCount
}
}
switch s.page {
case BrowserPage:
tapHandler := func(data any) {
if idx, ok := data.(int); ok {
browser.HandleBrowserTap(bm, &s.Browser, idx)
}
}
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
case EditorPage:
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
}
return s.Elems
}
// ToggleWordWrap toggles the word wrap setting.
func ToggleWordWrap(data any) {
TheState.WordWrap = !TheState.WordWrap
}
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
func HandleScroll(data any) {
delta := data.(int) // pixels
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
if TheState.ScrollOffset > TheState.MaxScroll {
TheState.ScrollOffset = TheState.MaxScroll
}
}
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
// The delta is in pixels (from gesture.Scroll.Update). Delegates to the browser package.
func HandleBrowserScroll(data any) {
delta := data.(int) // pixel delta from gesture.Scroll.Update
browser.HandlePixelScroll(&TheState.Browser, delta)
}
// GoToBrowser switches the app to the browser page.
func GoToBrowser(data any) {
TheState.page = BrowserPage
}
// GoToEditor switches the app to the editor page.
func GoToEditor(data any) {
TheState.page = EditorPage
}
// OpenFile sets the active filename and switches to the editor page.
// data is the filename string from the browser list.
func OpenFile(data any) {
filename := data.(string)
// Dispatch a request to load the file
// We use a non-blocking approach to avoid deadlock.
// The openFileChan is still used for communication, but we must
// ensure that the logic goroutine consumes it without holding locks
// that prevent this function from returning.
// Actually, the best way to avoid deadlock here is to NOT send on a channel
// directly, but instead update state in a way that the logic goroutine
// picks up on the next cycle, or use a separate goroutine to send the task.
go func() {
TheLogic.openFileChan <- filename
}()
TheState.Editor.Filename = filename
TheState.Editor.CursorPosition = 0 // Reset cursor to top
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
TheState.page = EditorPage
TheState.FocusedElementID = "editor_text" // Set focus to editor
}
// ToggleSortOrder cycles the browser sort mode through four modes.
func ToggleSortOrder(data any) {
// Cycle through the 4 sort modes
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
// Reset scroll on sort change
TheState.Browser.ScrollOffset = 0
// Clear cached pages so they reload with the new sort order
TheState.Browser.Pages = make(map[int]*browser.Page)
// Reload initial pages with the new sort order
browser.LoadInitialPages(&TheState.Browser)
// Recompute search results if there's an active search query
browser.HandleSortModeChange(&TheState.Browser)
}
// HandleCursorMove updates the cursor position within bounds.
func HandleCursorMove(delta int) {
newPos := TheState.Editor.CursorPosition + delta
if newPos < 0 {
newPos = 0
}
if newPos > len(TheState.Editor.Buffer) {
newPos = len(TheState.Editor.Buffer)
}
log.Printf("HandleCursorMove: old=%d, new=%d", TheState.Editor.CursorPosition, newPos)
TheState.Editor.CursorPosition = newPos
}
// HandleKeyDown interprets keyboard events for navigation and editing.
// Receives both key.Event (as key.Name) and key.EditEvent from the main loop.
func HandleKeyDown(data any) {
switch v := data.(type) {
case key.EditEvent:
// Text input from IME / keyboard
log.Printf("HandleKeyDown: EditEvent text=%q", v.Text)
HandleInsert(v.Text)
case key.Name:
log.Printf("HandleKeyDown: name=%q", v)
switch v {
case key.NameLeftArrow:
HandleCursorMove(-1)
case key.NameRightArrow:
HandleCursorMove(1)
case key.NameUpArrow:
HandleVerticalCursorMove(true)
case key.NameDownArrow:
HandleVerticalCursorMove(false)
case key.NameDeleteBackward:
HandleBackspace()
case key.NameDeleteForward:
HandleDelete()
case key.NameReturn:
HandleInsert("\n")
}
}
}
// HandleVerticalCursorMove updates the cursor position to the previous or next visual line.
func HandleVerticalCursorMove(up bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 {
return
}
pos := TheState.Editor.CursorPosition
// Find current glyph index.
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
// If idx == len, we are at the end. Use the last glyph.
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
currentX := layout.X[idx]
currentY := layout.Y[idx]
var targetIdx int = idx
if up {
// Scan backwards to find the previous line's Y.
prevY := currentY
for i := idx; i >= 0; i-- {
if layout.Y[i] < prevY {
prevY = layout.Y[i]
break
}
}
if prevY == currentY {
// Already at the top line?
return
}
// Now find the closest X in prevY.
bestDiff := float32(1e9)
for i := 0; i < len(layout.Y); i++ {
if layout.Y[i] == prevY {
diff := float32(layout.X[i] - currentX)
if diff < 0 {
diff = -diff
}
if diff < bestDiff {
bestDiff = diff
targetIdx = i
}
}
}
} else {
// Scan forwards to find the next line's Y.
nextY := currentY
for i := idx; i < len(layout.Y); i++ {
if layout.Y[i] > nextY {
nextY = layout.Y[i]
break
}
}
if nextY == currentY {
// Already at the bottom line?
return
}
// Now find the closest X in nextY.
bestDiff := float32(1e9)
for i := 0; i < len(layout.Y); i++ {
if layout.Y[i] == nextY {
diff := float32(layout.X[i] - currentX)
if diff < 0 {
diff = -diff
}
if diff < bestDiff {
bestDiff = diff
targetIdx = i
}
}
}
}
TheState.Editor.CursorPosition = layout.ByteOffsets[targetIdx]
}
// HandleDelete removes the character after the cursor.
func HandleDelete() {
pos := TheState.Editor.CursorPosition
buf := TheState.Editor.Buffer
if pos >= len(buf) {
return
}
TheState.Editor.Buffer = buf[:pos] + buf[pos+1:]
markDirty()
}
// HandleInsert inserts a string at the current cursor position.
func HandleInsert(s string) {
pos := TheState.Editor.CursorPosition
buf := TheState.Editor.Buffer
// Simple string concatenation for now
TheState.Editor.Buffer = buf[:pos] + s + buf[pos:]
TheState.Editor.CursorPosition += len(s)
markDirty()
}
// HandleBackspace removes the character before the cursor.
func HandleBackspace() {
pos := TheState.Editor.CursorPosition
if pos == 0 {
return
}
buf := TheState.Editor.Buffer
// Simple string slice
TheState.Editor.Buffer = buf[:pos-1] + buf[pos:]
TheState.Editor.CursorPosition--
markDirty()
}
func markDirty() {
TheLogic.markDirty()
}
// EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// Debug: ensure we are actually running this
// fmt.Printf("DEBUG: EditorLayout called\n")
margin := ui.Dp(10)
// --- Top bar: filename on row 1, icons on row 2 ---
statusBarRegion := ui.Region{
X: margin, Y: margin,
W: screenWidth - margin*2,
H: ui.Dp(52),
}
statusBarW := statusBarRegion.W
filename := "untitled.txt" // Needs to be managed in EditorState
statusBar := ui.NewContainer(
statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
// Row 1: filename
ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
// Row 2: back, cut, copy, paste icons
ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
ui.NewIcon("cut", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
ui.NewIcon("copy", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
ui.NewIcon("paste", ui.Region{X: ui.Dp(144), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
},
)
// --- Bottom bar ---
bottomBarHeight := ui.BottomBarHeight
bottomBarY := screenHeight - margin - bottomBarHeight
bottomBarRegion := ui.Region{
X: margin, Y: bottomBarY,
W: screenWidth - margin*2,
H: bottomBarHeight,
}
bottomBarW := bottomBarRegion.W
wrapText := "Wrap: Off"
if wordWrap {
wrapText = "Wrap: On"
}
bottomBar := ui.NewContainer(
bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
{Gesture: ui.Tap, Handler: ToggleWordWrap},
}),
},
)
// --- Editor text area ---
editorY := statusBarRegion.Y + statusBarRegion.H
editorH := bottomBarRegion.Y - editorY
editorRegion := ui.Region{
X: margin, Y: editorY,
W: screenWidth - margin*2,
H: editorH,
}
// Compute max scroll offset from the last line baseline reported by the renderer.
// lastLineY is the shaper's Y value for the last line's baseline.
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
maxScroll := TheState.LastLineY - editorRegion.H + EditorLineHeight()/2
if maxScroll < 0 {
maxScroll = 0
}
TheState.MaxScroll = maxScroll
// Add the TextField back in a way that passes the test.
editorElem := ui.NewTextField(
"editor_text",
TheState.Editor.Buffer,
editorRegion,
editorRegion.W,
TheState.ScrollOffset,
TheState.Editor.CursorPosition,
[]ui.Interaction{
{Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
{Gesture: ui.Tap, Handler: func(data any) {
if pt, ok := data.(ui.Point); ok {
// Convert window-space tap coordinates to text-local coordinates.
// layout.X is relative to the text region left, and layout.Y is relative to the text region top.
localX := float64(pt.X - editorRegion.X)
localY := float64(pt.Y - editorRegion.Y + TheState.ScrollOffset)
SetCursorFromPoint(localX, localY)
}
}},
},
)
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
// for Gio to deliver key events to this element.
editorElem.Focused = TheState.FocusedElementID == "editor_text"
return []ui.Element{statusBar, editorElem, bottomBar}
}
// SetCursorFromPoint updates the cursor position based on screen coordinates (Dp).
func SetCursorFromPoint(x, y float64) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
return
}
lineHeight := float64(EditorLineHeight())
// 1. Identify the intended line index based on y
// layout.Y values are relative to the text region origin.
// We need to account for scroll offset.
visualLine := int((y + float64(TheState.ScrollOffset)) / lineHeight)
// Group glyphs by their Y-baseline
type lineGroup struct {
y float64
indices []int
}
groups := []lineGroup{}
seenY := make(map[float64]int) // maps Y to group index
for i, yVal := range layout.Y {
yFloat := float64(yVal)
idx, ok := seenY[yFloat]
if !ok {
idx = len(groups)
groups = append(groups, lineGroup{y: yFloat, indices: []int{}})
seenY[yFloat] = idx
}
groups[idx].indices = append(groups[idx].indices, i)
}
// Sort groups by Y
sort.Slice(groups, func(i, j int) bool { return groups[i].y < groups[j].y })
// If visualLine is out of bounds, clamp
if visualLine < 0 {
visualLine = 0
}
if visualLine >= len(groups) {
visualLine = len(groups) - 1
}
targetGroup := groups[visualLine]
// 3. Identify rightmost extent on this line
rightmostX := 0.0
rightmostIdx := -1
for _, i := range targetGroup.indices {
xEnd := float64(layout.X[i] + layout.Advance[i])
if xEnd > rightmostX {
rightmostX = xEnd
rightmostIdx = i
}
}
// 4. Check if tap is to the right of the last character
if rightmostIdx != -1 && x > rightmostX {
// Position at the end of the line content, before any trailing newline.
start := layout.ByteOffsets[rightmostIdx]
r, size := utf8.DecodeRuneInString(TheState.Editor.Buffer[start:])
if r == '\n' {
TheState.Editor.CursorPosition = start
} else {
TheState.Editor.CursorPosition = start + size
}
return
}
// 5. Otherwise, find the closest glyph on this line.
bestIdx := -1
minDist := float64(1e9)
for _, i := range targetGroup.indices {
if bestIdx == -1 {
bestIdx = i
}
// Calculate distance to the glyph center
glyphCenterX := float64(layout.X[i] + layout.Advance[i]/2)
dist := glyphCenterX - x
if dist < 0 {
dist = -dist
}
if dist < minDist {
minDist = dist
bestIdx = i
}
}
if bestIdx != -1 {
TheState.Editor.CursorPosition = layout.ByteOffsets[bestIdx]
}
}