From 1418e1b9dc41d8f69bccb8de0fe0f1fb6835ce11 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Mon, 26 Feb 2018 22:54:39 -0500 Subject: Split UI library and widgets --- lib/ui/borders.go | 73 +++++++++++++++++++ lib/ui/context.go | 109 ++++++++++++++++++++++++++++ lib/ui/drawable.go | 10 +++ lib/ui/grid.go | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++ lib/ui/interactive.go | 15 ++++ lib/ui/tab.go | 115 ++++++++++++++++++++++++++++++ lib/ui/text.go | 71 +++++++++++++++++++ lib/ui/ui.go | 79 +++++++++++++++++++++ 8 files changed, 663 insertions(+) create mode 100644 lib/ui/borders.go create mode 100644 lib/ui/context.go create mode 100644 lib/ui/drawable.go create mode 100644 lib/ui/grid.go create mode 100644 lib/ui/interactive.go create mode 100644 lib/ui/tab.go create mode 100644 lib/ui/text.go create mode 100644 lib/ui/ui.go (limited to 'lib') diff --git a/lib/ui/borders.go b/lib/ui/borders.go new file mode 100644 index 0000000..08071ad --- /dev/null +++ b/lib/ui/borders.go @@ -0,0 +1,73 @@ +package ui + +import ( + tb "github.com/nsf/termbox-go" +) + +const ( + BORDER_LEFT = 1 << iota + BORDER_TOP = 1 << iota + BORDER_RIGHT = 1 << iota + BORDER_BOTTOM = 1 << iota +) + +type Bordered struct { + borders uint + content Drawable + onInvalidate func(d Drawable) +} + +func NewBordered(content Drawable, borders uint) *Bordered { + b := &Bordered{ + borders: borders, + content: content, + } + content.OnInvalidate(b.contentInvalidated) + return b +} + +func (bordered *Bordered) contentInvalidated(d Drawable) { + bordered.Invalidate() +} + +func (bordered *Bordered) Invalidate() { + if bordered.onInvalidate != nil { + bordered.onInvalidate(bordered) + } +} + +func (bordered *Bordered) OnInvalidate(onInvalidate func(d Drawable)) { + bordered.onInvalidate = onInvalidate +} + +func (bordered *Bordered) Draw(ctx *Context) { + x := 0 + y := 0 + width := ctx.Width() + height := ctx.Height() + cell := tb.Cell{ + Ch: ' ', + Fg: tb.ColorBlack, + Bg: tb.ColorWhite, + } + if bordered.borders&BORDER_LEFT != 0 { + ctx.Fill(0, 0, 1, ctx.Height(), cell) + x += 1 + width -= 1 + } + if bordered.borders&BORDER_TOP != 0 { + ctx.Fill(0, 0, ctx.Width(), 1, cell) + y += 1 + height -= 1 + } + if bordered.borders&BORDER_RIGHT != 0 { + ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), cell) + width -= 1 + } + if bordered.borders&BORDER_BOTTOM != 0 { + ctx.Fill(0, ctx.Height()-1, ctx.Width(), 1, cell) + height -= 1 + } + subctx := ctx.Subcontext(x, y, width, height) + bordered.content.Draw(subctx) +} diff --git a/lib/ui/context.go b/lib/ui/context.go new file mode 100644 index 0000000..ca3f452 --- /dev/null +++ b/lib/ui/context.go @@ -0,0 +1,109 @@ +package ui + +import ( + "fmt" + + "github.com/mattn/go-runewidth" + tb "github.com/nsf/termbox-go" +) + +// A context allows you to draw in a sub-region of the terminal +type Context struct { + x int + y int + width int + height int +} + +func (ctx *Context) X() int { + return ctx.x +} + +func (ctx *Context) Y() int { + return ctx.y +} + +func (ctx *Context) Width() int { + return ctx.width +} + +func (ctx *Context) Height() int { + return ctx.height +} + +func NewContext(width, height int) *Context { + return &Context{0, 0, width, height} +} + +func (ctx *Context) Subcontext(x, y, width, height int) *Context { + if x+width > ctx.width || y+height > ctx.height { + panic(fmt.Errorf("Attempted to create context larger than parent")) + } + return &Context{ + x: ctx.x + x, + y: ctx.y + y, + width: width, + height: height, + } +} + +func (ctx *Context) SetCell(x, y int, ch rune, fg, bg tb.Attribute) { + if x >= ctx.width || y >= ctx.height { + panic(fmt.Errorf("Attempted to draw outside of context")) + } + tb.SetCell(ctx.x+x, ctx.y+y, ch, fg, bg) +} + +func (ctx *Context) Printf(x, y int, ref tb.Cell, + format string, a ...interface{}) int { + + if x >= ctx.width || y >= ctx.height { + panic(fmt.Errorf("Attempted to draw outside of context")) + } + + str := fmt.Sprintf(format, a...) + + x += ctx.x + y += ctx.y + old_x := x + + newline := func() bool { + x = old_x + y++ + return y < ctx.height + } + for _, ch := range str { + if str == " こんにちは " { + fmt.Printf("%c\n", ch) + } + switch ch { + case '\n': + if !newline() { + return runewidth.StringWidth(str) + } + case '\r': + x = old_x + default: + tb.SetCell(x, y, ch, ref.Fg, ref.Bg) + x += runewidth.RuneWidth(ch) + if x == old_x+ctx.width { + if !newline() { + return runewidth.StringWidth(str) + } + } + } + } + + return runewidth.StringWidth(str) +} + +func (ctx *Context) Fill(x, y, width, height int, ref tb.Cell) { + _x := x + _y := y + for ; y < _y+height && y < ctx.height; y++ { + for ; x < _x+width && x < ctx.width; x++ { + ctx.SetCell(x, y, ref.Ch, ref.Fg, ref.Bg) + } + x = _x + } +} diff --git a/lib/ui/drawable.go b/lib/ui/drawable.go new file mode 100644 index 0000000..ef09451 --- /dev/null +++ b/lib/ui/drawable.go @@ -0,0 +1,10 @@ +package ui + +type Drawable interface { + // Called when this renderable should draw itself + Draw(ctx *Context) + // Specifies a function to call when this cell needs to be redrawn + OnInvalidate(callback func(d Drawable)) + // Invalidates the drawable + Invalidate() +} diff --git a/lib/ui/grid.go b/lib/ui/grid.go new file mode 100644 index 0000000..ede7d0c --- /dev/null +++ b/lib/ui/grid.go @@ -0,0 +1,191 @@ +package ui + +import ( + "fmt" + "math" +) + +type Grid struct { + rows []GridSpec + rowLayout []gridLayout + columns []GridSpec + columnLayout []gridLayout + Cells []*GridCell + onInvalidate func(d Drawable) + invalid bool +} + +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 is the number of cells this row/col shall + // occupy. If SIZE_WEIGHT, the space left after all exact rows/cols are + // measured is distributed amonst the remainder weighted by this value. + Size 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 + invalid bool +} + +func NewGrid() *Grid { + return &Grid{invalid: true} +} + +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) { + invalid := grid.invalid + if invalid { + grid.reflow(ctx) + } + for _, cell := range grid.Cells { + if !cell.invalid && !invalid { + continue + } + 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 + } + subctx := ctx.Subcontext(x, y, width, height) + cell.Content.Draw(subctx) + } +} + +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 + for _, spec := range *specs { + layout := gridLayout{Offset: offset} + if spec.Strategy == SIZE_EXACT { + layout.Size = spec.Size + } else if spec.Strategy == SIZE_WEIGHT { + size := float64(spec.Size) / float64(weight) + size *= float64(extent - exact) + 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()) + grid.invalid = false +} + +func (grid *Grid) invalidateLayout() { + grid.invalid = true + if grid.onInvalidate != nil { + grid.onInvalidate(grid) + } +} + +func (grid *Grid) Invalidate() { + grid.invalidateLayout() + for _, cell := range grid.Cells { + cell.Content.Invalidate() + } +} + +func (grid *Grid) OnInvalidate(onInvalidate func(d Drawable)) { + grid.onInvalidate = onInvalidate +} + +func (grid *Grid) AddChild(content Drawable) *GridCell { + cell := &GridCell{ + RowSpan: 1, + ColSpan: 1, + Content: content, + invalid: true, + } + grid.Cells = append(grid.Cells, cell) + cell.Content.OnInvalidate(grid.cellInvalidated) + cell.invalid = true + grid.invalidateLayout() + return cell +} + +func (grid *Grid) RemoveChild(cell *GridCell) { + for i, _cell := range grid.Cells { + if _cell == cell { + grid.Cells = append(grid.Cells[:i], grid.Cells[i+1:]...) + break + } + } + grid.invalidateLayout() +} + +func (grid *Grid) cellInvalidated(drawable Drawable) { + var cell *GridCell + for _, cell = range grid.Cells { + if cell.Content == drawable { + break + } + cell = nil + } + if cell == nil { + panic(fmt.Errorf("Attempted to invalidate unknown cell")) + } + cell.invalid = true + if grid.onInvalidate != nil { + grid.onInvalidate(grid) + } +} diff --git a/lib/ui/interactive.go b/lib/ui/interactive.go new file mode 100644 index 0000000..8bdf592 --- /dev/null +++ b/lib/ui/interactive.go @@ -0,0 +1,15 @@ +package ui + +import ( + tb "github.com/nsf/termbox-go" +) + +type Interactive interface { + // Returns true if the event was handled by this component + Event(event tb.Event) bool +} + +type Simulator interface { + // Queues up the given input events for simulation + Simulate(events []tb.Event) +} diff --git a/lib/ui/tab.go b/lib/ui/tab.go new file mode 100644 index 0000000..e6a8aa5 --- /dev/null +++ b/lib/ui/tab.go @@ -0,0 +1,115 @@ +package ui + +import ( + tb "github.com/nsf/termbox-go" +) + +type Tabs struct { + Tabs []*Tab + TabStrip *TabStrip + TabContent *TabContent + Selected int + + onInvalidateStrip func(d Drawable) + onInvalidateContent func(d Drawable) +} + +type Tab struct { + Content Drawable + Name string + invalid bool +} + +type TabStrip Tabs +type TabContent Tabs + +func NewTabs() *Tabs { + tabs := &Tabs{} + tabs.TabStrip = (*TabStrip)(tabs) + tabs.TabContent = (*TabContent)(tabs) + return tabs +} + +func (tabs *Tabs) Add(content Drawable, name string) { + tabs.Tabs = append(tabs.Tabs, &Tab{ + Content: content, + Name: name, + }) + tabs.TabStrip.Invalidate() + content.OnInvalidate(tabs.invalidateChild) +} + +func (tabs *Tabs) invalidateChild(d Drawable) { + for i, tab := range tabs.Tabs { + if tab.Content == d { + if i == tabs.Selected { + tabs.TabContent.Invalidate() + } + return + } + } +} + +func (tabs *Tabs) Remove(content Drawable) { + for i, tab := range tabs.Tabs { + if tab.Content == content { + tabs.Tabs = append(tabs.Tabs[:i], tabs.Tabs[i+1:]...) + break + } + } + tabs.TabStrip.Invalidate() +} + +func (tabs *Tabs) Select(index int) { + if tabs.Selected != index { + tabs.Selected = index + tabs.TabStrip.Invalidate() + tabs.TabContent.Invalidate() + } +} + +// TODO: Color repository +func (strip *TabStrip) Draw(ctx *Context) { + x := 0 + for i, tab := range strip.Tabs { + cell := tb.Cell{ + Fg: tb.ColorBlack, + Bg: tb.ColorWhite, + } + if strip.Selected == i { + cell.Fg = tb.ColorDefault + cell.Bg = tb.ColorDefault + } + x += ctx.Printf(x, 0, cell, " %s ", tab.Name) + } + cell := tb.Cell{ + Fg: tb.ColorBlack, + Bg: tb.ColorWhite, + } + ctx.Fill(x, 0, ctx.Width()-x, 1, cell) +} + +func (strip *TabStrip) Invalidate() { + if strip.onInvalidateStrip != nil { + strip.onInvalidateStrip(strip) + } +} + +func (strip *TabStrip) OnInvalidate(onInvalidate func(d Drawable)) { + strip.onInvalidateStrip = onInvalidate +} + +func (content *TabContent) Draw(ctx *Context) { + tab := content.Tabs[content.Selected] + tab.Content.Draw(ctx) +} + +func (content *TabContent) Invalidate() { + if content.onInvalidateContent != nil { + content.onInvalidateContent(content) + } +} + +func (content *TabContent) OnInvalidate(onInvalidate func(d Drawable)) { + content.onInvalidateContent = onInvalidate +} diff --git a/lib/ui/text.go b/lib/ui/text.go new file mode 100644 index 0000000..6164837 --- /dev/null +++ b/lib/ui/text.go @@ -0,0 +1,71 @@ +package ui + +import ( + "github.com/mattn/go-runewidth" + tb "github.com/nsf/termbox-go" +) + +const ( + TEXT_LEFT = iota + TEXT_CENTER = iota + TEXT_RIGHT = iota +) + +type Text struct { + text string + strategy uint + fg tb.Attribute + bg tb.Attribute + onInvalidate func(d Drawable) +} + +func NewText(text string) *Text { + return &Text{text: text} +} + +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) Color(fg tb.Attribute, bg tb.Attribute) *Text { + t.fg = fg + t.bg = bg + t.Invalidate() + return t +} + +func (t *Text) Draw(ctx *Context) { + size := runewidth.StringWidth(t.text) + cell := tb.Cell{ + Ch: ' ', + Fg: t.fg, + Bg: t.bg, + } + 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(), cell) + ctx.Printf(x, 0, cell, "%s", t.text) +} + +func (t *Text) OnInvalidate(onInvalidate func(d Drawable)) { + t.onInvalidate = onInvalidate +} + +func (t *Text) Invalidate() { + if t.onInvalidate != nil { + t.onInvalidate(t) + } +} diff --git a/lib/ui/ui.go b/lib/ui/ui.go new file mode 100644 index 0000000..9ea037c --- /dev/null +++ b/lib/ui/ui.go @@ -0,0 +1,79 @@ +package ui + +import ( + tb "github.com/nsf/termbox-go" + + "git.sr.ht/~sircmpwn/aerc2/config" +) + +type UI struct { + Exit bool + Content Drawable + ctx *Context + + interactive []Interactive + + tbEvents chan tb.Event + invalidations chan interface{} +} + +func Initialize(conf *config.AercConfig, content Drawable) (*UI, error) { + if err := tb.Init(); err != nil { + return nil, err + } + width, height := tb.Size() + state := UI{ + Content: content, + ctx: NewContext(width, height), + + tbEvents: make(chan tb.Event, 10), + invalidations: make(chan interface{}), + } + tb.SetInputMode(tb.InputEsc | tb.InputMouse) + tb.SetOutputMode(tb.Output256) + go (func() { + for !state.Exit { + state.tbEvents <- tb.PollEvent() + } + })() + go (func() { state.invalidations <- nil })() + content.OnInvalidate(func(_ Drawable) { + go (func() { state.invalidations <- nil })() + }) + return &state, nil +} + +func (state *UI) Close() { + tb.Close() +} + +func (state *UI) Tick() bool { + select { + case event := <-state.tbEvents: + switch event.Type { + case tb.EventKey: + if event.Key == tb.KeyEsc { + state.Exit = true + } + case tb.EventResize: + tb.Clear(tb.ColorDefault, tb.ColorDefault) + state.ctx = NewContext(event.Width, event.Height) + state.Content.Invalidate() + } + if state.interactive != nil { + for _, i := range state.interactive { + i.Event(event) + } + } + case <-state.invalidations: + state.Content.Draw(state.ctx) + tb.Flush() + default: + return false + } + return true +} + +func (state *UI) AddInteractive(i Interactive) { + state.interactive = append(state.interactive, i) +} -- cgit v1.2.3