Pad/internal/editor/state.go

499 lines
19 KiB
Go

package editor
import (
"sort"
"strings"
"gioui.org/widget"
"pad/internal/ui"
)
func init() {
ui.OpenFile = OpenFile
}
// SampleText is a static lorem-ipsum text used for display-only testing.
// Roughly 3 KB, filling a few pages of the editor.
const SampleText = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architectus qui exercitationem ullam corporis suscipit dolorum et saepe fugiat. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
Neque porro consequatur autem velbeat viciis quam autem voluptas minus odio voluptatem. Quis autem vel natus autem sequis dolor tempor. Ut enim minima voluptate et quis autem sequia dolor tempor. Sed autem quia dolor sed consequat et voluptate autem sequia dolor tempor. Nemo enim sed consequat et voluptate autem sequia dolor tempor.
The quick brown fox jumps over the lazy dog. This is a short line to test how the editor handles lines that are much shorter than the wrap width. Some lines will be very long and wrap many times, while others fit on a single line easily.
Attitulam velis, te sum quae dolorem sequia dolor tempor. Ut enim minima voluptate et quis autem sequia dolor tempor. Sed autem quia dolor sed consequat et voluptate autem sequia dolor tempor. Nemo enim sed consequat et voluptate autem sequia dolor tempor.
There are also words that are extremelylonganddonothaveanywhitespacesinwhichcasewithheuristicswrappingthewholewordwilloverflowthewrapwidthratherthanbeingbrokenmidcharacter. This is expected behavior for a text editor — long identifiers, URLs, or concatenated text should stay on one line even if they exceed the viewport width.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Two words: antidisestablishmentarianism and floccinauciniphilolipaecpelagioodontophont索菲乌斯 are examples of long words that may overflow the wrap width.
In conclusion, this sample text provides a variety of line lengths, word lengths, and paragraph structures to exercise the word wrap implementation. Short lines, long lines, very long words, normal words, empty lines — all present here.`
// 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
)
// 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)
Elems []ui.Element
// Browser state
BrowserScrollOffset ui.Dp // pixel-level scroll offset for browser list
BrowserListHeight ui.Dp // height of the list region, set during layout
SortMode SortMode // sort mode for browser list
SortOrderLabel string // label text for sort order toggle
SearchQuery string // current search query
SearchEditor widget.Editor // Gio editor for search input
SortedEntries []ui.ListItem // pre-sorted entries (computed only when sort mode changes)
// Editor state
ActiveFilename string // filename shown in editor status bar
}
func NewState() *State {
return &State{
scale: 1.0,
page: EditorPage,
SortMode: SortByDateDesc,
SortOrderLabel: "Date ↓",
SortedEntries: sortEntries(browserEntries, SortByDateDesc),
}
}
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() []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
switch s.page {
case BrowserPage:
s.Elems = BrowserLayout(dpW, dpH, s.SortMode)
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 list scroll offset.
// The delta is in pixels; convert to Dp for smooth per-pixel scrolling.
func HandleBrowserScroll(data any) {
delta := data.(int) // pixels
deltaDp := ui.ToDp(ui.Px(delta), TheState.scale)
TheState.BrowserScrollOffset += deltaDp
if TheState.BrowserScrollOffset < 0 {
TheState.BrowserScrollOffset = 0
}
maxScroll := computeBrowserMaxScroll()
if TheState.BrowserScrollOffset > maxScroll {
TheState.BrowserScrollOffset = maxScroll
}
}
// computeBrowserMaxScroll returns the maximum scroll offset in Dp
// so the last row is fully visible at the bottom of the list.
func computeBrowserMaxScroll() ui.Dp {
rowHeight := ui.Dp(48)
totalRows := len(getFilteredEntries())
// Calculate max scroll such that the last row's bottom edge
// aligns with the list region's bottom edge.
// lastRowBottom = totalRows * rowHeight
// We want: lastRowBottom - maxScroll = BrowserListHeight
// Therefore: maxScroll = totalRows * rowHeight - BrowserListHeight
maxScroll := ui.Dp(totalRows)*rowHeight - TheState.BrowserListHeight
if maxScroll < 0 {
return 0
}
return maxScroll
}
// visibleBrowserRows estimates how many browser rows fit in the viewport.
func visibleBrowserRows(pixelHeight int, scale float32) int {
margin := 10
headerHeight := 24
searchHeight := 36
gap := 3 // margin/2 * 2 in Dp
contentHeight := ui.ToDp(ui.Px(pixelHeight), float32(scale)) - ui.Dp(margin*2+headerHeight+searchHeight+gap)
rowHeight := ui.Dp(48)
rows := int(contentHeight / rowHeight)
if rows < 1 {
rows = 1
}
return rows
}
// 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.ActiveFilename = data.(string)
TheState.page = EditorPage
}
// ToggleSortOrder cycles the browser sort mode through four modes.
func ToggleSortOrder(data any) {
TheState.SortMode = (TheState.SortMode + 1) % 4
switch TheState.SortMode {
case SortByDateDesc:
TheState.SortOrderLabel = "Date ↓"
case SortByDateAsc:
TheState.SortOrderLabel = "Date ↑"
case SortByNameAsc:
TheState.SortOrderLabel = "Name ↑"
case SortByNameDesc:
TheState.SortOrderLabel = "Name ↓"
}
// Sort once when mode changes, not every frame
TheState.SortedEntries = sortEntries(getFilteredEntriesRaw(), TheState.SortMode)
TheState.BrowserScrollOffset = 0 // reset scroll on sort change
}
// EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
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.ActiveFilename
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"
}
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
editor := ui.NewTextField(
"editor_text",
SampleText,
editorRegion,
editorRegion.W,
TheState.ScrollOffset,
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleScroll}},
)
return []ui.Element{statusBar, editor, bottomBar}
}
// getFilteredEntriesRaw returns browser entries filtered by the current search query.
// Does not use the SortedEntries cache.
func getFilteredEntriesRaw() []ui.ListItem {
var filtered []ui.ListItem
query := strings.ToLower(TheState.SearchQuery)
if query == "" {
filtered = browserEntries
} else {
for _, entry := range browserEntries {
if strings.Contains(strings.ToLower(entry.Text), query) {
filtered = append(filtered, entry)
}
}
}
return filtered
}
// getFilteredEntries returns browser entries filtered by the current search query.
// If SortedEntries is non-nil (sort mode has been set), returns that instead.
func getFilteredEntries() []ui.ListItem {
if TheState.SortedEntries != nil {
return TheState.SortedEntries
}
return getFilteredEntriesRaw()
}
// sortEntries sorts the entries according to the given sort mode.
func sortEntries(entries []ui.ListItem, mode SortMode) []ui.ListItem {
// Make a copy to avoid modifying the original slice
sorted := make([]ui.ListItem, len(entries))
copy(sorted, entries)
// Parse date from subtext (format: "2025-01-15 • 4.2 KB")
getDate := func(entry ui.ListItem) string {
// Extract date part before the first space
parts := strings.Split(entry.Subtext, " ")
if len(parts) > 0 {
return parts[0]
}
return ""
}
switch mode {
case SortByDateDesc:
sort.Slice(sorted, func(i, j int) bool {
return getDate(sorted[i]) > getDate(sorted[j])
})
case SortByDateAsc:
sort.Slice(sorted, func(i, j int) bool {
return getDate(sorted[i]) < getDate(sorted[j])
})
case SortByNameAsc:
sort.Slice(sorted, func(i, j int) bool {
return strings.ToLower(sorted[i].Text) < strings.ToLower(sorted[j].Text)
})
case SortByNameDesc:
sort.Slice(sorted, func(i, j int) bool {
return strings.ToLower(sorted[i].Text) > strings.ToLower(sorted[j].Text)
})
}
return sorted
}
// browserEntries is a static list of sample files for the browser page.
// It is longer than the viewport so scrolling can be tested.
var browserEntries = []ui.ListItem{
{Text: "notes-2025-01-15.txt", Subtext: "2025-01-15 • 4.2 KB"},
{Text: "meeting-2025-01-14.txt", Subtext: "2025-01-14 • 1.8 KB"},
{Text: "todo-2025-01-14.txt", Subtext: "2025-01-14 • 892 B"},
{Text: "ideas-2025-01-13.txt", Subtext: "2025-01-13 • 2.1 KB"},
{Text: "diary-2025-01-12.txt", Subtext: "2025-01-12 • 3.5 KB"},
{Text: "bookmarks-2025-01-11.txt", Subtext: "2025-01-11 • 6.7 KB"},
{Text: "inbox-2025-01-10.txt", Subtext: "2025-01-10 • 12 KB"},
{Text: "archive-2025-01-09.txt", Subtext: "2025-01-09 • 28 KB"},
{Text: "draft-article.txt", Subtext: "2025-01-08 • 5.3 KB"},
{Text: "recipe-cole-sl.txt", Subtext: "2025-01-07 • 1.1 KB"},
{Text: "project-plan.txt", Subtext: "2025-01-06 • 8.4 KB"},
{Text: "weekly-review.txt", Subtext: "2025-01-05 • 2.9 KB"},
{Text: "changelog-v2.txt", Subtext: "2025-01-04 • 15 KB"},
{Text: "README.txt", Subtext: "2025-01-03 • 512 B"},
{Text: "scratch-pad.txt", Subtext: "2025-01-02 • 736 B"},
{Text: "old-notes.txt", Subtext: "2025-01-01 • 3.1 KB"},
{Text: "backup-jan.txt", Subtext: "2024-12-31 • 42 KB"},
{Text: "annual-review.txt", Subtext: "2024-12-30 • 9.8 KB"},
{Text: "december-log.txt", Subtext: "2024-12-29 • 7.2 KB"},
{Text: "random-thoughts.txt", Subtext: "2024-12-28 • 1.5 KB"},
{Text: "shopping-list.txt", Subtext: "2024-12-27 • 248 B"},
{Text: "travel-ideas.txt", Subtext: "2024-12-26 • 2.3 KB"},
{Text: "music-queue.txt", Subtext: "2024-12-25 • 4.7 KB"},
{Text: "movie-wishlist.txt", Subtext: "2024-12-24 • 890 B"},
{Text: "book-read-list.txt", Subtext: "2024-12-23 • 3.6 KB"},
{Text: "podcast-notes.txt", Subtext: "2024-12-22 • 5.1 KB"},
{Text: "quotes.txt", Subtext: "2024-12-21 • 6.2 KB"},
{Text: "vocab.txt", Subtext: "2024-12-20 • 1.9 KB"},
{Text: "word-of-day.txt", Subtext: "2024-12-19 • 384 B"},
{Text: "memories.txt", Subtext: "2024-12-18 • 11 KB"},
}
// BrowserLayout computes the element tree for the browser (file listing) page.
func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Element {
margin := ui.Dp(10)
contentWidth := screenWidth - margin*2
// --- Header bar: directory name + sort toggle (gray background like editor StatusBar) ---
headerHeight := ui.Dp(24)
headerRegion := ui.Region{
X: margin, Y: margin,
W: contentWidth, H: headerHeight,
}
var sortLabel string
switch sortMode {
case SortByDateDesc:
sortLabel = "Date ↓"
case SortByDateAsc:
sortLabel = "Date ↑"
case SortByNameAsc:
sortLabel = "Name ↑"
case SortByNameDesc:
sortLabel = "Name ↓"
}
headerBar := ui.NewContainer(
headerRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewLabel("My Documents", 16, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignStart, "", nil),
ui.NewLabel(sortLabel, 12, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignEnd, "sort",
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}}),
},
)
// --- Search bar ---
searchHeight := ui.Dp(36)
searchY := headerRegion.Y + headerRegion.H + margin/2
searchRegion := ui.Region{
X: margin, Y: searchY,
W: contentWidth, H: searchHeight,
}
searchBar := ui.NewGioEditor("search_bar", searchRegion, &TheState.SearchEditor)
TheState.SearchEditor.SingleLine = true
var searchPlaceholder ui.Element
if TheState.SearchEditor.Len() == 0 {
searchPlaceholder = ui.NewLabel("Search…", 14,
ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(8), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(16)},
ui.AlignStart, "", nil)
}
// --- ListView ---
listY := searchY + searchHeight + margin/2
listHeight := screenHeight - listY - margin // fill remaining height
listRegion := ui.Region{
X: margin, Y: listY,
W: contentWidth, H: listHeight,
}
// Store list height for scroll clamping calculation
TheState.BrowserListHeight = listHeight
// Filter entries by search query (case-insensitive substring match)
filteredEntries := getFilteredEntries()
// Compute first visible row from Dp scroll offset
rowHeight := ui.Dp(48)
firstVisibleRow := int(TheState.BrowserScrollOffset / rowHeight)
if firstVisibleRow < 0 {
firstVisibleRow = 0
}
if firstVisibleRow > len(filteredEntries) {
firstVisibleRow = len(filteredEntries)
}
visibleEntries := filteredEntries[firstVisibleRow:]
list := ui.NewListView(
"browser_list",
visibleEntries,
listRegion,
TheState.BrowserScrollOffset, // Dp, not row index
-1, // Selected: none
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}},
)
// Set row filenames so ListView.Draw can wire up click handlers
list.RowFilenames = make([]string, len(visibleEntries))
for i, entry := range visibleEntries {
list.RowFilenames[i] = entry.Text
}
elems := []ui.Element{
headerBar,
searchBar,
}
if searchPlaceholder != nil {
elems = append(elems, searchPlaceholder)
}
elems = append(elems, list)
return elems
}