package editor import ( "log" "sort" "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 // Document content CursorPosition int // Byte offset GlyphLayout ui.GlyphLayout // Per-glyph layout from renderer SelectionStart int // -1 if no selection SelectionEnd int // -1 if no selection Dirty bool // Needs save CursorVisible bool // Blink state // UndoStack []EditCommand // Planned for later } // 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, }, } } 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) { TheState.Editor.Buffer = "Select a file to edit..." // Placeholder, should be loaded from file 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:] TheState.Editor.Dirty = true } // 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) TheState.Editor.Dirty = true } // 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-- TheState.Editor.Dirty = true } // 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 { return } // 1. Find the best line based on y bestLineY := layout.Y[0] minYDist := 1e9 for _, yVal := range layout.Y { yDist := float64(yVal) - y if yDist < 0 { yDist = -yDist } if yDist < minYDist { minYDist = yDist bestLineY = yVal } } // 2. Identify glyphs on this line and find the rightmost extent rightmostX := 0.0 rightmostIdx := -1 for i, yVal := range layout.Y { if yVal == bestLineY { xEnd := float64(layout.X[i] + layout.Advance[i]) if xEnd > rightmostX { rightmostX = xEnd rightmostIdx = i } } } // 3. Check if tap is to the right of the last character on this line if rightmostIdx != -1 && x > rightmostX { // If rightmostIdx is the last glyph of the entire document if rightmostIdx == len(layout.X)-1 { TheState.Editor.CursorPosition = len(TheState.Editor.Buffer) } else { // Position after the last character on this line TheState.Editor.CursorPosition = layout.ByteOffsets[rightmostIdx+1] } return } // 4. Otherwise, find the closest glyph on this line. bestIdx := -1 minDist := float64(1e9) for i, yVal := range layout.Y { if yVal == bestLineY { 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] } }