init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user