package ui import ( "strconv" "strings" "git.yetaga.in/alazyreader/library/media" "github.com/gdamore/tcell/v2" ) type Drawable interface { Draw(tcell.Screen) SetSize(x, y, h, w int) SetStyle(tcell.Style) SetVisible(bool) } type Offsets struct { Top int Bottom int Left int Right int Percent int } type Contents []struct { Offsets Offsets Container Drawable } const ( LayoutUnmanaged = iota LayoutHorizontalEven LayoutVerticalEven LayoutHorizontalPercent LayoutVerticalPercent ) var ( StyleActive = tcell.Style{}.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) StyleInactive = tcell.Style{}.Foreground(tcell.ColorGray).Background(tcell.ColorBlack) ) // A Container has no visible UI of its own, but arranges sub-components on the screen. // layoutMethod manages how subcomponents are organized. If `LayoutUnmanaged`, it just uses the offsets // in contents to paint on the screen. Otherwise, `LayoutHorizontalEven` and `LayoutVerticalEven` will // have it compute even distributions of space for all components either horizontally or vertically, // filling the container. type Container struct { x, y int h, w int layoutMethod int contents Contents visible bool } func NewContainer(contents Contents, layoutMethod int) *Container { return &Container{ layoutMethod: layoutMethod, contents: contents, visible: true, } } func (c *Container) Draw(s tcell.Screen) { if !c.visible { return } for i := range c.contents { c.contents[i].Container.Draw(s) } } func (c *Container) SetSize(x, y, h, w int) { c.x, c.y, c.h, c.w = x, y, h, w carry := 0 if c.layoutMethod == LayoutVerticalEven { num := len(c.contents) extra := c.h % num for r := range c.contents { w := c.w h := c.h / num x := c.x y := c.y + (h * r) + carry if extra > 0 { // distribute "extra" space to containers as we have some left h++ extra-- carry++ } c.contents[r].Container.SetSize(x, y, h, w) } } else if c.layoutMethod == LayoutHorizontalEven { num := len(c.contents) extra := c.w % num for r := range c.contents { w := c.w / num h := c.h x := c.x + (w * r) + carry y := c.y if extra > 0 { // distribute "extra" space to containers as we have some left w++ extra-- carry++ } c.contents[r].Container.SetSize(x, y, h, w) } } else if c.layoutMethod == LayoutHorizontalPercent { // first, work out overall distribution total := 0 for r := range c.contents { // `0` or negatives are set as minimum if c.contents[r].Offsets.Percent < 1 { total += 1 } else { total += c.contents[r].Offsets.Percent } } carry := 0 // push around containers for r := range c.contents { ratio := (float64(c.contents[r].Offsets.Percent) / float64(total)) w := int(float64(c.w) * ratio) h := c.h x := c.x + carry y := c.y carry += w // and add any remaining space to the last container if r == len(c.contents)-1 { w += (c.w - carry) } c.contents[r].Container.SetSize(x, y, h, w) } } else if c.layoutMethod == LayoutVerticalPercent { // first, work out overall distribution total := 0 for r := range c.contents { // `0` or negatives are set as minimum if c.contents[r].Offsets.Percent < 1 { total += 1 } else { total += c.contents[r].Offsets.Percent } } carry := 0 // push around containers for r := range c.contents { ratio := (float64(c.contents[r].Offsets.Percent) / float64(total)) w := c.w h := int(float64(c.h) * ratio) x := c.x y := c.y + carry carry += h // and add any remaining space to the last container if r == len(c.contents)-1 { h += (c.h - carry) } c.contents[r].Container.SetSize(x, y, h, w) } } else { for r := range c.contents { x := c.x + c.contents[r].Offsets.Left y := c.y + c.contents[r].Offsets.Top h := c.h - c.contents[r].Offsets.Bottom w := c.w - c.contents[r].Offsets.Right c.contents[r].Container.SetSize(x, y, h, w) } } } func (c *Container) SetStyle(s tcell.Style) { // containers have no visible elements to style } func (c *Container) SetVisible(b bool) { c.visible = b } func (c *Container) Contents() Contents { return c.contents } func (c *Container) SetContents(con Contents) { c.contents = con } // A Box draws a ASCII box around its contents, with an optional title and footer. type Box struct { x, y int h, w int title Drawable menuItems Drawable contents Contents style tcell.Style cascade bool visible bool transparent bool } func NewBox(title string, menuItems []string, contents Contents, initialStyle tcell.Style, cascade bool) *Box { return &Box{ title: NewPaddedText(title), menuItems: NewPaddedText(strings.Join(menuItems, " ")), contents: contents, style: initialStyle, cascade: cascade, visible: true, transparent: false, } } func (b *Box) SetSize(x, y, h, w int) { b.x, b.y, b.h, b.w = x, y, h, w b.title.SetSize(b.x+2, b.y, 0, 0) b.menuItems.SetSize(b.x+2, b.y+b.h-1, 0, 0) for c := range b.contents { x := b.x + b.contents[c].Offsets.Left y := b.y + b.contents[c].Offsets.Top h := b.h - b.contents[c].Offsets.Bottom w := b.w - b.contents[c].Offsets.Right b.contents[c].Container.SetSize(x, y, h, w) } } func (b *Box) Draw(s tcell.Screen) { if !b.visible { return } // blank out inner area if !b.transparent { for m := b.x + 1; m < b.x+b.w-1; m++ { for n := b.y + 1; n < b.y+b.h-1; n++ { s.SetContent(m, n, ' ', nil, b.style) } } } // draw outside bars for m := b.x + 1; m < b.x+b.w-1; m++ { s.SetContent(m, b.y, tcell.RuneHLine, nil, b.style) s.SetContent(m, b.y+b.h-1, tcell.RuneHLine, nil, b.style) } for m := b.y + 1; m < b.y+b.h-1; m++ { s.SetContent(b.x, m, tcell.RuneVLine, nil, b.style) s.SetContent(b.x+b.w-1, m, tcell.RuneVLine, nil, b.style) } s.SetContent(b.x, b.y, tcell.RuneULCorner, nil, b.style) s.SetContent(b.x+b.w-1, b.y, tcell.RuneURCorner, nil, b.style) s.SetContent(b.x, b.y+b.h-1, tcell.RuneLLCorner, nil, b.style) s.SetContent(b.x+b.w-1, b.y+b.h-1, tcell.RuneLRCorner, nil, b.style) if b.title != nil { b.title.Draw(s) } if b.menuItems != nil { b.menuItems.Draw(s) } for c := range b.contents { b.contents[c].Container.Draw(s) } } func (b *Box) SetStyle(s tcell.Style) { b.style = s b.title.SetStyle(s) b.menuItems.SetStyle(s) if b.cascade { for c := range b.contents { b.contents[c].Container.SetStyle(s) } } } func (b *Box) SetVisible(v bool) { b.visible = v } func (b *Box) SetTransparent(v bool) { b.transparent = v } func (b *Box) Contents() Contents { return b.contents } func (b *Box) SetContents(c Contents) { b.contents = c } // A List is a scrollable, pageable list with a selector token. type List struct { x, y int h, w int selected int listItems []ListKeyValue style tcell.Style visible bool } type ListKeyValue struct { Key int Value string } func NewList(listItems []ListKeyValue, initialSelected int) *List { return &List{ listItems: listItems, selected: initialSelected, visible: true, } } func (l *List) SetSize(x, y, h, w int) { l.x, l.y, l.h, l.w = x, y, h, w } func (l *List) Draw(s tcell.Screen) { if !l.visible { return } for i := range l.listItems { for j, r := range l.listItems[i].Value { s.SetContent(l.x+j, l.y+i, r, nil, l.style) } if i == l.selected { s.SetContent(l.x+len(l.listItems[i].Value)+1, l.y+i, '<', nil, l.style) } } } func (l *List) SetVisible(b bool) { l.visible = b } func (l *List) SetStyle(s tcell.Style) { l.style = s } func (l *List) Selected() int { return l.selected } func (l *List) SelectedID() int { if l.listItems == nil || len(l.listItems) == 0 { return 0 } return l.listItems[l.selected].Key } func (l *List) SetSelected(i int) { l.selected = i } func (l *List) ListMembers() []ListKeyValue { return l.listItems } func (l *List) SetMembers(lkv []ListKeyValue) { l.listItems = lkv } // BookDetails displays an editable list of book details type BookDetails struct { x, y int h, w int book *media.Book style tcell.Style visible bool } func NewBookDetails(b *media.Book) *BookDetails { return &BookDetails{ book: b, visible: true, } } func (l *BookDetails) SetBook(b *media.Book) { l.book = b } func (l *BookDetails) SetSize(x, y, h, w int) { l.x, l.y, l.h, l.w = x, y, h, w } func (l *BookDetails) Draw(s tcell.Screen) { if l.book == nil { return } if !l.visible { return } items := []struct { label string value string }{ {"Title", l.book.Title}, {"Authors", strings.Join(l.book.Authors, ", ")}, {"Sort Author", l.book.SortAuthor}, {"ISBN-10", l.book.ISBN10}, {"ISBN-13", l.book.ISBN13}, {"Format", l.book.Format}, {"Genre", l.book.Genre}, {"Publisher", l.book.Publisher}, {"Series", l.book.Series}, {"Volume", l.book.Volume}, {"Year", l.book.Year}, {"Signed", strconv.FormatBool(l.book.Signed)}, {"Cover URL", l.book.CoverURL}, {"Notes", l.book.Notes}, {"Description", l.book.Description}, } for i := range items { if i < l.h-2 { kv := NewKeyValue(items[i].label, ": ", items[i].value) kv.SetSize(l.x, l.y+i, 0, 0) kv.SetStyle(l.style) kv.Draw(s) } } } func (l *BookDetails) SetVisible(b bool) { l.visible = b } func (l *BookDetails) SetStyle(s tcell.Style) { l.style = s } // PaddedText outputs strings with a space on both sides. // Useful for generating headings, footers, etc. Used by Box. type PaddedText struct { x, y int h, w int text string style tcell.Style visible bool } func NewPaddedText(text string) *PaddedText { return &PaddedText{text: text, visible: true} } func (p *PaddedText) SetSize(x, y, _, _ int) { p.x, p.y, p.h, p.w = x, y, 1, len(p.text)+2 } func (p *PaddedText) SetStyle(s tcell.Style) { p.style = s } func (p *PaddedText) Draw(s tcell.Screen) { if p.text == "" { return } if !p.visible { return } t := p.x s.SetContent(t, p.y, ' ', nil, p.style) t++ for _, r := range p.text { s.SetContent(t, p.y, r, nil, p.style) t++ } s.SetContent(t, p.y, ' ', nil, p.style) } func (p *PaddedText) SetVisible(b bool) { p.visible = b } type KeyValue struct { x, y int h, w int key string value string separator string style tcell.Style visible bool } func NewKeyValue(key, separator, value string) *KeyValue { return &KeyValue{ key: key, separator: separator, value: value, visible: true, } } func (p *KeyValue) SetSize(x, y, _, _ int) { p.x, p.y, p.h, p.w = x, y, 1, len(p.key)+len(p.separator)+len(p.value) } func (p *KeyValue) SetStyle(s tcell.Style) { p.style = s } func (p *KeyValue) Draw(s tcell.Screen) { if !p.visible { return } for j, r := range p.key { s.SetContent(p.x+j, p.y, r, nil, p.style) } for j, r := range p.separator { s.SetContent(p.x+len(p.key)+j, p.y, r, nil, p.style) } for j, r := range p.value { s.SetContent(p.x+len(p.key)+len(p.separator)+j, p.y, r, nil, p.style) } } func (p *KeyValue) SetVisible(b bool) { p.visible = b } func (p *KeyValue) GetValue() string { return p.value } type EditableTextLine struct { x, y int h, w int text string style tcell.Style visible bool cursorPos int showCursor bool } func NewEditableTextLine(initialText string) *EditableTextLine { return &EditableTextLine{ text: initialText, visible: true, showCursor: true, } } func (p *EditableTextLine) SetSize(x, y, _, _ int) { p.x, p.y, p.h, p.w = x, y, 1, len(p.text) } func (p *EditableTextLine) SetStyle(s tcell.Style) { p.style = s } func (p *EditableTextLine) Draw(s tcell.Screen) { if !p.visible { return } for j, r := range p.text { s.SetContent(p.x+j, p.y, r, nil, p.style) } s.ShowCursor(p.x+p.cursorPos, p.y) } func (p *EditableTextLine) SetVisible(b bool) { p.visible = b } func (p *EditableTextLine) SetCursorVisible(b bool) { p.showCursor = b } func (p *EditableTextLine) SetText(t string) { p.text = t if len(p.text) == 0 { p.ResetCursor(true) return } p.ResetCursor(false) } func (p *EditableTextLine) Text() string { return p.text } func (p *EditableTextLine) ResetCursor(beginning bool) { if beginning { p.cursorPos = 0 } else { p.cursorPos = len(p.text) } } func (p *EditableTextLine) InsertAtCursor(r rune) { if len(p.text) == 0 { p.text = string(r) p.cursorPos = 1 return } p.text = p.text[0:p.cursorPos] + string(r) + p.text[p.cursorPos:len(p.text)] p.cursorPos = p.cursorPos + 1 } func (p *EditableTextLine) MoveCursor(i int) { if p.cursorPos+i < 0 { p.cursorPos = 0 return } if p.cursorPos+i > len(p.text) { p.cursorPos = len(p.text) return } p.cursorPos = p.cursorPos + i } func (p *EditableTextLine) DeleteAtCursor() { if len(p.text) == 0 { p.cursorPos = 0 return } p.text = p.text[0:p.cursorPos-1] + p.text[p.cursorPos:len(p.text)] p.cursorPos = p.cursorPos - 1 }