~whereswaldon/rosebud

a19d9ed43ab494087b5ee8354d5836cc59ceb4b1 — Chris Waldon 1 year, 3 months ago 3aa7938
appwidget{,/apptheme}: import suggestion editor code

This code is borrowed from another of my projects.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
A appwidget/apptheme/apptheme.go => appwidget/apptheme/apptheme.go +8 -0
@@ 0,0 1,8 @@
package apptheme

import "gioui.org/layout"

type (
	C = layout.Context
	D = layout.Dimensions
)

A appwidget/apptheme/box.go => appwidget/apptheme/box.go +70 -0
@@ 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
	})
}

A appwidget/apptheme/suggestor.go => appwidget/apptheme/suggestor.go +95 -0
@@ 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)
						})
					}),
				)
			})
		})
	})
}

A appwidget/apptheme/theme.go => appwidget/apptheme/theme.go +93 -0
@@ 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,
	}
}

A appwidget/apptheme/tx-editor.go => appwidget/apptheme/tx-editor.go +67 -0
@@ 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
	})
}

A appwidget/appwidget.go => appwidget/appwidget.go +8 -0
@@ 0,0 1,8 @@
package appwidget

import "gioui.org/layout"

type (
	C = layout.Context
	D = layout.Dimensions
)

A appwidget/tx-editor.go => appwidget/tx-editor.go +280 -0
@@ 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()   {}