A simple website to serve a list of books currently in my possession.
https://library.yetaga.in/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
448 lines
9.9 KiB
448 lines
9.9 KiB
package ui |
|
|
|
import ( |
|
"strconv" |
|
"strings" |
|
|
|
"git.yetaga.in/alazyreader/library/book" |
|
"github.com/gdamore/tcell" |
|
) |
|
|
|
type Drawable interface { |
|
Draw(tcell.Screen) |
|
SetSize(x, y, h, w int) |
|
SetStyle(tcell.Style) |
|
} |
|
|
|
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(0).Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) |
|
StyleInactive = tcell.Style(0).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 |
|
} |
|
|
|
func NewContainer(contents Contents, layoutMethod int) *Container { |
|
return &Container{ |
|
layoutMethod: layoutMethod, |
|
contents: contents, |
|
} |
|
} |
|
|
|
func (c *Container) Draw(s tcell.Screen) { |
|
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) 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 |
|
} |
|
|
|
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, |
|
} |
|
} |
|
|
|
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) { |
|
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) 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 |
|
} |
|
|
|
type ListKeyValue struct { |
|
Key int |
|
Value string |
|
} |
|
|
|
func NewList(listItems []ListKeyValue, initialSelected int) *List { |
|
return &List{ |
|
listItems: listItems, |
|
selected: initialSelected, |
|
} |
|
} |
|
|
|
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) { |
|
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) 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 |
|
} |
|
|
|
// BookDetails displays an editable list of book details |
|
type BookDetails struct { |
|
x, y int |
|
h, w int |
|
book *book.Book |
|
style tcell.Style |
|
} |
|
|
|
func NewBookDetails(b *book.Book) *BookDetails { |
|
return &BookDetails{ |
|
book: b, |
|
} |
|
} |
|
|
|
func (l *BookDetails) SetBook(b *book.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 |
|
} |
|
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)}, |
|
{"On Loan", l.book.OnLoan}, |
|
{"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) 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 |
|
} |
|
|
|
func NewPaddedText(text string) *PaddedText { |
|
return &PaddedText{text: text} |
|
} |
|
|
|
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 |
|
} |
|
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) |
|
} |
|
|
|
type KeyValue struct { |
|
x, y int |
|
h, w int |
|
key string |
|
value string |
|
separator string |
|
style tcell.Style |
|
} |
|
|
|
func NewKeyValue(key, separator, value string) *KeyValue { |
|
return &KeyValue{ |
|
key: key, |
|
separator: separator, |
|
value: value, |
|
} |
|
} |
|
|
|
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) { |
|
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) GetValue() string { |
|
return p.value |
|
}
|
|
|