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.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: %s", v) switch v { case key.NameLeftArrow: HandleCursorMove(-1) case key.NameRightArrow: HandleCursorMove(1) case key.NameDeleteBackward: HandleBackspace() case key.NameDeleteForward: HandleDelete() } } } // 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, []ui.Interaction{ {Gesture: ui.Scroll, Handler: HandleScroll}, {Gesture: ui.KeyDown, Handler: HandleKeyDown}, }, ) // 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" // Position cursor from GlyphLayout. layout := TheState.Editor.GlyphLayout pos := TheState.Editor.CursorPosition lineHeight := EditorLineHeight() var cursorX, cursorY ui.Dp if len(layout.ByteOffsets) == 0 { // Empty buffer — place cursor at the top-left of the editor region. cursorX = editorRegion.X cursorY = editorRegion.Y } else { // Binary search for the glyph at or after the cursor byte offset. idx := sort.Search(len(layout.ByteOffsets), func(i int) bool { return layout.ByteOffsets[i] >= pos }) if idx < len(layout.ByteOffsets) { // Cursor is at a glyph position. cursorX = editorRegion.X + layout.X[idx] cursorY = editorRegion.Y - TheState.ScrollOffset + layout.Y[idx] - EditorFontSize } else { // Cursor is at end of buffer — place after last glyph. cursorX = editorRegion.X + layout.X[len(layout.X)-1] + layout.Advance[len(layout.Advance)-1] cursorY = editorRegion.Y - TheState.ScrollOffset + layout.Y[len(layout.Y)-1] - EditorFontSize } } // Derive line/col for bottom bar display (still using byte-based // approximation until GlyphLayout-based line tracking is added). line, col := byteOffsetToLineCol(TheState.Editor.Buffer, TheState.Editor.CursorPosition) // Create the cursor element at the computed position. // Ensure the cursor element knows its bounds for clipping. cursorElem := ui.NewCursor( "editor_cursor", ui.Region{X: cursorX, Y: cursorY, W: ui.Dp(2), H: lineHeight}, line, col, true, ) cursorElem.ClipRegion = editorRegion return []ui.Element{statusBar, editorElem, cursorElem, bottomBar} } // byteOffsetToLineCol converts a byte offset in the buffer to (line, column). // Both line and column are 0-indexed. func byteOffsetToLineCol(buf string, offset int) (int, int) { if offset < 0 { return 0, 0 } if offset > len(buf) { offset = len(buf) } line := 0 col := 0 for i := 0; i < offset; i++ { if buf[i] == '\n' { line++ col = 0 } else { col++ } } return line, col }