Browser page: scroll + click coexist via register order fix
- Added Page state (BrowserPage/EditorPage) and SortMode - BrowserLayout with 30 static entries, sort toggle, search placeholder - ListView rows clickable via per-row RegisterClick - Scroll gesture registered at START of ListView.Draw (before clicks) so click gestures registered later are checked first during hit test - Unique IDs for interactive elements: editor_text, browser_list, search_bar - ListView.Interactions() filters out Scroll (registered in Draw, not registerInteraction) - RegisterScroll added to Renderer, called from element Draw methods - lastLineYChan frame loop fixed: only re-layout if value changed - Back icon added, editor status bar shows active filename
This commit is contained in:
parent
0246316d8f
commit
ad3049dd22
|
|
@ -105,8 +105,10 @@ func (l *Logic) Run() {
|
||||||
update.apply(l.state)
|
update.apply(l.state)
|
||||||
l.frameChan <- l.state.layout()
|
l.frameChan <- l.state.layout()
|
||||||
case y := <-l.lastLineYChan:
|
case y := <-l.lastLineYChan:
|
||||||
|
if ui.Dp(y) != l.state.LastLineY {
|
||||||
l.state.LastLineY = ui.Dp(y)
|
l.state.LastLineY = ui.Dp(y)
|
||||||
l.frameChan <- l.state.layout()
|
l.frameChan <- l.state.layout()
|
||||||
|
}
|
||||||
case events := <-l.inputChan:
|
case events := <-l.inputChan:
|
||||||
for _, evt := range events {
|
for _, evt := range events {
|
||||||
evt.Handler(evt.Data)
|
evt.Handler(evt.Data)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import (
|
||||||
"pad/internal/ui"
|
"pad/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
ui.OpenFile = OpenFile
|
||||||
|
}
|
||||||
|
|
||||||
// SampleText is a static lorem-ipsum text used for display-only testing.
|
// SampleText is a static lorem-ipsum text used for display-only testing.
|
||||||
// Roughly 3 KB, filling a few pages of the editor.
|
// Roughly 3 KB, filling a few pages of the editor.
|
||||||
const SampleText = `
|
const SampleText = `
|
||||||
|
|
@ -36,20 +40,48 @@ func EditorLineHeight() ui.Dp {
|
||||||
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
|
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 (
|
||||||
|
SortByDateNewest SortMode = iota // default: newest modified first
|
||||||
|
SortByNameAsc
|
||||||
|
)
|
||||||
|
|
||||||
// State holds all application state owned by the logic goroutine.
|
// State holds all application state owned by the logic goroutine.
|
||||||
type State struct {
|
type State struct {
|
||||||
PixelWidth int // raw pixel width from Gio ConfigEvent
|
PixelWidth int // raw pixel width from Gio ConfigEvent
|
||||||
PixelHeight int // raw pixel height from Gio ConfigEvent
|
PixelHeight int // raw pixel height from Gio ConfigEvent
|
||||||
scale float32
|
scale float32
|
||||||
|
page Page // current page (Browser or Editor)
|
||||||
WordWrap bool
|
WordWrap bool
|
||||||
ScrollOffset ui.Dp // vertical scroll position in Dp
|
ScrollOffset ui.Dp // vertical scroll position in Dp
|
||||||
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
||||||
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
||||||
Elems []ui.Element
|
Elems []ui.Element
|
||||||
|
// Browser state
|
||||||
|
BrowserScrollOffset int // index of first visible browser entry
|
||||||
|
SortMode SortMode // sort mode for browser list
|
||||||
|
SortOrderLabel string // label text for sort order toggle
|
||||||
|
// Editor state
|
||||||
|
ActiveFilename string // filename shown in editor status bar
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewState() *State {
|
func NewState() *State {
|
||||||
return &State{scale: 1.0}
|
return &State{
|
||||||
|
scale: 1.0,
|
||||||
|
page: EditorPage,
|
||||||
|
SortMode: SortByDateNewest,
|
||||||
|
SortOrderLabel: "Date",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) SetScale(scale float32) {
|
func (s *State) SetScale(scale float32) {
|
||||||
|
|
@ -65,7 +97,12 @@ func (s *State) Scale() float32 {
|
||||||
func (s *State) layout() []ui.Element {
|
func (s *State) layout() []ui.Element {
|
||||||
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
||||||
dpH := ui.ToDp(ui.Px(s.PixelHeight), 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)
|
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
||||||
|
}
|
||||||
return s.Elems
|
return s.Elems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,6 +125,74 @@ func HandleScroll(data any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleBrowserScroll updates the browser list scroll offset.
|
||||||
|
// The delta is in pixels; convert to Dp, then to row index offset.
|
||||||
|
func HandleBrowserScroll(data any) {
|
||||||
|
delta := data.(int) // pixels
|
||||||
|
deltaDp := ui.ToDp(ui.Px(delta), TheState.scale)
|
||||||
|
rowHeight := ui.Dp(48)
|
||||||
|
rowDelta := int(deltaDp / rowHeight)
|
||||||
|
// Ensure at least ±1 for non-zero scroll events
|
||||||
|
if rowDelta == 0 && delta != 0 {
|
||||||
|
if delta > 0 {
|
||||||
|
rowDelta = 1
|
||||||
|
} else {
|
||||||
|
rowDelta = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TheState.BrowserScrollOffset += rowDelta
|
||||||
|
if TheState.BrowserScrollOffset < 0 {
|
||||||
|
TheState.BrowserScrollOffset = 0
|
||||||
|
}
|
||||||
|
maxOffset := len(browserEntries) - visibleBrowserRows(TheState.PixelHeight, TheState.scale)
|
||||||
|
if TheState.BrowserScrollOffset > maxOffset {
|
||||||
|
TheState.BrowserScrollOffset = maxOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 between date and name.
|
||||||
|
func ToggleSortOrder(data any) {
|
||||||
|
TheState.SortMode = (TheState.SortMode + 1) % 2
|
||||||
|
switch TheState.SortMode {
|
||||||
|
case SortByDateNewest:
|
||||||
|
TheState.SortOrderLabel = "Date"
|
||||||
|
case SortByNameAsc:
|
||||||
|
TheState.SortOrderLabel = "Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EditorLayout computes the element tree for the editor page.
|
// EditorLayout computes the element tree for the editor page.
|
||||||
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
margin := ui.Dp(10)
|
margin := ui.Dp(10)
|
||||||
|
|
@ -99,16 +204,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
H: ui.Dp(52),
|
H: ui.Dp(52),
|
||||||
}
|
}
|
||||||
statusBarW := statusBarRegion.W
|
statusBarW := statusBarRegion.W
|
||||||
|
filename := TheState.ActiveFilename
|
||||||
|
if filename == "" {
|
||||||
|
filename = "untitled.txt"
|
||||||
|
}
|
||||||
statusBar := ui.NewContainer(
|
statusBar := ui.NewContainer(
|
||||||
statusBarRegion,
|
statusBarRegion,
|
||||||
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
||||||
[]ui.Element{
|
[]ui.Element{
|
||||||
// Row 1: filename
|
// Row 1: filename
|
||||||
ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
||||||
// Row 2: cut, copy, paste icons
|
// Row 2: back, cut, copy, paste icons
|
||||||
ui.NewIcon("cut", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
|
ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0,
|
||||||
ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
|
||||||
ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
|
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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -155,6 +266,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
TheState.MaxScroll = maxScroll
|
TheState.MaxScroll = maxScroll
|
||||||
|
|
||||||
editor := ui.NewTextField(
|
editor := ui.NewTextField(
|
||||||
|
"editor_text",
|
||||||
SampleText,
|
SampleText,
|
||||||
editorRegion,
|
editorRegion,
|
||||||
editorRegion.W,
|
editorRegion.W,
|
||||||
|
|
@ -164,3 +276,117 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
|
|
||||||
return []ui.Element{statusBar, editor, bottomBar}
|
return []ui.Element{statusBar, editor, bottomBar}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: directory name + sort toggle ---
|
||||||
|
headerHeight := ui.Dp(24)
|
||||||
|
headerRegion := ui.Region{
|
||||||
|
X: margin, Y: margin,
|
||||||
|
W: contentWidth, H: headerHeight,
|
||||||
|
}
|
||||||
|
header := ui.NewLabel("My Documents", 16, headerRegion, ui.AlignStart, "", nil)
|
||||||
|
|
||||||
|
// --- Sort toggle ---
|
||||||
|
var sortLabel string
|
||||||
|
switch sortMode {
|
||||||
|
case SortByDateNewest:
|
||||||
|
sortLabel = "Date ↓"
|
||||||
|
case SortByNameAsc:
|
||||||
|
sortLabel = "Name ↑"
|
||||||
|
}
|
||||||
|
sortRegion := ui.Region{
|
||||||
|
X: contentWidth - ui.Dp(60), Y: ui.Dp(2), W: ui.Dp(60), H: headerHeight,
|
||||||
|
}
|
||||||
|
sortToggle := ui.NewLabel(sortLabel, 12, sortRegion, 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.NewTextField(
|
||||||
|
"search_bar", // id
|
||||||
|
"", // empty query
|
||||||
|
searchRegion,
|
||||||
|
searchRegion.W,
|
||||||
|
ui.Dp(0),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
// Override placeholder for search bar — TextField doesn't have a dedicated
|
||||||
|
// search variant yet, so we use a Label as a visual placeholder for now.
|
||||||
|
searchPlaceholder := ui.NewLabel("Search…", 14,
|
||||||
|
ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(2), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(4)},
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
visibleEntries := browserEntries[TheState.BrowserScrollOffset:]
|
||||||
|
list := ui.NewListView(
|
||||||
|
"browser_list",
|
||||||
|
visibleEntries,
|
||||||
|
listRegion,
|
||||||
|
TheState.BrowserScrollOffset,
|
||||||
|
-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
|
||||||
|
}
|
||||||
|
|
||||||
|
return []ui.Element{
|
||||||
|
header,
|
||||||
|
sortToggle,
|
||||||
|
searchBar,
|
||||||
|
searchPlaceholder,
|
||||||
|
list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -107,6 +111,15 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
|
||||||
if img == nil {
|
if img == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Register click area if this icon has interactions
|
||||||
|
if cr, ok := r.clicks[i.id]; ok && cr.click != nil {
|
||||||
|
clickClip := clip.Rect{
|
||||||
|
Min: image.Point{X: int(r.toPx(i.region.X)), Y: int(r.toPx(i.region.Y))},
|
||||||
|
Max: image.Point{X: int(r.toPx(i.region.X+i.region.W)), Y: int(r.toPx(i.region.Y+i.region.H))},
|
||||||
|
}.Push(gtx.Ops)
|
||||||
|
cr.click.Add(gtx.Ops)
|
||||||
|
clickClip.Pop()
|
||||||
|
}
|
||||||
// Auto-scale: if Size is 0, use region dimensions so the icon fills its region
|
// Auto-scale: if Size is 0, use region dimensions so the icon fills its region
|
||||||
w, h := Dp(0), Dp(0)
|
w, h := Dp(0), Dp(0)
|
||||||
if i.Size == 0 {
|
if i.Size == 0 {
|
||||||
|
|
@ -117,10 +130,12 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
|
||||||
func (i Icon) ID() string { return i.id }
|
func (i Icon) ID() string { return i.id }
|
||||||
|
|
||||||
// NewIcon creates a visible Icon element.
|
// NewIcon creates a visible Icon element.
|
||||||
func NewIcon(name string, region Region, size Dp) Icon {
|
func NewIcon(name string, region Region, size Dp, interactions []Interaction) Icon {
|
||||||
return Icon{
|
return Icon{
|
||||||
|
id: name,
|
||||||
region: region,
|
region: region,
|
||||||
visible: true,
|
visible: true,
|
||||||
|
interactions: interactions,
|
||||||
Name: name,
|
Name: name,
|
||||||
Size: size,
|
Size: size,
|
||||||
}
|
}
|
||||||
|
|
@ -154,8 +169,9 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTextField creates a visible multiline TextField.
|
// NewTextField creates a visible multiline TextField.
|
||||||
func NewTextField(value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField {
|
func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField {
|
||||||
return TextField{
|
return TextField{
|
||||||
|
id: id,
|
||||||
region: region,
|
region: region,
|
||||||
visible: true,
|
visible: true,
|
||||||
interactions: interactions,
|
interactions: interactions,
|
||||||
|
|
@ -182,12 +198,99 @@ type ListView struct {
|
||||||
Items []ListItem
|
Items []ListItem
|
||||||
ScrollOffset int
|
ScrollOffset int
|
||||||
Selected int
|
Selected int
|
||||||
|
RowFilenames []string // filenames for click navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lv ListView) Region() Region { return lv.region }
|
func (lv ListView) Region() Region { return lv.region }
|
||||||
func (lv ListView) Visible() bool { return lv.visible }
|
func (lv ListView) Visible() bool { return lv.visible }
|
||||||
func (lv ListView) ID() string { return lv.id }
|
func (lv ListView) ID() string { return lv.id }
|
||||||
func (lv ListView) Interactions() []Interaction { return lv.interactions }
|
func (lv ListView) Interactions() []Interaction {
|
||||||
|
// ListView registers its scroll in Draw, not via registerInteraction.
|
||||||
|
// Filter out Scroll so registerInteraction only handles Tap.
|
||||||
|
var filtered []Interaction
|
||||||
|
for _, interaction := range lv.interactions {
|
||||||
|
if interaction.Gesture != Scroll {
|
||||||
|
filtered = append(filtered, interaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw renders each list item as a row of text.
|
||||||
|
func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
|
||||||
|
// Register scroll gesture first, so click gestures registered later are checked first
|
||||||
|
if len(lv.interactions) > 0 {
|
||||||
|
var scrollHandler func(any)
|
||||||
|
for _, interaction := range lv.interactions {
|
||||||
|
if interaction.Gesture == Scroll {
|
||||||
|
scrollHandler = interaction.Handler
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scrollHandler != nil {
|
||||||
|
r.RegisterScroll(gtx, lv.id, lv.region, scrollHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowHeight := Dp(48)
|
||||||
|
for i, item := range lv.Items {
|
||||||
|
// Only draw visible rows (virtualized)
|
||||||
|
y := lv.region.Y + Dp(i)*rowHeight
|
||||||
|
if y+rowHeight < lv.region.Y || y > lv.region.Y+lv.region.H {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Draw background for selected item
|
||||||
|
if item.Selected || (lv.Selected == i) {
|
||||||
|
r.drawBg(gtx, Region{
|
||||||
|
X: lv.region.X, Y: lv.region.Y + Dp(i)*rowHeight,
|
||||||
|
W: lv.region.W, H: rowHeight,
|
||||||
|
}, Color{R: 200, G: 220, B: 255, A: 255})
|
||||||
|
}
|
||||||
|
// Register click area for this row
|
||||||
|
rowGlobalIndex := lv.ScrollOffset + i
|
||||||
|
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
|
||||||
|
var filename string
|
||||||
|
if rowGlobalIndex < len(lv.RowFilenames) {
|
||||||
|
filename = lv.RowFilenames[rowGlobalIndex]
|
||||||
|
}
|
||||||
|
r.RegisterClick(gtx, rowID, Region{
|
||||||
|
X: lv.region.X, Y: y,
|
||||||
|
W: lv.region.W, H: rowHeight,
|
||||||
|
}, func(data any) {
|
||||||
|
OpenFile(filename)
|
||||||
|
})
|
||||||
|
// Draw main text
|
||||||
|
textRegion := Region{
|
||||||
|
X: lv.region.X + Dp(8),
|
||||||
|
Y: lv.region.Y + Dp(i)*rowHeight + Dp(4),
|
||||||
|
W: lv.region.W - Dp(32),
|
||||||
|
H: rowHeight - Dp(8),
|
||||||
|
}
|
||||||
|
r.drawText(gtx, item.Text, 14, textRegion, AlignStart, Color{R: 0, G: 0, B: 0, A: 255}, "")
|
||||||
|
// Draw subtext
|
||||||
|
if item.Subtext != "" {
|
||||||
|
subRegion := Region{
|
||||||
|
X: lv.region.X + Dp(8),
|
||||||
|
Y: lv.region.Y + Dp(i)*rowHeight + rowHeight - Dp(22),
|
||||||
|
W: lv.region.W - Dp(32),
|
||||||
|
H: Dp(16),
|
||||||
|
}
|
||||||
|
r.drawText(gtx, item.Subtext, 12, subRegion, AlignStart, Color{R: 128, G: 128, B: 128, A: 255}, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewListView creates a visible ListView element.
|
||||||
|
func NewListView(id string, items []ListItem, region Region, scrollOffset, selected int, interactions []Interaction) ListView {
|
||||||
|
return ListView{
|
||||||
|
id: id,
|
||||||
|
region: region,
|
||||||
|
visible: true,
|
||||||
|
interactions: interactions,
|
||||||
|
Items: items,
|
||||||
|
ScrollOffset: scrollOffset,
|
||||||
|
Selected: selected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListItem is a single entry in a ListView.
|
// ListItem is a single entry in a ListView.
|
||||||
type ListItem struct {
|
type ListItem struct {
|
||||||
|
|
@ -211,6 +314,24 @@ func (ai AlphaIndex) Visible() bool { return ai.visible }
|
||||||
func (ai AlphaIndex) ID() string { return ai.id }
|
func (ai AlphaIndex) ID() string { return ai.id }
|
||||||
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions }
|
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions }
|
||||||
|
|
||||||
|
// Draw renders letters vertically along the right edge.
|
||||||
|
func (ai AlphaIndex) Draw(gtx layout.Context, r *Renderer) {
|
||||||
|
letterHeight := ai.region.H / Dp(len(ai.Letters))
|
||||||
|
for i, letter := range ai.Letters {
|
||||||
|
region := Region{
|
||||||
|
X: ai.region.X,
|
||||||
|
Y: ai.region.Y + Dp(i)*letterHeight,
|
||||||
|
W: ai.region.W,
|
||||||
|
H: letterHeight,
|
||||||
|
}
|
||||||
|
col := Color{R: 100, G: 100, B: 100, A: 255}
|
||||||
|
if letter == ai.ActiveLetter {
|
||||||
|
col = Color{R: 0, G: 100, B: 200, A: 255}
|
||||||
|
}
|
||||||
|
r.drawText(gtx, letter, 10, region, AlignCenter, col, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewAlphaIndex creates a visible AlphaIndex element.
|
// NewAlphaIndex creates a visible AlphaIndex element.
|
||||||
func NewAlphaIndex(region Region, letters []string) AlphaIndex {
|
func NewAlphaIndex(region Region, letters []string) AlphaIndex {
|
||||||
return AlphaIndex{
|
return AlphaIndex{
|
||||||
|
|
@ -381,6 +502,10 @@ func NewSpacer(height Dp) Spacer {
|
||||||
|
|
||||||
// --- Utility types ---
|
// --- Utility types ---
|
||||||
|
|
||||||
|
// OpenFile is called by the browser list when a file row is tapped.
|
||||||
|
// Set by the editor package after initialization.
|
||||||
|
var OpenFile func(any)
|
||||||
|
|
||||||
// Color is an RGBA color.
|
// Color is an RGBA color.
|
||||||
type Color struct {
|
type Color struct {
|
||||||
R, G, B, A uint8
|
R, G, B, A uint8
|
||||||
|
|
|
||||||
BIN
internal/ui/icons/back.png
Normal file
BIN
internal/ui/icons/back.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 B |
3
internal/ui/icons/back.svg
Normal file
3
internal/ui/icons/back.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
|
|
@ -28,7 +28,7 @@ var iconTemplate = template.Must(template.New("").Parse(`// Code generated by ge
|
||||||
package icons
|
package icons
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed {{.Embeds}}
|
//go:embed {{.Embeds}}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,15 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed copy.svg cut.svg paste.svg
|
//go:embed back.svg copy.svg cut.svg paste.svg
|
||||||
var svgFS embed.FS
|
var svgFS embed.FS
|
||||||
|
|
||||||
|
// backSVG contains the back icon SVG source.
|
||||||
|
var backSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
|
||||||
// copySVG contains the copy icon SVG source.
|
// copySVG contains the copy icon SVG source.
|
||||||
var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||||
<rect x="7" y="7" width="12" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
<rect x="7" y="7" width="12" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
|
@ -17,16 +23,12 @@ var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width
|
||||||
`
|
`
|
||||||
|
|
||||||
// cutSVG contains the cut icon SVG source.
|
// cutSVG contains the cut icon SVG source.
|
||||||
var cutSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
var cutSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<!-- Left blade -->
|
<circle cx="6" cy="6" r="3"/>
|
||||||
<circle cx="7" cy="7" r="4" />
|
<circle cx="6" cy="18" r="3"/>
|
||||||
<line x1="9.5" y1="9.5" x2="14" y2="14" />
|
<path d="M20 4 8.12 15.12"/>
|
||||||
<!-- Right blade -->
|
<path d="M14.8 14.8 20 20"/>
|
||||||
<circle cx="17" cy="7" r="4" />
|
<path d="M8.12 8.12 12 12"/>
|
||||||
<line x1="14.5" y1="9.5" x2="10" y2="14" />
|
|
||||||
<!-- Handles -->
|
|
||||||
<line x1="7" y1="3" x2="7" y2="11" />
|
|
||||||
<line x1="17" y1="3" x2="17" y2="11" />
|
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
|
||||||
|
|
||||||
// loadIcons loads PNG icons from the embedded filesystem.
|
// loadIcons loads PNG icons from the embedded filesystem.
|
||||||
func (r *Renderer) loadIcons() {
|
func (r *Renderer) loadIcons() {
|
||||||
for _, name := range []string{"cut", "copy", "paste"} {
|
for _, name := range []string{"back", "cut", "copy", "paste"} {
|
||||||
data, err := iconFS.ReadFile("icons/" + name + ".png")
|
data, err := iconFS.ReadFile("icons/" + name + ".png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
@ -151,6 +151,39 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterClick registers a click gesture for a rectangular region.
|
||||||
|
// Used by ListView to register per-row click areas. The click.Add() call
|
||||||
|
// is made within a clip so only the region is clickable.
|
||||||
|
func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) {
|
||||||
|
reg, ok := r.clicks[id]
|
||||||
|
if !ok {
|
||||||
|
reg = clickReg{click: &gesture.Click{}}
|
||||||
|
}
|
||||||
|
reg.handler = handler
|
||||||
|
r.clicks[id] = reg
|
||||||
|
// Clip click to row region
|
||||||
|
clickClip := clip.Rect{
|
||||||
|
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
|
||||||
|
Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))},
|
||||||
|
}.Push(gtx.Ops)
|
||||||
|
reg.click.Add(gtx.Ops)
|
||||||
|
clickClip.Pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterScroll registers a scroll gesture for a rectangular region.
|
||||||
|
// Called by elements during their Draw method, after paint operations.
|
||||||
|
// No clip is pushed — the scroll gesture is registered at the current area,
|
||||||
|
// similar to Gio's layout.List.
|
||||||
|
func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) {
|
||||||
|
reg, ok := r.scrolls[id]
|
||||||
|
if !ok {
|
||||||
|
reg = scrollReg{scroll: &gesture.Scroll{}}
|
||||||
|
}
|
||||||
|
reg.handler = handler
|
||||||
|
r.scrolls[id] = reg
|
||||||
|
reg.scroll.Add(gtx.Ops)
|
||||||
|
}
|
||||||
|
|
||||||
// CheckGestures checks all registered gestures and returns any events.
|
// CheckGestures checks all registered gestures and returns any events.
|
||||||
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||||||
var events []InputEvent
|
var events []InputEvent
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user