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 } // IsSaving returns true if a save is pending. func (e *EditorState) IsSaving() bool { return e.saveTimer != nil } // 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") case key.NameHome: HandleHome() case key.NameEnd: HandleEnd() case key.NamePageUp: HandlePageUpDown(true) case key.NamePageDown: HandlePageUpDown(false) } } } // HandleHome moves the cursor to the start of the current visual line. func HandleHome() { 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(layout.ByteOffsets) { idx = len(layout.ByteOffsets) - 1 } currentY := layout.Y[idx] // Find first glyph on currentY. targetIdx := idx for i := idx; i >= 0; i-- { if layout.Y[i] == currentY { targetIdx = i } else { break } } TheState.Editor.CursorPosition = layout.ByteOffsets[targetIdx] } // HandleEnd moves the cursor to the end of the current visual line. func HandleEnd() { 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(layout.ByteOffsets) { idx = len(layout.ByteOffsets) - 1 } currentY := layout.Y[idx] // Find last glyph on currentY. targetIdx := idx for i := idx; i < len(layout.Y); i++ { if layout.Y[i] == currentY { targetIdx = i } else { break } } // Position after the last character of the line. // If it's a newline, it's the newline itself. start := layout.ByteOffsets[targetIdx] r, size := utf8.DecodeRuneInString(TheState.Editor.Buffer[start:]) if r == '\n' { TheState.Editor.CursorPosition = start } else { TheState.Editor.CursorPosition = start + size } } // HandlePageUpDown scrolls and moves the cursor. func HandlePageUpDown(up bool) { // For now, simple scrolling. Cursor movement could be added later. pageSize := TheState.MaxScroll / 4 // Or some fraction if pageSize < EditorLineHeight() { pageSize = EditorLineHeight() } if up { TheState.ScrollOffset -= pageSize if TheState.ScrollOffset < 0 { TheState.ScrollOffset = 0 } } else { TheState.ScrollOffset += pageSize if TheState.ScrollOffset > TheState.MaxScroll { TheState.ScrollOffset = TheState.MaxScroll } } } 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 := TheState.Editor.Filename if filename == "" { filename = "untitled.txt" } 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" } statusText := "Saved" if TheState.Editor.IsSaving() { statusText = "Saving..." } else if TheState.Editor.WriteFailed() { statusText = "Error" } else if TheState.Editor.IsDirty() { statusText = "Modified" } bottomBar := ui.NewContainer( bottomBarRegion, ui.Color{R: 230, G: 230, B: 230, A: 255}, []ui.Element{ ui.NewLabel(statusText, 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: y is passed as relative to the text region top + scroll offset. // So y is the position in the *content*. visualLine := int(y / lineHeight) log.Printf("SetCursorFromPoint: y=%f, scroll=%f, visualLine=%d, lineHeight=%f", y, float64(TheState.ScrollOffset), visualLine, lineHeight) // Group glyphs by their Y-baseline type lineGroup struct { y float64 indices []int } groups := []lineGroup{} // Find all unique baseline Ys // The tap Y is based on line height (top of line). // We need to associate Y-baseline with visual line index. // Create map from visual line (0, 1, 2...) to baseline Y. // Since line height is fixed: // Line 0 baseline is at some Y0. // Line 1 baseline is at Y0 + lineHeight. // Let's find Y0 first. minY := 1e9 for _, yVal := range layout.Y { if float64(yVal) < minY { minY = float64(yVal) } } // Now group by baseline groups = []lineGroup{} // Reset groups for i, yVal := range layout.Y { yFloat := float64(yVal) lineIdx := int((yFloat - minY) / lineHeight + 0.5) // round to nearest line if lineIdx < 0 { lineIdx = 0 } // Ensure enough groups for len(groups) <= lineIdx { groups = append(groups, lineGroup{ y: minY + float64(len(groups))*lineHeight, indices: []int{}, }) } groups[lineIdx].indices = append(groups[lineIdx].indices, i) } // If visualLine is out of bounds, clamp if visualLine < 0 { visualLine = 0 } if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 { // Clamp to last valid group that has indices for i := len(groups) - 1; i >= 0; i-- { if len(groups[i].indices) > 0 { visualLine = i break } } } if len(groups[visualLine].indices) == 0 { return } 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 } } log.Printf("TargetGroup: Y=%f, Indices=%v, rightmostIdx=%d, x=%f, rightmostX=%f", targetGroup.y, targetGroup.indices, rightmostIdx, x, rightmostX) // 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 } log.Printf("Checking glyph %d: x=%f, centerX=%f, dist=%f", i, x, glyphCenterX, dist) if dist < minDist { minDist = dist bestIdx = i } } log.Printf("BestIdx=%d, ByteOffset=%d", bestIdx, layout.ByteOffsets[bestIdx]) if bestIdx != -1 { TheState.Editor.CursorPosition = layout.ByteOffsets[bestIdx] log.Printf("After SetCursor: CursorPosition=%d", TheState.Editor.CursorPosition) } }