From a19d9ed43ab494087b5ee8354d5836cc59ceb4b1 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Sat, 26 Nov 2022 08:53:33 -0500 Subject: [PATCH] appwidget{,/apptheme}: import suggestion editor code This code is borrowed from another of my projects. Signed-off-by: Chris Waldon --- appwidget/apptheme/apptheme.go | 8 + appwidget/apptheme/box.go | 70 ++++++++ appwidget/apptheme/suggestor.go | 95 +++++++++++ appwidget/apptheme/theme.go | 93 +++++++++++ appwidget/apptheme/tx-editor.go | 67 ++++++++ appwidget/appwidget.go | 8 + appwidget/tx-editor.go | 280 ++++++++++++++++++++++++++++++++ 7 files changed, 621 insertions(+) create mode 100644 appwidget/apptheme/apptheme.go create mode 100644 appwidget/apptheme/box.go create mode 100644 appwidget/apptheme/suggestor.go create mode 100644 appwidget/apptheme/theme.go create mode 100644 appwidget/apptheme/tx-editor.go create mode 100644 appwidget/appwidget.go create mode 100644 appwidget/tx-editor.go diff --git a/appwidget/apptheme/apptheme.go b/appwidget/apptheme/apptheme.go new file mode 100644 index 0000000..848f7fd --- /dev/null +++ b/appwidget/apptheme/apptheme.go @@ -0,0 +1,8 @@ +package apptheme + +import "gioui.org/layout" + +type ( + C = layout.Context + D = layout.Dimensions +) diff --git a/appwidget/apptheme/box.go b/appwidget/apptheme/box.go new file mode 100644 index 0000000..1800fba --- /dev/null +++ b/appwidget/apptheme/box.go @@ -0,0 +1,70 @@ +package apptheme + +import ( + "image" + "image/color" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +// BoxStyle implements a simple box model layout primitive. +type BoxStyle struct { + Padding layout.Inset + Margin layout.Inset + Bg color.NRGBA + Rounding unit.Dp + BorderWidth unit.Dp + BorderColor color.NRGBA +} + +// Box constructs a default box. +func Box(th *Theme) BoxStyle { + return BoxStyle{ + Padding: layout.UniformInset(unit.Dp(8)), + Bg: th.Surface1.Base, + } +} + +// BoxContrast constructs a box designed to contrast with the normal box. +func BoxFor(th *material.Theme, surface ContrastPair) BoxStyle { + return BoxStyle{ + Padding: layout.UniformInset(unit.Dp(8)), + Bg: surface.Base, + } +} + +// Layout the box. +func (b BoxStyle) Layout(gtx C, w layout.Widget) D { + return b.Margin.Layout(gtx, func(gtx C) D { + externalConstraints := gtx.Constraints + macro := op.Record(gtx.Ops) + dims := layout.Stack{Alignment: layout.Center}.Layout(gtx, + layout.Expanded(func(gtx C) D { + paint.Fill(gtx.Ops, b.Bg) + return widget.Border{ + Color: b.BorderColor, + CornerRadius: b.Rounding, + Width: b.BorderWidth, + }.Layout(gtx, func(gtx C) D { + return D{Size: gtx.Constraints.Min} + }) + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints = externalConstraints + return b.Padding.Layout(gtx, w) + }), + ) + call := macro.Stop() + defer clip.UniformRRect(image.Rectangle{ + Max: dims.Size, + }, gtx.Dp(b.Rounding)).Push(gtx.Ops).Pop() + call.Add(gtx.Ops) + return dims + }) +} diff --git a/appwidget/apptheme/suggestor.go b/appwidget/apptheme/suggestor.go new file mode 100644 index 0000000..e9d9f48 --- /dev/null +++ b/appwidget/apptheme/suggestor.go @@ -0,0 +1,95 @@ +package apptheme + +import ( + "image" + "image/color" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "git.sr.ht/~whereswaldon/rosebud/appwidget" +) + +// SuggestorStyle presents completion suggestions. +type SuggestorStyle[T comparable] struct { + State *appwidget.Suggestor[T] + Surface component.SurfaceStyle + List material.ListStyle + OptionFunc func(C, *Theme, T) D + CreateOption material.LabelStyle + OptionPadding layout.Inset + SelectedHighlight color.NRGBA + th *Theme +} + +// Suggestor configures a default SuggestorStyle. +func Suggestor[T comparable](th *Theme, state *appwidget.Suggestor[T], optionFunc func(gtx C, th *Theme, option T) D) SuggestorStyle[T] { + state.Options.List.Axis = layout.Vertical + s := SuggestorStyle[T]{ + State: state, + Surface: component.Surface(th.Th), + List: material.List(th.Th, &state.Options), + OptionFunc: optionFunc, + OptionPadding: layout.UniformInset(unit.Dp(4)), + CreateOption: material.Body1(th.Th, "Create New"), + SelectedHighlight: component.WithAlpha(th.Primary.Base, 50), + th: th, + } + s.List.AnchorStrategy = material.Overlay + return s +} + +// Layout the suggestor. +func (s SuggestorStyle[T]) Layout(gtx C) D { + s.State.Layout(gtx) + return s.Surface.Layout(gtx, func(gtx C) D { + return s.List.Layout(gtx, len(s.State.Entities)+1, func(gtx C, index int) D { + if index < len(s.State.Entities) { + entity := s.State.Entities[index] + for index > len(s.State.Clickables)-1 { + s.State.Clickables = append(s.State.Clickables, widget.Clickable{}) + } + click := &s.State.Clickables[index] + return material.Clickable(gtx, click, func(gtx C) D { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + if s.State.Current == index { + rect := clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}) + paint.FillShape(gtx.Ops, s.SelectedHighlight, rect.Op()) + } + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return s.OptionPadding.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return s.OptionFunc(gtx, s.th, entity) + }) + }), + ) + }) + } + click := &s.State.Clickables[index] + return material.Clickable(gtx, click, func(gtx C) D { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + if s.State.Current == index { + rect := clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}) + paint.FillShape(gtx.Ops, s.SelectedHighlight, rect.Op()) + } + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return s.OptionPadding.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return s.CreateOption.Layout(gtx) + }) + }), + ) + }) + }) + }) +} diff --git a/appwidget/apptheme/theme.go b/appwidget/apptheme/theme.go new file mode 100644 index 0000000..b2c0467 --- /dev/null +++ b/appwidget/apptheme/theme.go @@ -0,0 +1,93 @@ +package apptheme + +import ( + "image/color" + + "gioui.org/text" + "gioui.org/widget/material" +) + +func rgb(c uint32) color.NRGBA { + return argb(0xff000000 | c) +} + +func argb(c uint32) color.NRGBA { + return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} +} + +var ( + Primary = rgb(0x69f0ae) + PrimaryLight = rgb(0x9fffe0) + PrimaryDark = rgb(0x2bbd7e) + Secondary = rgb(0xff80ab) + SecondaryLight = rgb(0xffb2dd) + SecondaryDark = rgb(0xe94f7c) + Black = rgb(0x000000) + White = rgb(0xffffff) + LightGray = rgb(0xeeeeee) + Gray = rgb(0xdddddd) + DarkGray = rgb(0xcccccc) + Background = rgb(0xeeeeee) +) + +type ContrastPair struct { + Base color.NRGBA + Contrast color.NRGBA +} + +type Palette struct { + Primary ContrastPair + PrimaryLight ContrastPair + Background ContrastPair + Surface1 ContrastPair + Surface2 ContrastPair + Surface3 ContrastPair + Danger ContrastPair +} + +type Theme struct { + Th *material.Theme + Palette +} + +func NewTheme(fonts []text.FontFace) *Theme { + th := material.NewTheme(fonts) + palette := Palette{ + Primary: ContrastPair{ + Base: Primary, + Contrast: White, + }, + PrimaryLight: ContrastPair{ + Base: PrimaryLight, + Contrast: White, + }, + Background: ContrastPair{ + Base: Background, + Contrast: Black, + }, + Surface1: ContrastPair{ + Base: DarkGray, + Contrast: Black, + }, + Surface2: ContrastPair{ + Base: Gray, + Contrast: Black, + }, + Surface3: ContrastPair{ + Base: LightGray, + Contrast: Black, + }, + Danger: ContrastPair{ + Base: Secondary, + Contrast: White, + }, + } + th.ContrastBg = palette.Primary.Base + th.ContrastFg = palette.Primary.Contrast + th.Bg = palette.Background.Base + th.Fg = palette.Background.Contrast + return &Theme{ + Th: th, + Palette: palette, + } +} diff --git a/appwidget/apptheme/tx-editor.go b/appwidget/apptheme/tx-editor.go new file mode 100644 index 0000000..e903bd9 --- /dev/null +++ b/appwidget/apptheme/tx-editor.go @@ -0,0 +1,67 @@ +package apptheme + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget/material" + "git.sr.ht/~whereswaldon/rosebud/appwidget" +) + +// TxEditorStyle configures the presentation of an editor that +// can suggest completions. +type TxEditorStyle struct { + Box BoxStyle + EditorStyle material.EditorStyle + State *appwidget.TxEditor + SuggestorStyle[string] + th *Theme +} + +// TagEditor configures a default TxEditorStyle. +func TxEditor(th *Theme, state *appwidget.TxEditor, hint string) TxEditorStyle { + return TxEditorStyle{ + State: state, + EditorStyle: material.Editor(th.Th, &state.Editor, hint), + SuggestorStyle: Suggestor(th, &state.Suggestor, func(gtx C, th *Theme, option string) D { + return material.Body1(th.Th, option).Layout(gtx) + }), + Box: BoxStyle{ + BorderColor: th.Surface3.Contrast, + BorderWidth: unit.Dp(1), + Padding: layout.UniformInset(unit.Dp(8)), + Rounding: unit.Dp(8), + Bg: th.Surface3.Base, + }, + th: th, + } +} + +// Layout the editor and (possibly) the selection box. +func (s TxEditorStyle) Layout(gtx C) D { + s.State.Editor.SingleLine = true + return s.Box.Layout(gtx, func(gtx C) D { + dims := s.State.Layout(gtx, s.EditorStyle.Layout) + if s.State.Suggesting() { + caret := s.EditorStyle.Editor.CaretCoords() + // Ensure the suggestion box stays within the editor always, so that + // it doesn't get squished up towards the end of a line. + caret.X = 0 + + gtx.Constraints.Max.Y = gtx.Dp(300) + gtx.Constraints.Max.X = gtx.Dp(100) + gtx.Constraints.Min = image.Point{} + + defer op.Offset(image.Point{ + X: int(caret.X), + Y: int(caret.Y), + }).Push(gtx.Ops).Pop() + macro := op.Record(gtx.Ops) + s.SuggestorStyle.Layout(gtx) + op.Defer(gtx.Ops, macro.Stop()) + } + return dims + }) +} diff --git a/appwidget/appwidget.go b/appwidget/appwidget.go new file mode 100644 index 0000000..5a31591 --- /dev/null +++ b/appwidget/appwidget.go @@ -0,0 +1,8 @@ +package appwidget + +import "gioui.org/layout" + +type ( + C = layout.Context + D = layout.Dimensions +) diff --git a/appwidget/tx-editor.go b/appwidget/tx-editor.go new file mode 100644 index 0000000..a50fd28 --- /dev/null +++ b/appwidget/tx-editor.go @@ -0,0 +1,280 @@ +package appwidget + +import ( + "log" + "strings" + + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + "gioui.org/x/eventx" +) + +// suggestionEvent defines a subtype for events used to communicate with +// a suggestor. +type suggestionEvent interface { + isSuggestionEvent() +} + +// suggestionNextEvent indicates that the user wants to scroll to +// the next available suggestion. +type suggestionNextEvent struct{} + +// suggestionPreviousEvent indicates that the user wants to scroll +// back to the previous available suggestion. +type suggestionPreviousEvent struct{} + +// suggestionChooseEvent indicates that the user has chosen the +// currently-selected suggestion. +type suggestionChooseEvent struct{} + +// SuggestionAcceptedEvent indicates that the user has chosen a suggestion. +type SuggestionAcceptedEvent[T comparable] struct { + // Suggestion is the chosen entity. + Suggestion T +} + +// SuggestionCreateNewEvent indicates that the user has requested to make +// a new entity from the current input text. +type SuggestionCreateNewEvent[T any] struct { + Text string +} + +// SuggestionRequestEvent indicates that the editor in the entity +// wants suggestions for entities to insert. +type SuggestionRequestEvent[T any] struct { + // Text is the user composition to base the suggestions upon. + Text string +} + +// SuggestionCancelEvent indicates that the editor in the entity +// no longer wants suggestions for entities to insert. +type SuggestionCancelEvent[T any] struct{} + +// Suggestor displays suggestions for an editor. +type Suggestor[T comparable] struct { + Current int + Options widget.List + // After the first layout, there should always be one more clickable + // than entity. The final clickable is used for the "create new entity" + // option. + Clickables []widget.Clickable + Entities []T + events []event.Event +} + +// consume processes one of the suggestion-specific events like +// suggestionChooseEvent, suggestionNextEvent, and suggestionPreviousEvent. +func (s *Suggestor[T]) consume(event interface{}) { + switch event.(type) { + case suggestionNextEvent: + s.Current = (s.Current + 1) % len(s.Entities) + case suggestionPreviousEvent: + s.Current = (s.Current - 1 + len(s.Entities)) % len(s.Entities) + case suggestionChooseEvent: + if s.Current < len(s.Entities) { + s.events = append(s.events, SuggestionAcceptedEvent[T]{ + Suggestion: s.Entities[s.Current], + }) + } else if s.Current == len(s.Entities) { + s.events = append(s.events, SuggestionCreateNewEvent[T]{}) + } + } +} + +// Events returns any events generated since the previous call to Events. +func (s *Suggestor[T]) Events() []event.Event { + var out []event.Event + out, s.events = s.events, s.events[:0] + return out +} + +// Layout updates the state of the Suggestor with events from the gtx. +func (s *Suggestor[T]) Layout(gtx C) D { + for i := range s.Entities { + if i >= len(s.Clickables) { + s.Clickables = append(s.Clickables, widget.Clickable{}) + } + if s.Clickables[i].Clicked() { + s.events = append(s.events, SuggestionAcceptedEvent[T]{ + Suggestion: s.Entities[i], + }) + } + } + createIndex := len(s.Entities) + if createIndex > len(s.Clickables)-1 { + s.Clickables = append(s.Clickables, widget.Clickable{}) + } + if s.Clickables[createIndex].Clicked() { + s.events = append(s.events, SuggestionCreateNewEvent[T]{}) + } + if s.Current > len(s.Entities) { + s.Current = 0 + } + return D{} +} + +type TxChangedEvent struct{} + +func (TxChangedEvent) ImplementsEvent() {} + +type TxEditor struct { + Editor widget.Editor + Suggestor Suggestor[string] + suggesting bool + suggestionEvents []suggestionEvent + externalEvents []interface{} +} + +func (e *TxEditor) suggestEvents() []suggestionEvent { + var out []suggestionEvent + out, e.suggestionEvents = e.suggestionEvents, e.suggestionEvents[:0] + return out +} + +func (e *TxEditor) Events() []interface{} { + var out []interface{} + out, e.externalEvents = e.externalEvents, e.externalEvents[:0] + return out +} + +func (e *TxEditor) Suggesting() bool { + return e.suggesting +} + +func (e *TxEditor) SetSuggestions(suggestions []string) { + e.Suggestor.Entities = suggestions + log.Printf("set suggestions: %v", suggestions) +} + +// ApplySuggestion replaces what the user has typed with a properly-formed +// hyperlink to the suggested entry. +func (e *TxEditor) applySuggestion(event SuggestionAcceptedEvent[string]) { + e.suggesting = false + e.Editor.SetText(event.Suggestion) + e.externalEvents = append(e.externalEvents, TxChangedEvent{}) +} + +// ResolveSuggestionCreate should be invoked when the entity requested by +// SuggestionCreateNewEvent is created. +func (e *TxEditor) ResolveSuggestionCreate(createdTag string) { + e.applySuggestion(SuggestionAcceptedEvent[string]{ + Suggestion: createdTag, + }) + e.externalEvents = append(e.externalEvents, TxChangedEvent{}) +} + +// processBodyEvents is used to respond to interactions with the editor for +// the entity's body text. It expects a copy of all key events routed to +// the editor. +func (e *TxEditor) processBodyEvents(keyEvents []event.Event) []event.Event { + for i := 0; i < len(keyEvents); i++ { + event := keyEvents[i] + var dropEvent bool + switch event := event.(type) { + case key.Event: + switch event.Name { + case key.NameEscape: + e.externalEvents = append(e.externalEvents, SuggestionCancelEvent[string]{}) + e.suggesting = false + case key.NameUpArrow: + if e.suggesting { + dropEvent = true + if event.State == key.Press { + e.suggestionEvents = append(e.suggestionEvents, suggestionPreviousEvent{}) + } + } + case key.NameDownArrow: + if e.suggesting { + dropEvent = true + if event.State == key.Press { + e.suggestionEvents = append(e.suggestionEvents, suggestionNextEvent{}) + } + } + case key.NameEnter, "⏎": + if e.suggesting { + dropEvent = true + if event.State == key.Press { + e.suggestionEvents = append(e.suggestionEvents, suggestionChooseEvent{}) + } + } + } + } + if dropEvent { + if i+1 < len(keyEvents) { + keyEvents = append(keyEvents[:i], keyEvents[i+1:]...) + } else { + keyEvents = keyEvents[:i] + } + i-- + } + } + return keyEvents +} + +var suggestionKeys = strings.Join([]string{key.NameUpArrow, key.NameDownArrow, key.NameReturn, key.NameEnter, key.NameEscape}, "|") + +func (e *TxEditor) Layout(gtx C, editor layout.Widget) D { + // Check for changes within the editor during the last frame. + for _, event := range e.Editor.Events() { + switch event.(type) { + case widget.ChangeEvent: + if e.Editor.Text() != "" { + e.suggesting = true + op.InvalidateOp{}.Add(gtx.Ops) + } + if e.suggesting { + e.externalEvents = append(e.externalEvents, SuggestionRequestEvent[string]{ + Text: e.Editor.Text(), + }) + } + } + e.externalEvents = append(e.externalEvents, event) + } + // Intercept and filter events on their way to the editor. + tag := e.Editor.KeyTag() + editorKeys := gtx.Events(tag) + editorKeys = append(editorKeys, gtx.Events(e)...) + editorKeys = e.processBodyEvents(editorKeys) + + key.InputOp{ + Tag: e, + Keys: key.Set(suggestionKeys), + }.Add(gtx.Ops) + + // If interactions with the editor generated suggestion interactions, + // process them. + for _, event := range e.suggestEvents() { + e.Suggestor.consume(event) + } + + // If interactions with the suggestor generated results, process them. + for _, event := range e.Suggestor.Events() { + switch event := event.(type) { + case SuggestionAcceptedEvent[string]: + e.applySuggestion(event) + op.InvalidateOp{}.Add(gtx.Ops) + case SuggestionCreateNewEvent[string]: + text := e.Editor.Text() + e.externalEvents = append(e.externalEvents, SuggestionCreateNewEvent[string]{ + Text: text, + }) + op.InvalidateOp{}.Add(gtx.Ops) + } + } + gtx = eventx.Combine(gtx, &eventx.EventGroup{ + Tag: tag, + Items: editorKeys, + }) + return editor(gtx) +} + +func (suggestionNextEvent) isSuggestionEvent() {} +func (suggestionPreviousEvent) isSuggestionEvent() {} +func (suggestionChooseEvent) isSuggestionEvent() {} +func (SuggestionAcceptedEvent[T]) ImplementsEvent() {} +func (SuggestionCreateNewEvent[T]) ImplementsEvent() {} +func (SuggestionCancelEvent[T]) ImplementsEvent() {} +func (SuggestionRequestEvent[T]) ImplementsEvent() {} -- 2.45.2