- Fix cursor vertical alignment and ensure it respects ScrollOffset. - Fix scrolling in editor page by correctly registering scroll interactions in the renderer. - Ensure cursor is clipped to the editor text area to prevent drawing over status bars.
411 lines
13 KiB
Go
411 lines
13 KiB
Go
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
|
|
}
|