init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
package ui
import (
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rockorager/vaxis"
)
const (
BORDER_LEFT = 1 << iota
BORDER_TOP = 1 << iota
BORDER_RIGHT = 1 << iota
BORDER_BOTTOM = 1 << iota
)
type Bordered struct {
borders uint
content Drawable
uiConfig *config.UIConfig
}
func NewBordered(
content Drawable, borders uint, uiConfig *config.UIConfig,
) *Bordered {
b := &Bordered{
borders: borders,
content: content,
uiConfig: uiConfig,
}
return b
}
func (bordered *Bordered) Children() []Drawable {
return []Drawable{bordered.content}
}
func (bordered *Bordered) Invalidate() {
Invalidate()
}
func (bordered *Bordered) Draw(ctx *Context) {
x := 0
y := 0
width := ctx.Width()
height := ctx.Height()
style := bordered.uiConfig.GetStyle(config.STYLE_BORDER)
verticalChar := bordered.uiConfig.BorderCharVertical
horizontalChar := bordered.uiConfig.BorderCharHorizontal
if bordered.borders&BORDER_LEFT != 0 {
ctx.Fill(0, 0, 1, ctx.Height(), verticalChar, style)
x += 1
width -= 1
}
if bordered.borders&BORDER_TOP != 0 {
ctx.Fill(0, 0, ctx.Width(), 1, horizontalChar, style)
y += 1
height -= 1
}
if bordered.borders&BORDER_RIGHT != 0 {
ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), verticalChar, style)
width -= 1
}
if bordered.borders&BORDER_BOTTOM != 0 {
ctx.Fill(0, ctx.Height()-1, ctx.Width(), 1, horizontalChar, style)
height -= 1
}
subctx := ctx.Subcontext(x, y, width, height)
bordered.content.Draw(subctx)
}
func (bordered *Bordered) MouseEvent(localX int, localY int, event vaxis.Event) {
if content, ok := bordered.content.(Mouseable); ok {
content.MouseEvent(localX, localY, event)
}
}
+75
View File
@@ -0,0 +1,75 @@
package ui
import (
"strings"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rockorager/vaxis"
"github.com/mattn/go-runewidth"
)
type Box struct {
content Drawable
title string
borders string
uiConfig *config.UIConfig
}
func NewBox(
content Drawable, title, borders string, uiConfig *config.UIConfig,
) *Box {
if borders == "" || len(borders) < 8 {
borders = "││┌─┐└─┘"
}
b := &Box{
content: content,
title: title,
borders: borders,
uiConfig: uiConfig,
}
return b
}
func (b *Box) Draw(ctx *Context) {
w := ctx.Width()
h := ctx.Height()
style := b.uiConfig.GetStyle(config.STYLE_BORDER)
box := []rune(b.borders)
ctx.Fill(0, 0, 1, h, box[0], style)
ctx.Fill(w-1, 0, 1, h, box[1], style)
ctx.Printf(0, 0, style, "%c%s%c", box[2], strings.Repeat(string(box[3]), w-2), box[4])
ctx.Printf(0, h-1, style, "%c%s%c", box[5], strings.Repeat(string(box[6]), w-2), box[7])
if b.title != "" && w > 4 {
style = b.uiConfig.GetStyle(config.STYLE_TITLE)
title := runewidth.Truncate(b.title, w-4, "…")
ctx.Printf(2, 0, style, "%s", title)
}
subctx := ctx.Subcontext(1, 1, w-2, h-2)
b.content.Draw(subctx)
}
func (b *Box) Invalidate() {
b.content.Invalidate()
}
func (b *Box) MouseEvent(localX int, localY int, event vaxis.Event) {
if content, ok := b.content.(Mouseable); ok {
content.MouseEvent(localX, localY, event)
}
}
func (b *Box) Event(e vaxis.Event) bool {
if content, ok := b.content.(Interactive); ok {
return content.Event(e)
}
return false
}
func (b *Box) Focus(_ bool) {
}
+133
View File
@@ -0,0 +1,133 @@
package ui
import (
"fmt"
"git.sr.ht/~rockorager/vaxis"
)
// A context allows you to draw in a sub-region of the terminal
type Context struct {
window vaxis.Window
x, y int
onPopover func(*Popover)
}
func (ctx *Context) Width() int {
width, _ := ctx.window.Size()
return width
}
func (ctx *Context) Height() int {
_, height := ctx.window.Size()
return height
}
// returns the vaxis Window for this context
func (ctx *Context) Window() vaxis.Window {
return ctx.window
}
func NewContext(vx *vaxis.Vaxis, p func(*Popover)) *Context {
win := vx.Window()
return &Context{win, 0, 0, p}
}
func (ctx *Context) Subcontext(x, y, width, height int) *Context {
if x < 0 || y < 0 {
panic(fmt.Errorf("Attempted to create context with negative offset"))
}
win := ctx.window.New(x, y, width, height)
return &Context{win, ctx.x + x, ctx.y + y, ctx.onPopover}
}
func (ctx *Context) SetCell(x, y int, ch rune, style vaxis.Style) {
width, height := ctx.window.Size()
if x >= width || y >= height {
// no-op when dims are inadequate
return
}
ctx.window.SetCell(x, y, vaxis.Cell{
Character: vaxis.Character{
Grapheme: string(ch),
},
Style: style,
})
}
func (ctx *Context) Printf(x, y int, style vaxis.Style,
format string, a ...interface{},
) int {
width, height := ctx.window.Size()
if x >= width || y >= height {
// no-op when dims are inadequate
return 0
}
str := fmt.Sprintf(format, a...)
buf := StyledString(str)
ApplyAttrs(buf, style)
old_x := x
newline := func() bool {
x = old_x
y++
return y < height
}
for _, sr := range buf.Cells {
switch sr.Grapheme {
case "\n":
if !newline() {
return buf.Len()
}
case "\r":
x = old_x
default:
ctx.window.SetCell(x, y, sr)
x += sr.Width
if x == old_x+width {
if !newline() {
return buf.Len()
}
}
}
}
return buf.Len()
}
func (ctx *Context) Fill(x, y, width, height int, rune rune, style vaxis.Style) {
win := ctx.window.New(x, y, width, height)
win.Fill(vaxis.Cell{
Character: vaxis.Character{
Grapheme: string(rune),
Width: 1,
},
Style: style,
})
}
func (ctx *Context) SetCursor(x, y int, style vaxis.CursorStyle) {
ctx.window.ShowCursor(x, y, style)
}
func (ctx *Context) HideCursor() {
ctx.window.Vx.HideCursor()
}
func (ctx *Context) Popover(x, y, width, height int, d Drawable) {
ctx.onPopover(&Popover{
x: ctx.x + x,
y: ctx.y + y,
width: width,
height: height,
content: d,
})
}
func (ctx *Context) Size() (int, int) {
return ctx.window.Size()
}
+24
View File
@@ -0,0 +1,24 @@
package ui
import "git.sr.ht/~rockorager/vaxis"
type Fill struct {
Rune rune
Style vaxis.Style
}
func NewFill(f rune, s vaxis.Style) Fill {
return Fill{f, s}
}
func (f Fill) Draw(ctx *Context) {
for x := 0; x < ctx.Width(); x += 1 {
for y := 0; y < ctx.Height(); y += 1 {
ctx.SetCell(x, y, f.Rune, f.Style)
}
}
}
func (f Fill) Invalidate() {
// no-op
}
+257
View File
@@ -0,0 +1,257 @@
package ui
import (
"math"
"sync"
"git.sr.ht/~rockorager/vaxis"
)
type Grid struct {
rows []GridSpec
rowLayout []gridLayout
columns []GridSpec
columnLayout []gridLayout
// Protected by mutex
cells []*GridCell
mutex sync.RWMutex
}
const (
SIZE_EXACT = iota
SIZE_WEIGHT = iota
)
// Specifies the layout of a single row or column
type GridSpec struct {
// One of SIZE_EXACT or SIZE_WEIGHT
Strategy int
// If Strategy = SIZE_EXACT, this function returns the number of cells
// this row/col shall occupy. If SIZE_WEIGHT, the space left after all
// exact rows/cols are measured is distributed amongst the remainder
// weighted by the value returned by this function.
Size func() int
}
// Used to cache layout of each row/column
type gridLayout struct {
Offset int
Size int
}
type GridCell struct {
Row int
Column int
RowSpan int
ColSpan int
Content Drawable
}
func NewGrid() *Grid {
return &Grid{}
}
// MakeGrid creates a grid with the specified number of columns and rows. Each
// cell has a size of 1.
func MakeGrid(numRows, numCols, rowStrategy, colStrategy int) *Grid {
rows := make([]GridSpec, numRows)
for i := 0; i < numRows; i++ {
rows[i] = GridSpec{rowStrategy, Const(1)}
}
cols := make([]GridSpec, numCols)
for i := 0; i < numCols; i++ {
cols[i] = GridSpec{colStrategy, Const(1)}
}
return NewGrid().Rows(rows).Columns(cols)
}
func (cell *GridCell) At(row, col int) *GridCell {
cell.Row = row
cell.Column = col
return cell
}
func (cell *GridCell) Span(rows, cols int) *GridCell {
cell.RowSpan = rows
cell.ColSpan = cols
return cell
}
func (grid *Grid) Rows(spec []GridSpec) *Grid {
grid.rows = spec
return grid
}
func (grid *Grid) Columns(spec []GridSpec) *Grid {
grid.columns = spec
return grid
}
func (grid *Grid) Draw(ctx *Context) {
grid.reflow(ctx)
grid.mutex.RLock()
defer grid.mutex.RUnlock()
for _, cell := range grid.cells {
rows := grid.rowLayout[cell.Row : cell.Row+cell.RowSpan]
cols := grid.columnLayout[cell.Column : cell.Column+cell.ColSpan]
x := cols[0].Offset
y := rows[0].Offset
if x < 0 || y < 0 {
continue
}
width := 0
height := 0
for _, col := range cols {
width += col.Size
}
for _, row := range rows {
height += row.Size
}
if x+width > ctx.Width() {
width = ctx.Width() - x
}
if y+height > ctx.Height() {
height = ctx.Height() - y
}
if width <= 0 || height <= 0 {
continue
}
subctx := ctx.Subcontext(x, y, width, height)
if cell.Content != nil {
cell.Content.Draw(subctx)
}
}
}
func (grid *Grid) MouseEvent(localX int, localY int, event vaxis.Event) {
if event, ok := event.(vaxis.Mouse); ok {
grid.mutex.RLock()
defer grid.mutex.RUnlock()
for _, cell := range grid.cells {
rows := grid.rowLayout[cell.Row : cell.Row+cell.RowSpan]
cols := grid.columnLayout[cell.Column : cell.Column+cell.ColSpan]
x := cols[0].Offset
y := rows[0].Offset
width := 0
height := 0
for _, col := range cols {
width += col.Size
}
for _, row := range rows {
height += row.Size
}
if x <= localX && localX < x+width && y <= localY && localY < y+height {
switch content := cell.Content.(type) {
case MouseableDrawableInteractive:
content.MouseEvent(localX-x, localY-y, event)
case Mouseable:
content.MouseEvent(localX-x, localY-y, event)
case MouseHandler:
content.MouseEvent(localX-x, localY-y, event)
}
}
}
}
}
func (grid *Grid) reflow(ctx *Context) {
grid.rowLayout = nil
grid.columnLayout = nil
flow := func(specs *[]GridSpec, layouts *[]gridLayout, extent int) {
exact := 0
weight := 0
nweights := 0
for _, spec := range *specs {
if spec.Strategy == SIZE_EXACT {
exact += spec.Size()
} else if spec.Strategy == SIZE_WEIGHT {
nweights += 1
weight += spec.Size()
}
}
offset := 0
remainingExact := 0
if weight > 0 {
remainingExact = (extent - exact) % weight
}
for _, spec := range *specs {
layout := gridLayout{Offset: offset}
if spec.Strategy == SIZE_EXACT {
layout.Size = spec.Size()
} else if spec.Strategy == SIZE_WEIGHT {
proportion := float64(spec.Size()) / float64(weight)
size := proportion * float64(extent-exact)
if remainingExact > 0 {
extraExact := int(math.Ceil(proportion * float64(remainingExact)))
layout.Size = int(math.Floor(size)) + extraExact
remainingExact -= extraExact
} else {
layout.Size = int(math.Floor(size))
}
}
offset += layout.Size
*layouts = append(*layouts, layout)
}
}
flow(&grid.rows, &grid.rowLayout, ctx.Height())
flow(&grid.columns, &grid.columnLayout, ctx.Width())
}
func (grid *Grid) Invalidate() {
Invalidate()
}
func (grid *Grid) AddChild(content Drawable) *GridCell {
cell := &GridCell{
RowSpan: 1,
ColSpan: 1,
Content: content,
}
grid.mutex.Lock()
grid.cells = append(grid.cells, cell)
grid.mutex.Unlock()
grid.Invalidate()
return cell
}
func (grid *Grid) RemoveChild(content Drawable) {
grid.mutex.Lock()
for i, cell := range grid.cells {
if cell.Content == content {
grid.cells = append(grid.cells[:i], grid.cells[i+1:]...)
break
}
}
grid.mutex.Unlock()
grid.Invalidate()
}
func (grid *Grid) ReplaceChild(old Drawable, new Drawable) {
grid.mutex.Lock()
for i, cell := range grid.cells {
if cell.Content == old {
grid.cells[i] = &GridCell{
RowSpan: cell.RowSpan,
ColSpan: cell.ColSpan,
Row: cell.Row,
Column: cell.Column,
Content: new,
}
break
}
}
grid.mutex.Unlock()
grid.Invalidate()
}
func Const(i int) func() int {
return func() int { return i }
}
+68
View File
@@ -0,0 +1,68 @@
package ui
import (
"git.sr.ht/~rockorager/vaxis"
)
// Drawable is a UI component that can draw. Unless specified, all methods must
// only be called from a single goroutine, the UI goroutine.
type Drawable interface {
// Called when this renderable should draw itself.
Draw(ctx *Context)
// Invalidates the UI. This can be called from any goroutine.
Invalidate()
}
type Closeable interface {
Close()
}
type Visible interface {
// Indicate that this component is visible or not
Show(bool)
}
type Interactive interface {
// Returns true if the event was handled by this component
Event(event vaxis.Event) bool
// Indicates whether or not this control will receive input events
Focus(focus bool)
}
type Beeper interface {
OnBeep(func())
}
type DrawableInteractive interface {
Drawable
Interactive
}
type DrawableInteractiveBeeper interface {
DrawableInteractive
Beeper
}
// A drawable which contains other drawables
type Container interface {
Drawable
// Return all of the drawables which are children of this one (do not
// recurse into your grandchildren).
Children() []Drawable
}
type MouseHandler interface {
// Handle a mouse event which occurred at the local x and y positions
MouseEvent(localX int, localY int, event vaxis.Event)
}
// A drawable that can be interacted with by the mouse
type Mouseable interface {
Drawable
MouseHandler
}
type MouseableDrawableInteractive interface {
DrawableInteractive
MouseHandler
}
+57
View File
@@ -0,0 +1,57 @@
package ui
import "git.sr.ht/~rockorager/vaxis"
type Popover struct {
x, y, width, height int
content Drawable
}
func (p *Popover) Draw(ctx *Context) {
var subcontext *Context
// trim desired width to fit
width := p.width
if p.x+p.width > ctx.Width() {
width = ctx.Width() - p.x
}
switch {
case p.y+p.height+1 < ctx.Height():
// draw below
subcontext = ctx.Subcontext(p.x, p.y+1, width, p.height)
case p.y-p.height >= 0:
// draw above
subcontext = ctx.Subcontext(p.x, p.y-p.height, width, p.height)
default:
// can't fit entirely above or below, so find the largest available
// vertical space and shrink to fit
if p.y > ctx.Height()-p.y {
// there is more space above than below
height := p.y
subcontext = ctx.Subcontext(p.x, 0, width, height)
} else {
// there is more space below than above
height := ctx.Height() - p.y
subcontext = ctx.Subcontext(p.x, p.y+1, width, height-1)
}
}
p.content.Draw(subcontext)
}
func (p *Popover) Event(e vaxis.Event) bool {
if di, ok := p.content.(DrawableInteractive); ok {
return di.Event(e)
}
return false
}
func (p *Popover) Focus(f bool) {
if di, ok := p.content.(DrawableInteractive); ok {
di.Focus(f)
}
}
func (p *Popover) Invalidate() {
Invalidate()
}
+64
View File
@@ -0,0 +1,64 @@
package ui
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rockorager/vaxis"
)
type Stack struct {
children []Drawable
uiConfig *config.UIConfig
}
func NewStack(uiConfig *config.UIConfig) *Stack {
return &Stack{uiConfig: uiConfig}
}
func (stack *Stack) Children() []Drawable {
return stack.children
}
func (stack *Stack) Invalidate() {
Invalidate()
}
func (stack *Stack) Draw(ctx *Context) {
if len(stack.children) > 0 {
stack.Peek().Draw(ctx)
} else {
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
stack.uiConfig.GetStyle(config.STYLE_STACK))
}
}
func (stack *Stack) MouseEvent(localX int, localY int, event vaxis.Event) {
if len(stack.children) > 0 {
if element, ok := stack.Peek().(Mouseable); ok {
element.MouseEvent(localX, localY, event)
}
}
}
func (stack *Stack) Push(d Drawable) {
stack.children = append(stack.children, d)
stack.Invalidate()
}
func (stack *Stack) Pop() Drawable {
if len(stack.children) == 0 {
panic(fmt.Errorf("Tried to pop from an empty UI stack"))
}
d := stack.children[len(stack.children)-1]
stack.children = stack.children[:len(stack.children)-1]
stack.Invalidate()
return d
}
func (stack *Stack) Peek() Drawable {
if len(stack.children) == 0 {
panic(fmt.Errorf("Tried to peek from an empty stack"))
}
return stack.children[len(stack.children)-1]
}
+142
View File
@@ -0,0 +1,142 @@
package ui
import (
"git.sr.ht/~rockorager/vaxis"
)
func StyledString(s string) *vaxis.StyledString {
return state.vx.NewStyledString(s, vaxis.Style{})
}
// Applies a style to a string. Any currently applied styles will not be overwritten
func ApplyStyle(style vaxis.Style, str string) string {
ss := StyledString(str)
d := vaxis.Style{}
for i, sr := range ss.Cells {
if sr.Style == d {
sr.Style = style
ss.Cells[i] = sr
}
}
return ss.Encode()
}
// PadLeft inserts blank spaces at the beginning of the StyledString to produce
// a string of the provided width
func PadLeft(ss *vaxis.StyledString, width int) {
w := ss.Len()
if w >= width {
return
}
cell := vaxis.Cell{
Character: vaxis.Character{
Grapheme: " ",
Width: 1,
},
}
w = width - w
cells := make([]vaxis.Cell, 0, len(ss.Cells)+w)
for w > 0 {
cells = append(cells, cell)
w -= 1
}
cells = append(cells, ss.Cells...)
ss.Cells = cells
}
// PadLeft inserts blank spaces at the end of the StyledString to produce
// a string of the provided width
func PadRight(ss *vaxis.StyledString, width int) {
w := ss.Len()
if w >= width {
return
}
cell := vaxis.Cell{
Character: vaxis.Character{
Grapheme: " ",
Width: 1,
},
}
w = width - w
for w > 0 {
w -= 1
ss.Cells = append(ss.Cells, cell)
}
}
// ApplyAttrs applies the style, and if another style is present ORs the
// attributes
func ApplyAttrs(ss *vaxis.StyledString, style vaxis.Style) {
for i, cell := range ss.Cells {
if style.Foreground != 0 {
cell.Style.Foreground = style.Foreground
}
if style.Background != 0 {
cell.Style.Background = style.Background
}
cell.Style.Attribute |= style.Attribute
if style.UnderlineColor != 0 {
cell.Style.UnderlineColor = style.UnderlineColor
}
if style.UnderlineStyle != vaxis.UnderlineOff {
cell.Style.UnderlineStyle = style.UnderlineStyle
}
ss.Cells[i] = cell
}
}
// Truncates the styled string on the right and inserts a '…' as the last
// character
func Truncate(ss *vaxis.StyledString, width int) {
if ss.Len() <= width {
return
}
cells := make([]vaxis.Cell, 0, len(ss.Cells))
total := 0
for _, cell := range ss.Cells {
if total+cell.Width >= width {
// we can't fit this cell so put in our truncator
cells = append(cells, vaxis.Cell{
Character: vaxis.Character{
Grapheme: "…",
Width: 1,
},
Style: cell.Style,
})
break
}
total += cell.Width
cells = append(cells, cell)
}
ss.Cells = cells
}
// TruncateHead truncates the left side of the string and inserts '…' as the
// first character
func TruncateHead(ss *vaxis.StyledString, width int) {
l := ss.Len()
if l <= width {
return
}
offset := l - width
cells := make([]vaxis.Cell, 0, len(ss.Cells))
cells = append(cells, vaxis.Cell{
Character: vaxis.Character{
Grapheme: "…",
Width: 1,
},
})
total := 0
for _, cell := range ss.Cells {
total += cell.Width
if total < offset {
// we always have at least one for our truncator. We
// copy this cells style to it so that it retains the
// style information from the first printed cell
cells[0].Style = cell.Style
continue
}
cells = append(cells, cell)
}
ss.Cells = cells
}
+504
View File
@@ -0,0 +1,504 @@
package ui
import (
"sync"
"github.com/mattn/go-runewidth"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rockorager/vaxis"
)
const tabRuneWidth int = 32 // TODO: make configurable
type Tabs struct {
tabs []*Tab
TabStrip *TabStrip
TabContent *TabContent
curIndex int
history []*Tab
m sync.Mutex
ui func(d Drawable) *config.UIConfig
parent *Tabs //nolint:structcheck // used within this file
CloseTab func(index int)
}
type Tab struct {
Content Drawable
Name string
pinned bool
indexBeforePin int
title string
}
func (t *Tab) SetTitle(s string) {
t.title = s
}
func (t *Tab) displayName(pinMarker string) string {
name := t.Name
if t.title != "" {
name = t.title
}
if t.pinned {
name = pinMarker + name
}
return name
}
type (
TabStrip Tabs
TabContent Tabs
)
func NewTabs(ui func(d Drawable) *config.UIConfig) *Tabs {
tabs := &Tabs{ui: ui}
tabs.TabStrip = (*TabStrip)(tabs)
tabs.TabStrip.parent = tabs
tabs.TabContent = (*TabContent)(tabs)
tabs.TabContent.parent = tabs
return tabs
}
func (tabs *Tabs) Add(content Drawable, name string, background bool) *Tab {
tab := &Tab{
Content: content,
Name: name,
}
tabs.tabs = append(tabs.tabs, tab)
if !background {
tabs.selectPriv(len(tabs.tabs)-1, true)
}
return tab
}
func (tabs *Tabs) Names() []string {
var names []string
tabs.m.Lock()
for _, tab := range tabs.tabs {
names = append(names, tab.Name)
}
tabs.m.Unlock()
return names
}
func (tabs *Tabs) Remove(content Drawable) {
tabs.m.Lock()
defer tabs.m.Unlock()
index := -1
for i, tab := range tabs.tabs {
if tab.Content == content {
index = i
break
}
}
if index == -1 {
return
}
tab := tabs.tabs[index]
if vis, ok := tab.Content.(Visible); ok {
vis.Show(false)
}
if vis, ok := tab.Content.(Interactive); ok {
vis.Focus(false)
}
tabs.tabs = append(tabs.tabs[:index], tabs.tabs[index+1:]...)
tabs.removeHistory(tab)
if index == tabs.curIndex {
// only pop the tab history if the closing tab is selected
prevIndex, ok := tabs.popHistory()
if !ok {
if tabs.curIndex < len(tabs.tabs) {
// history is empty, select tab on the right if possible
prevIndex = tabs.curIndex
} else {
// if removing the last tab, select the now last tab
prevIndex = len(tabs.tabs) - 1
}
}
tabs.selectPriv(prevIndex, false)
} else if index < tabs.curIndex {
// selected tab is now one to the left of where it was
tabs.selectPriv(tabs.curIndex-1, false)
}
Invalidate()
}
func (tabs *Tabs) Replace(contentSrc Drawable, contentTarget Drawable, name string) {
tabs.m.Lock()
defer tabs.m.Unlock()
for i, tab := range tabs.tabs {
if tab.Content == contentSrc {
if vis, ok := tab.Content.(Visible); ok {
vis.Show(false)
}
if vis, ok := tab.Content.(Interactive); ok {
vis.Focus(false)
}
tab.Content = contentTarget
tabs.selectPriv(i, false)
Invalidate()
break
}
}
}
func (tabs *Tabs) Get(index int) *Tab {
tabs.m.Lock()
defer tabs.m.Unlock()
if index < 0 || index >= len(tabs.tabs) {
return nil
}
return tabs.tabs[index]
}
func (tabs *Tabs) Selected() *Tab {
tabs.m.Lock()
defer tabs.m.Unlock()
if tabs.curIndex < 0 || tabs.curIndex >= len(tabs.tabs) {
return nil
}
return tabs.tabs[tabs.curIndex]
}
func (tabs *Tabs) Select(index int) bool {
tabs.m.Lock()
defer tabs.m.Unlock()
return tabs.selectPriv(index, true)
}
func (tabs *Tabs) selectPriv(index int, unselectPrev bool) bool {
if index < 0 || index >= len(tabs.tabs) {
return false
}
// only push valid tabs onto the history
if unselectPrev && tabs.curIndex < len(tabs.tabs) {
prev := tabs.tabs[tabs.curIndex]
if vis, ok := prev.Content.(Visible); ok {
vis.Show(false)
}
if vis, ok := prev.Content.(Interactive); ok {
vis.Focus(false)
}
tabs.pushHistory(prev)
}
next := tabs.tabs[index]
if vis, ok := next.Content.(Visible); ok {
vis.Show(true)
}
if vis, ok := next.Content.(Interactive); ok {
vis.Focus(true)
}
tabs.curIndex = index
Invalidate()
return true
}
func (tabs *Tabs) SelectName(name string) bool {
tabs.m.Lock()
defer tabs.m.Unlock()
for i, tab := range tabs.tabs {
if tab.Name == name {
return tabs.selectPriv(i, true)
}
}
return false
}
func (tabs *Tabs) SelectPrevious() bool {
tabs.m.Lock()
defer tabs.m.Unlock()
index, ok := tabs.popHistory()
if !ok {
return false
}
return tabs.selectPriv(index, true)
}
func (tabs *Tabs) SelectOffset(offset int) {
tabs.m.Lock()
tabCount := len(tabs.tabs)
newIndex := (tabs.curIndex + offset) % tabCount
if newIndex < 0 {
// Handle negative offsets correctly
newIndex += tabCount
}
tabs.selectPriv(newIndex, true)
tabs.m.Unlock()
}
func (tabs *Tabs) MoveTab(to int, relative bool) {
tabs.m.Lock()
tabs.moveTabPriv(to, relative)
tabs.m.Unlock()
}
func (tabs *Tabs) moveTabPriv(to int, relative bool) {
from := tabs.curIndex
if relative {
to = from + to
}
if to < 0 {
to = 0
}
if to >= len(tabs.tabs) {
to = len(tabs.tabs) - 1
}
tabs.tabs[from], tabs.tabs[to] = tabs.tabs[to], tabs.tabs[from]
tabs.curIndex = to
Invalidate()
}
func (tabs *Tabs) PinTab() {
tabs.m.Lock()
defer tabs.m.Unlock()
if tabs.tabs[tabs.curIndex].pinned {
return
}
pinEnd := len(tabs.tabs)
for i, t := range tabs.tabs {
if !t.pinned {
pinEnd = i
break
}
}
for _, t := range tabs.tabs {
if t.pinned && t.indexBeforePin > tabs.curIndex-pinEnd {
t.indexBeforePin -= 1
}
}
tabs.tabs[tabs.curIndex].pinned = true
tabs.tabs[tabs.curIndex].indexBeforePin = tabs.curIndex - pinEnd
tabs.moveTabPriv(pinEnd, false)
}
func (tabs *Tabs) UnpinTab() {
tabs.m.Lock()
defer tabs.m.Unlock()
if !tabs.tabs[tabs.curIndex].pinned {
return
}
pinEnd := len(tabs.tabs)
for i, t := range tabs.tabs {
if i != tabs.curIndex && t.pinned && t.indexBeforePin > tabs.tabs[tabs.curIndex].indexBeforePin {
t.indexBeforePin += 1
}
if !t.pinned {
pinEnd = i
break
}
}
tabs.tabs[tabs.curIndex].pinned = false
tabs.moveTabPriv(tabs.tabs[tabs.curIndex].indexBeforePin+pinEnd-1, false)
}
func (tabs *Tabs) NextTab() {
tabs.m.Lock()
next := tabs.curIndex + 1
if next >= len(tabs.tabs) {
next = 0
}
tabs.selectPriv(next, true)
tabs.m.Unlock()
}
func (tabs *Tabs) PrevTab() {
tabs.m.Lock()
next := tabs.curIndex - 1
if next < 0 {
next = len(tabs.tabs) - 1
}
tabs.selectPriv(next, true)
tabs.m.Unlock()
}
const maxHistory = 256
func (tabs *Tabs) pushHistory(tab *Tab) {
tabs.history = append(tabs.history, tab)
if len(tabs.history) > maxHistory {
tabs.history = tabs.history[1:]
}
}
func (tabs *Tabs) popHistory() (int, bool) {
if len(tabs.history) == 0 {
return -1, false
}
tab := tabs.history[len(tabs.history)-1]
tabs.history = tabs.history[:len(tabs.history)-1]
index := -1
for i, t := range tabs.tabs {
if t == tab {
index = i
break
}
}
if index == -1 {
return -1, false
}
return index, true
}
func (tabs *Tabs) removeHistory(tab *Tab) {
var newHist []*Tab
for _, item := range tabs.history {
if item != tab {
newHist = append(newHist, item)
}
}
tabs.history = newHist
}
// TODO: Color repository
func (strip *TabStrip) Draw(ctx *Context) {
x := 0
strip.parent.m.Lock()
for i, tab := range strip.tabs {
uiConfig := strip.ui(tab.Content)
if uiConfig == nil {
uiConfig = config.Ui
}
style := uiConfig.GetStyle(config.STYLE_TAB)
if strip.curIndex == i {
style = uiConfig.GetStyleSelected(config.STYLE_TAB)
}
tabWidth := tabRuneWidth
if ctx.Width()-x < tabWidth {
tabWidth = ctx.Width() - x - 2
}
name := tab.displayName(uiConfig.PinnedTabMarker)
trunc := runewidth.Truncate(name, tabWidth, "…")
x += ctx.Printf(x, 0, style, " %s ", trunc)
if x >= ctx.Width() {
break
}
}
strip.parent.m.Unlock()
ctx.Fill(x, 0, ctx.Width()-x, 1, ' ',
config.Ui.GetStyle(config.STYLE_TAB))
}
func (strip *TabStrip) Invalidate() {
Invalidate()
}
func (strip *TabStrip) MouseEvent(localX int, localY int, event vaxis.Event) {
strip.parent.m.Lock()
defer strip.parent.m.Unlock()
changeFocus := func(focus bool) {
interactive, ok := strip.parent.tabs[strip.parent.curIndex].Content.(Interactive)
if ok {
interactive.Focus(focus)
}
}
unfocus := func() { changeFocus(false) }
refocus := func() { changeFocus(true) }
if event, ok := event.(vaxis.Mouse); ok {
switch event.Button {
case vaxis.MouseLeftButton:
selectedTab, ok := strip.clicked(localX, localY)
if !ok || selectedTab == strip.parent.curIndex {
return
}
unfocus()
strip.parent.selectPriv(selectedTab, true)
refocus()
case vaxis.MouseWheelDown:
unfocus()
index := strip.parent.curIndex + 1
if index >= len(strip.parent.tabs) {
index = 0
}
strip.parent.selectPriv(index, true)
refocus()
case vaxis.MouseWheelUp:
unfocus()
index := strip.parent.curIndex - 1
if index < 0 {
index = len(strip.parent.tabs) - 1
}
strip.parent.selectPriv(index, true)
refocus()
case vaxis.MouseMiddleButton:
selectedTab, ok := strip.clicked(localX, localY)
if !ok {
return
}
unfocus()
strip.parent.m.Unlock()
strip.parent.CloseTab(selectedTab)
strip.parent.m.Lock()
refocus()
}
}
}
func (strip *TabStrip) clicked(mouseX int, mouseY int) (int, bool) {
x := 0
for i, tab := range strip.tabs {
uiConfig := strip.ui(tab.Content)
if uiConfig == nil {
uiConfig = config.Ui
}
name := tab.displayName(uiConfig.PinnedTabMarker)
trunc := runewidth.Truncate(name, tabRuneWidth, "…")
length := runewidth.StringWidth(trunc) + 2
if x <= mouseX && mouseX < x+length {
return i, true
}
x += length
}
return 0, false
}
func (content *TabContent) Children() []Drawable {
content.parent.m.Lock()
children := make([]Drawable, len(content.tabs))
for i, tab := range content.tabs {
children[i] = tab.Content
}
content.parent.m.Unlock()
return children
}
func (content *TabContent) Draw(ctx *Context) {
content.parent.m.Lock()
if content.curIndex >= len(content.tabs) {
width := ctx.Width()
height := ctx.Height()
ctx.Fill(0, 0, width, height, ' ',
config.Ui.GetStyle(config.STYLE_TAB))
}
tab := content.tabs[content.curIndex]
content.parent.m.Unlock()
tab.Content.Draw(ctx)
}
func (content *TabContent) MouseEvent(localX int, localY int, event vaxis.Event) {
content.parent.m.Lock()
tab := content.tabs[content.curIndex]
content.parent.m.Unlock()
if tabContent, ok := tab.Content.(Mouseable); ok {
tabContent.MouseEvent(localX, localY, event)
}
}
func (content *TabContent) Invalidate() {
Invalidate()
}
+220
View File
@@ -0,0 +1,220 @@
package ui
import (
"math"
"regexp"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rockorager/vaxis"
"github.com/mattn/go-runewidth"
)
type Table struct {
Columns []Column
Rows []Row
Height int
// Optional callback that allows customizing the default drawing routine
// of table rows. If true is returned, the default routine is skipped.
CustomDraw func(t *Table, row int, c *Context) bool
// Optional callback that allows returning a custom style for the row.
GetRowStyle func(t *Table, row int) vaxis.Style
// true if at least one column has WIDTH_FIT
autoFitWidths bool
// if false, widths need to be computed before drawing
widthsComputed bool
}
type Column struct {
Offset int
Width int
Def *config.ColumnDef
Separator string
}
type Row struct {
Cells []string
Priv interface{}
}
func NewTable(
height int,
columnDefs []*config.ColumnDef, separator string,
customDraw func(*Table, int, *Context) bool,
getRowStyle func(*Table, int) vaxis.Style,
) Table {
if customDraw == nil {
customDraw = func(*Table, int, *Context) bool { return false }
}
if getRowStyle == nil {
getRowStyle = func(*Table, int) vaxis.Style {
return vaxis.Style{}
}
}
columns := make([]Column, len(columnDefs))
autoFitWidths := false
for c, col := range columnDefs {
if col.Flags.Has(config.WIDTH_FIT) {
autoFitWidths = true
}
columns[c] = Column{Def: col}
if c != len(columns)-1 {
// set separator for all columns except the last one
columns[c].Separator = separator
}
}
return Table{
Columns: columns,
Height: height,
CustomDraw: customDraw,
GetRowStyle: getRowStyle,
autoFitWidths: autoFitWidths,
}
}
// add a row to the table, returns true when the table is full
func (t *Table) AddRow(cells []string, priv interface{}) bool {
if len(cells) != len(t.Columns) {
panic("invalid number of cells")
}
if len(t.Rows) >= t.Height {
return true
}
t.Rows = append(t.Rows, Row{Cells: cells, Priv: priv})
if t.autoFitWidths {
t.widthsComputed = false
}
return len(t.Rows) >= t.Height
}
func (t *Table) computeWidths(width int) {
contentMaxWidths := make([]int, len(t.Columns))
if t.autoFitWidths {
for _, row := range t.Rows {
for c := range t.Columns {
buf := StyledString(row.Cells[c])
if buf.Len() > contentMaxWidths[c] {
contentMaxWidths[c] = buf.Len()
}
}
}
}
nonFixed := width
autoWidthCount := 0
for c := range t.Columns {
col := &t.Columns[c]
switch {
case col.Def.Flags.Has(config.WIDTH_FIT):
col.Width = contentMaxWidths[c]
// compensate for exact width columns
col.Width += runewidth.StringWidth(col.Separator)
case col.Def.Flags.Has(config.WIDTH_EXACT):
col.Width = int(math.Round(col.Def.Width))
// compensate for exact width columns
col.Width += runewidth.StringWidth(col.Separator)
case col.Def.Flags.Has(config.WIDTH_AUTO):
col.Width = 0
autoWidthCount += 1
case col.Def.Flags.Has(config.WIDTH_FRACTION):
col.Width = int(math.Round(float64(width) * col.Def.Width))
}
nonFixed -= col.Width
}
autoWidth := 0
if autoWidthCount > 0 && nonFixed > 0 {
autoWidth = nonFixed / autoWidthCount
if autoWidth == 0 {
autoWidth = 1
}
}
offset := 0
remain := width
for c := range t.Columns {
col := &t.Columns[c]
if col.Def.Flags.Has(config.WIDTH_AUTO) && autoWidth > 0 {
col.Width = autoWidth
if nonFixed >= 2*autoWidth {
nonFixed -= autoWidth
}
}
if remain == 0 {
// column is outside of screen
col.Width = -1
} else if col.Width > remain {
// limit width to avoid overflow
col.Width = remain
}
remain -= col.Width
col.Offset = offset
offset += col.Width
// reserve room for separator
col.Width -= runewidth.StringWidth(col.Separator)
}
}
var metaCharsRegexp = regexp.MustCompile(`[\t\r\f\n\v]`)
func (col *Column) alignCell(cell string) string {
cell = metaCharsRegexp.ReplaceAllString(cell, " ")
buf := StyledString(cell)
width := buf.Len()
switch {
case col.Def.Flags.Has(config.ALIGN_LEFT):
if width < col.Width {
PadRight(buf, col.Width)
cell = buf.Encode()
} else if width > col.Width {
Truncate(buf, col.Width)
cell = buf.Encode()
}
case col.Def.Flags.Has(config.ALIGN_CENTER):
if width < col.Width {
pad := col.Width - width
PadLeft(buf, col.Width-(pad/2))
PadRight(buf, col.Width)
cell = buf.Encode()
} else if width > col.Width {
Truncate(buf, col.Width)
cell = buf.Encode()
}
case col.Def.Flags.Has(config.ALIGN_RIGHT):
if width < col.Width {
PadLeft(buf, col.Width)
cell = buf.Encode()
} else if width > col.Width {
TruncateHead(buf, col.Width)
cell = buf.Encode()
}
}
return cell
}
func (t *Table) Draw(ctx *Context) {
if !t.widthsComputed {
t.computeWidths(ctx.Width())
t.widthsComputed = true
}
for r, row := range t.Rows {
if t.CustomDraw(t, r, ctx) {
continue
}
for c, col := range t.Columns {
if col.Width == -1 {
// column overflows screen width
continue
}
cell := col.alignCell(row.Cells[c])
style := t.GetRowStyle(t, r)
buf := StyledString(cell)
ApplyAttrs(buf, style)
cell = buf.Encode()
ctx.Printf(col.Offset, r, style, "%s%s", cell, col.Separator)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package ui
import (
"git.sr.ht/~rockorager/vaxis"
"github.com/mattn/go-runewidth"
)
const (
TEXT_LEFT = iota
TEXT_CENTER = iota
TEXT_RIGHT = iota
)
type Text struct {
text string
strategy uint
style vaxis.Style
}
func NewText(text string, style vaxis.Style) *Text {
return &Text{
text: text,
style: style,
}
}
func (t *Text) Text(text string) *Text {
t.text = text
t.Invalidate()
return t
}
func (t *Text) Strategy(strategy uint) *Text {
t.strategy = strategy
t.Invalidate()
return t
}
func (t *Text) Draw(ctx *Context) {
size := runewidth.StringWidth(t.text)
x := 0
if t.strategy == TEXT_CENTER {
x = (ctx.Width() - size) / 2
}
if t.strategy == TEXT_RIGHT {
x = ctx.Width() - size
}
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', t.style)
ctx.Printf(x, 0, t.style, "%s", t.text)
}
func (t *Text) Invalidate() {
Invalidate()
}
+621
View File
@@ -0,0 +1,621 @@
package ui
import (
"context"
"math"
"strings"
"sync"
"time"
"github.com/mattn/go-runewidth"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
)
// TODO: Attach history providers
type TextInput struct {
sync.Mutex
cells int
ctx *Context
focus bool
index int
password bool
prompt string
scroll int
text []vaxis.Character
change []func(ti *TextInput)
focusLost []func(ti *TextInput)
tabcomplete func(ctx context.Context, s string) ([]opt.Completion, string)
tabcompleteCancel context.CancelFunc
completions []opt.Completion
prefix string
completeIndex int
completeDelay time.Duration
completeDebouncer *time.Timer
completeMinChars int
completeKey *config.KeyStroke
uiConfig *config.UIConfig
}
// Creates a new TextInput. TextInputs will render a "textbox" in the entire
// context they're given, and process keypresses to build a string from user
// input.
func NewTextInput(text string, ui *config.UIConfig) *TextInput {
chars := vaxis.Characters(text)
return &TextInput{
cells: -1,
text: chars,
index: len(chars),
uiConfig: ui,
tabcompleteCancel: func() {},
}
}
func (ti *TextInput) Password(password bool) *TextInput {
ti.password = password
return ti
}
func (ti *TextInput) Prompt(prompt string) *TextInput {
ti.prompt = prompt
return ti
}
func (ti *TextInput) TabComplete(
tabcomplete func(ctx context.Context, s string) ([]opt.Completion, string),
d time.Duration, minChars int, key *config.KeyStroke,
) *TextInput {
ti.tabcomplete = tabcomplete
ti.completeDelay = d
ti.completeMinChars = minChars
ti.completeKey = key
return ti
}
func (ti *TextInput) String() string {
return charactersToString(ti.text)
}
func (ti *TextInput) StringLeft() string {
if ti.index > len(ti.text) {
ti.index = len(ti.text)
}
left := ti.text[:ti.index]
return charactersToString(left)
}
func (ti *TextInput) StringRight() string {
if ti.index >= len(ti.text) {
return ""
}
right := ti.text[ti.index:]
return charactersToString(right)
}
func charactersToString(chars []vaxis.Character) string {
buf := strings.Builder{}
for _, ch := range chars {
buf.WriteString(ch.Grapheme)
}
return buf.String()
}
func (ti *TextInput) Set(value string) *TextInput {
ti.text = vaxis.Characters(value)
ti.index = len(ti.text)
ti.scroll = 0
return ti
}
func (ti *TextInput) Invalidate() {
Invalidate()
}
func (ti *TextInput) Draw(ctx *Context) {
scroll := 0
if ti.focus {
ti.ensureScroll()
scroll = ti.scroll
}
ti.ctx = ctx // gross
defaultStyle := ti.uiConfig.GetStyle(config.STYLE_DEFAULT)
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
text := ti.text[scroll:]
sindex := ti.index - scroll
if ti.password {
x := ctx.Printf(0, 0, defaultStyle, "%s", ti.prompt)
cells := len(ti.text)
ctx.Fill(x, 0, cells, 1, '*', defaultStyle)
} else {
ctx.Printf(0, 0, defaultStyle, "%s%s", ti.prompt, charactersToString(text))
}
cells := runewidth.StringWidth(charactersToString(text[:sindex]) + ti.prompt)
if ti.focus {
ctx.SetCursor(cells, 0, vaxis.CursorDefault)
ti.drawPopover(ctx)
}
}
func (ti *TextInput) drawPopover(ctx *Context) {
if len(ti.completions) == 0 {
return
}
valWidth := 0
descWidth := 0
for _, c := range ti.completions {
valWidth = max(valWidth, runewidth.StringWidth(unquote(c.Value)))
descWidth = max(descWidth, runewidth.StringWidth(c.Description))
}
descWidth = min(descWidth, 80)
// one space padding
width := 1 + valWidth
if descWidth != 0 {
// two spaces padding + parentheses
width += 2 + descWidth + 2
}
// one space padding + gutter
width += 2
cmp := &completions{ti: ti, valWidth: valWidth, descWidth: descWidth}
height := len(ti.completions)
pos := len(ti.prefix) - ti.scroll
if pos+width > ctx.Width() {
pos = ctx.Width() - width
}
if pos < 0 {
pos = 0
}
ctx.Popover(pos, 0, width, height, cmp)
}
func (ti *TextInput) MouseEvent(localX int, localY int, event vaxis.Event) {
if event, ok := event.(vaxis.Mouse); ok {
if event.Button == vaxis.MouseLeftButton {
if localX >= len(ti.prompt)+1 && localX <= len(ti.text[ti.scroll:])+len(ti.prompt)+1 {
ti.index = localX - len(ti.prompt) - 1
ti.ensureScroll()
ti.Invalidate()
}
}
}
}
func (ti *TextInput) Focus(focus bool) {
if ti.focus && !focus {
ti.onFocusLost()
}
ti.focus = focus
if focus && ti.ctx != nil {
cells := runewidth.StringWidth(charactersToString(ti.text[:ti.index]))
ti.ctx.SetCursor(cells+1, 0, vaxis.CursorDefault)
} else if !focus && ti.ctx != nil {
ti.ctx.HideCursor()
}
}
func (ti *TextInput) ensureScroll() {
if ti.ctx == nil {
return
}
w := ti.ctx.Width() - len(ti.prompt)
if ti.index >= ti.scroll+w {
ti.scroll = ti.index - w + 1
}
if ti.index < ti.scroll {
ti.scroll = ti.index
}
}
func (ti *TextInput) insert(ch vaxis.Character) {
left := ti.text[:ti.index]
right := ti.text[ti.index:]
ti.text = append(left, append([]vaxis.Character{ch}, right...)...) //nolint:gocritic // intentional append to different slice
ti.index++
ti.ensureScroll()
ti.Invalidate()
ti.onChange()
}
func (ti *TextInput) deleteWord() {
if len(ti.text) == 0 || ti.index <= 0 {
return
}
separators := "/'\""
i := ti.index - 1
for i >= 0 && ti.text[i].Grapheme == " " {
i--
}
if i >= 0 && strings.Contains(separators, ti.text[i].Grapheme) {
for i >= 0 && strings.Contains(separators, ti.text[i].Grapheme) {
i--
}
} else {
separators += " "
for i >= 0 && !strings.Contains(separators, ti.text[i].Grapheme) {
i--
}
}
ti.text = append(ti.text[:i+1], ti.text[ti.index:]...)
ti.index = i + 1
ti.ensureScroll()
ti.Invalidate()
ti.onChange()
}
func (ti *TextInput) deleteLineForward() {
if len(ti.text) == 0 || len(ti.text) == ti.index {
return
}
ti.text = ti.text[:ti.index]
ti.ensureScroll()
ti.Invalidate()
ti.onChange()
}
func (ti *TextInput) deleteLineBackward() {
if len(ti.text) == 0 || ti.index == 0 {
return
}
ti.text = ti.text[ti.index:]
ti.index = 0
ti.ensureScroll()
ti.Invalidate()
ti.onChange()
}
func (ti *TextInput) deleteChar() {
if len(ti.text) > 0 && ti.index != len(ti.text) {
ti.text = append(ti.text[:ti.index], ti.text[ti.index+1:]...)
ti.ensureScroll()
ti.Invalidate()
ti.onChange()
}
}
func (ti *TextInput) backspace() {
if len(ti.text) > 0 && ti.index != 0 {
ti.text = append(ti.text[:ti.index-1], ti.text[ti.index:]...)
ti.index--
ti.ensureScroll()
ti.Invalidate()
ti.onChange()
}
}
func (ti *TextInput) executeCompletion() {
if len(ti.completions) > 0 {
ti.Set(ti.prefix + ti.completions[ti.completeIndex].Value + ti.StringRight())
}
}
func (ti *TextInput) invalidateCompletions() {
ti.completions = nil
}
func (ti *TextInput) onChange() {
ti.updateCompletions()
for _, change := range ti.change {
change(ti)
}
}
func (ti *TextInput) onFocusLost() {
for _, focusLost := range ti.focusLost {
focusLost(ti)
}
}
func (ti *TextInput) updateCompletions() {
if ti.tabcomplete == nil {
// no completer
return
}
if ti.completeMinChars == config.MANUAL_COMPLETE {
// only manually triggered completion
return
}
if ti.completeDebouncer == nil {
ti.completeDebouncer = time.AfterFunc(ti.completeDelay, func() {
defer log.PanicHandler()
ti.Lock()
if len(ti.StringLeft()) >= ti.completeMinChars {
ti.showCompletions(false)
}
ti.Unlock()
})
} else {
ti.completeDebouncer.Stop()
ti.completeDebouncer.Reset(ti.completeDelay)
}
}
func (ti *TextInput) showCompletions(explicit bool) {
if ti.tabcomplete == nil {
// no completer
return
}
if ti.tabcompleteCancel != nil {
// Cancel any inflight completions we currently have
ti.tabcompleteCancel()
}
ctx, cancel := context.WithCancel(context.Background())
ti.tabcompleteCancel = cancel
go func() {
defer log.PanicHandler()
matches, prefix := ti.tabcomplete(ctx, ti.StringLeft())
select {
case <-ctx.Done():
return
default:
ti.Lock()
defer ti.Unlock()
ti.completions = matches
ti.prefix = prefix
if explicit && len(ti.completions) == 1 {
// automatically accept if there is only one choice
ti.completeIndex = 0
ti.executeCompletion()
ti.invalidateCompletions()
} else {
ti.completeIndex = -1
}
Invalidate()
}
}()
}
func (ti *TextInput) OnChange(onChange func(ti *TextInput)) {
ti.change = append(ti.change, onChange)
}
func (ti *TextInput) OnFocusLost(onFocusLost func(ti *TextInput)) {
ti.focusLost = append(ti.focusLost, onFocusLost)
}
func (ti *TextInput) Event(event vaxis.Event) bool {
ti.Lock()
defer ti.Unlock()
if key, ok := event.(vaxis.Key); ok {
c := ti.completeKey
if c != nil && key.Matches(c.Key, c.Modifiers) {
ti.showCompletions(true)
return true
}
ti.invalidateCompletions()
switch {
case key.Matches(vaxis.KeyBackspace):
ti.backspace()
case key.Matches('d', vaxis.ModCtrl), key.Matches(vaxis.KeyDelete):
ti.deleteChar()
case key.Matches('b', vaxis.ModCtrl), key.Matches(vaxis.KeyLeft):
if ti.index > 0 {
ti.index--
ti.ensureScroll()
ti.Invalidate()
}
case key.Matches('f', vaxis.ModCtrl), key.Matches(vaxis.KeyRight):
if ti.index < len(ti.text) {
ti.index++
ti.ensureScroll()
ti.Invalidate()
}
case key.Matches('a', vaxis.ModCtrl), key.Matches(vaxis.KeyHome):
ti.index = 0
ti.ensureScroll()
ti.Invalidate()
case key.Matches('e', vaxis.ModCtrl), key.Matches(vaxis.KeyEnd):
ti.index = len(ti.text)
ti.ensureScroll()
ti.Invalidate()
case key.Matches('k', vaxis.ModCtrl):
ti.deleteLineForward()
case key.Matches('w', vaxis.ModCtrl):
ti.deleteWord()
case key.Matches('u', vaxis.ModCtrl):
ti.deleteLineBackward()
case key.Matches(vaxis.KeyEsc):
ti.Invalidate()
case key.Text != "":
chars := vaxis.Characters(key.Text)
for _, ch := range chars {
ti.insert(ch)
}
}
}
return true
}
type completions struct {
ti *TextInput
valWidth int
descWidth int
}
func unquote(s string) string {
if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") {
s = strings.ReplaceAll(s[1:len(s)-1], `'"'"'`, "'")
}
return s
}
func (c *completions) Draw(ctx *Context) {
bg := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_DEFAULT)
bgDesc := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_DESCRIPTION)
gutter := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_GUTTER)
pill := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_PILL)
sel := c.ti.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DEFAULT)
selDesc := c.ti.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DESCRIPTION)
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg)
numVisible := ctx.Height()
startIdx := 0
if len(c.ti.completions) > numVisible && c.index()+1 > numVisible {
startIdx = c.index() - (numVisible - 1)
}
endIdx := startIdx + numVisible - 1
for idx, opt := range c.ti.completions {
if idx < startIdx {
continue
}
if idx > endIdx {
continue
}
val := runewidth.FillRight(unquote(opt.Value), c.valWidth)
desc := opt.Description
if desc != "" {
if runewidth.StringWidth(desc) > c.descWidth {
desc = runewidth.Truncate(desc, c.descWidth, "…")
}
desc = " " + runewidth.FillRight("("+desc+")", c.descWidth+2)
}
if c.index() == idx {
n := ctx.Printf(0, idx-startIdx, sel, " %s", val)
ctx.Printf(n, idx-startIdx, selDesc, "%s ", desc)
} else {
n := ctx.Printf(0, idx-startIdx, bg, " %s", val)
ctx.Printf(n, idx-startIdx, bgDesc, "%s ", desc)
}
}
percentVisible := float64(numVisible) / float64(len(c.ti.completions))
if percentVisible >= 1.0 {
return
}
// gutter
ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), ' ', gutter)
pillSize := int(math.Ceil(float64(ctx.Height()) * percentVisible))
percentScrolled := float64(startIdx) / float64(len(c.ti.completions))
pillOffset := int(math.Floor(float64(ctx.Height()) * percentScrolled))
ctx.Fill(ctx.Width()-1, pillOffset, 1, pillSize, ' ', pill)
}
func (c *completions) index() int {
return c.ti.completeIndex
}
func (c *completions) next() {
index := c.index()
index++
if index >= len(c.ti.completions) {
index = -1
}
c.ti.completeIndex = index
Invalidate()
}
func (c *completions) prev() {
index := c.index()
index--
if index < -1 {
index = len(c.ti.completions) - 1
}
c.ti.completeIndex = index
Invalidate()
}
func (c *completions) exec() {
c.ti.executeCompletion()
c.ti.invalidateCompletions()
Invalidate()
}
func (c *completions) Event(e vaxis.Event) bool {
if e, ok := e.(vaxis.Key); ok {
k := c.ti.completeKey
if k != nil && e.Matches(k.Key, k.Modifiers) {
if len(c.ti.completions) == 1 {
c.ti.completeIndex = 0
c.exec()
} else {
stem := findStem(c.ti.completions)
if c.needsStem(stem) {
c.stem(stem)
}
c.next()
}
return true
}
switch {
case e.Matches('n', vaxis.ModCtrl), e.Matches(vaxis.KeyDown):
c.next()
return true
case e.Matches(vaxis.KeyTab, vaxis.ModShift),
e.Matches('p', vaxis.ModCtrl),
e.Matches(vaxis.KeyUp):
c.prev()
return true
case e.Matches(vaxis.KeyEnter):
if c.index() >= 0 {
c.exec()
return true
}
}
}
return false
}
func (c *completions) needsStem(stem string) bool {
if stem == "" || c.index() >= 0 {
return false
}
if len(stem)+len(c.ti.prefix) > len(c.ti.StringLeft()) {
return true
}
return false
}
func (c *completions) stem(stem string) {
c.ti.Set(c.ti.prefix + stem + c.ti.StringRight())
c.ti.index = len(vaxis.Characters(c.ti.prefix + stem))
}
func findStem(words []opt.Completion) string {
if len(words) == 0 {
return ""
}
if len(words) == 1 {
return words[0].Value
}
var stem string
stemLen := 1
firstWord := []rune(words[0].Value)
for {
if len(firstWord) < stemLen {
return stem
}
var r rune = firstWord[stemLen-1]
for _, word := range words[1:] {
runes := []rune(word.Value)
if len(runes) < stemLen {
return stem
}
if runes[stemLen-1] != r {
return stem
}
}
stem += string(r)
stemLen++
}
}
func (c *completions) Focus(_ bool) {}
func (c *completions) Invalidate() {}
+72
View File
@@ -0,0 +1,72 @@
package ui
import "testing"
func TestDeleteWord(t *testing.T) {
tests := []struct {
name string
text string
expected string
}{
{
name: "hello-world",
text: "hello world",
expected: "hello ",
},
{
name: "empty",
text: "",
expected: "",
},
{
name: "quoted",
text: `"hello"`,
expected: `"hello`,
},
{
name: "hello-and-space",
text: "hello ",
expected: "",
},
{
name: "space-and-hello",
text: " hello",
expected: " ",
},
{
name: "only-quote",
text: `"`,
expected: "",
},
{
name: "only-space",
text: " ",
expected: "",
},
{
name: "space-and-quoted",
text: " 'hello",
expected: " '",
},
{
name: "paths",
text: "foo/bar/baz",
expected: "foo/bar/",
},
{
name: "space-and-paths",
text: " /foo",
expected: " /",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
textinput := NewTextInput(test.text, nil)
textinput.deleteWord()
if charactersToString(textinput.text) != test.expected {
t.Errorf("word was deleted incorrectly: got %s but expected %s", charactersToString(textinput.text), test.expected)
}
})
}
}
+186
View File
@@ -0,0 +1,186 @@
package ui
import (
"os"
"os/signal"
"sync/atomic"
"syscall"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rockorager/vaxis"
)
// Use unbuffered channels (always blocking unless somebody can read
// immediately) We are merely using this as a proxy to the internal vaxis event
// channel.
var Events = make(chan vaxis.Event)
var Quit = make(chan struct{})
var Callbacks = make(chan func(), 50)
// QueueFunc queues a function to be called in the main goroutine. This can be
// used to prevent race conditions from delayed functions
func QueueFunc(fn func()) {
Callbacks <- fn
}
// Use a buffered channel of size 1 to avoid blocking callers of Invalidate()
var Redraw = make(chan bool, 1)
// Invalidate marks the entire UI as invalid and request a redraw as soon as
// possible. Invalidate can be called from any goroutine and will never block.
func Invalidate() {
if atomic.SwapUint32(&state.dirty, 1) != 1 {
Redraw <- true
}
}
var state struct {
content DrawableInteractive
ctx *Context
vx *vaxis.Vaxis
popover *Popover
dirty uint32 // == 1 if render has been queued in Redraw channel
// == 1 if suspend is pending
suspending uint32
refresh uint32 // == 1 if a refresh has been queued
}
func Initialize(content DrawableInteractive) error {
opts := vaxis.Options{
DisableMouse: !config.Ui.MouseEnabled,
CSIuBitMask: vaxis.CSIuDisambiguate,
WithTTY: "/dev/tty",
}
vx, err := vaxis.New(opts)
if err != nil {
return err
}
vx.Window().Clear()
vx.HideCursor()
state.content = content
state.vx = vx
state.ctx = NewContext(state.vx, onPopover)
vx.SetTitle("aerc")
Invalidate()
if beeper, ok := content.(DrawableInteractiveBeeper); ok {
beeper.OnBeep(vx.Bell)
}
content.Focus(true)
go func() {
defer log.PanicHandler()
for event := range vx.Events() {
Events <- event
}
}()
return nil
}
func onPopover(p *Popover) {
state.popover = p
}
func Exit() {
close(Quit)
}
var SuspendQueue = make(chan bool, 1)
func QueueSuspend() {
if atomic.SwapUint32(&state.suspending, 1) != 1 {
SuspendQueue <- true
}
}
// SuspendScreen should be called from the main thread.
func SuspendScreen() {
_ = state.vx.Suspend()
}
func ResumeScreen() {
err := state.vx.Resume()
if err != nil {
log.Errorf("ui: cannot resume after suspend: %v", err)
}
Invalidate()
}
func Suspend() error {
var err error
if atomic.SwapUint32(&state.suspending, 0) != 0 {
err = state.vx.Suspend()
if err == nil {
sigcont := make(chan os.Signal, 1)
signal.Notify(sigcont, syscall.SIGCONT)
err = syscall.Kill(0, syscall.SIGTSTP)
if err == nil {
<-sigcont
}
signal.Reset(syscall.SIGCONT)
err = state.vx.Resume()
state.content.Draw(state.ctx)
state.vx.Render()
}
}
return err
}
func Close() {
state.vx.Close()
}
func QueueRefresh() {
if atomic.SwapUint32(&state.refresh, 1) != 1 {
Invalidate()
}
}
func Render() {
if atomic.SwapUint32(&state.dirty, 0) != 0 {
state.vx.Window().Clear()
// reset popover for the next Draw
state.popover = nil
state.vx.HideCursor()
state.content.Draw(state.ctx)
if state.popover != nil {
// if the Draw resulted in a popover, draw it
state.popover.Draw(state.ctx)
}
switch atomic.SwapUint32(&state.refresh, 0) {
case 0:
state.vx.Render()
case 1:
state.vx.Refresh()
}
}
}
func HandleEvent(event vaxis.Event) {
switch event := event.(type) {
case vaxis.Resize:
state.ctx = NewContext(state.vx, onPopover)
Invalidate()
case vaxis.Redraw:
Invalidate()
default:
// We never care about num or caps lock. Remove them so it
// doesn't interfere with key matching
if key, ok := event.(vaxis.Key); ok {
key.Modifiers &^= vaxis.ModCapsLock
key.Modifiers &^= vaxis.ModNumLock
event = key
}
// if we have a popover, and it can handle the event, it does so
if state.popover == nil || !state.popover.Event(event) {
// otherwise, we send the event to the main content
state.content.Event(event)
}
}
}