package ui import ( "strings" "unicode" "github.com/gdamore/tcell/v2" ) // FrameKind picks the box-drawing characters a frame is made of. Turbo Vision // drew the focused window with a double line and every other one with a single // line, which is how the eye finds the active window without any colour. type FrameKind int // The two frame styles. const ( FrameSingle FrameKind = iota FrameDouble ) // frameRunes is one set of box-drawing characters. type frameRunes struct { topLeft, top, topRight rune left, right rune bottomLeft, bottom, bottomRight rune } // runesFor returns the characters a frame kind is drawn with. func (k FrameKind) runesFor() frameRunes { if k == FrameDouble { return frameRunes{'╔', '═', '╗', '║', '║', '╚', '═', '╝'} } return frameRunes{'┌', '─', '┐', '│', '│', '└', '─', '┘'} } // Horizontal returns the character this frame kind draws horizontal runs with, // which a title bar needs in order to blend into the frame around it. func (k FrameKind) Horizontal() rune { return k.runesFor().top } // DrawFrame draws a box around the edge of r, leaving its interior untouched. // // p.DrawFrame(p.Size(), ui.FrameDouble, style) func DrawFrame(p *Painter, r Rect, kind FrameKind, style tcell.Style) { if r.W < 2 || r.H < 2 { return } runes := kind.runesFor() p.HLine(r.X+1, r.Y, r.W-2, runes.top, style) p.HLine(r.X+1, r.Bottom()-1, r.W-2, runes.bottom, style) p.VLine(r.X, r.Y+1, r.H-2, runes.left, style) p.VLine(r.Right()-1, r.Y+1, r.H-2, runes.right, style) p.SetCell(r.X, r.Y, runes.topLeft, style) p.SetCell(r.Right()-1, r.Y, runes.topRight, style) p.SetCell(r.X, r.Bottom()-1, runes.bottomLeft, style) p.SetCell(r.Right()-1, r.Bottom()-1, runes.bottomRight, style) } // ShadowWidth and ShadowHeight are how far a window's shadow falls: two cells // to the right, one below. Terminal cells are about twice as tall as they are // wide, so this is what looks square. const ( ShadowWidth = 2 ShadowHeight = 1 ) // DrawShadow dims the cells to the bottom right of r, as a window casts a // shadow onto the desktop behind it. It must be called before the window // itself is drawn, since it restyles whatever is already there. func DrawShadow(p *Painter, r Rect, style tcell.Style) { p.Shade(Rect{X: r.Right(), Y: r.Y + ShadowHeight, W: ShadowWidth, H: r.H}, style) p.Shade(Rect{X: r.X + ShadowWidth, Y: r.Bottom(), W: r.W - ShadowWidth, H: ShadowHeight}, style) } // HotKeyMarker surrounds the letter a label can be reached by, the way Turbo // Vision wrote "~F~ile" for a File menu opened with Alt-F. const HotKeyMarker = '~' // SplitHotKey takes a label written with tilde markers and returns the text to // draw, the rune the label answers to, and where that rune sits in the text. // // A label with no marker has no hot key, and the index is then -1. // // text, key, at := ui.SplitHotKey("~F~ile") // "File", 'f', 0 func SplitHotKey(label string) (text string, key rune, index int) { var out []rune index = -1 runes := []rune(label) for i := 0; i < len(runes); i++ { if runes[i] != HotKeyMarker { out = append(out, runes[i]) continue } // The marker before the hot letter is the one that names it; the // closing marker just disappears. if index < 0 && i+1 < len(runes) { index = len(out) key = unicode.ToLower(runes[i+1]) } } return string(out), key, index } // PlainLabel returns a label with its hot-key markers removed, which is what // measuring the width of a label needs. func PlainLabel(label string) string { return strings.ReplaceAll(label, string(HotKeyMarker), "") } // LabelWidth returns how many cells a label occupies once drawn. func LabelWidth(label string) int { return len([]rune(PlainLabel(label))) } // DrawLabel draws a label, showing its hot key in a style of its own, and // returns the column just after the last character. func DrawLabel(p *Painter, x, y int, label string, normal, hot tcell.Style) int { text, _, index := SplitHotKey(label) for i, r := range []rune(text) { style := normal if i == index { style = hot } p.SetCell(x+i, y, r, style) } return x + len([]rune(text)) } // MatchesHotKey reports whether a key press selects the given label. // The comparison ignores case, as Alt-F and Alt-f are the same request. func MatchesHotKey(label string, key rune) bool { _, hot, index := SplitHotKey(label) return index >= 0 && hot == unicode.ToLower(key) } // Scroll-bar characters: a dotted track, a solid thumb, and the arrows at // either end that Turbo Vision let you click on. const ( scrollTrackRune = '░' scrollThumbRune = '▓' arrowUpRune = '▲' arrowDownRune = '▼' arrowLeftRune = '◄' arrowRightRune = '►' ) // DrawVScrollBar draws a vertical scroll bar down a one-cell-wide column. // // position is the first visible line, span how many lines are visible and // total how many there are. A bar with nothing to scroll is drawn as an empty // track rather than hidden, so the window's edge does not change shape. func DrawVScrollBar(p *Painter, x, y, height, position, span, total int, track, thumb tcell.Style) { if height < 2 { return } p.SetCell(x, y, arrowUpRune, thumb) p.SetCell(x, y+height-1, arrowDownRune, thumb) trackHeight := height - 2 if trackHeight <= 0 { return } p.VLine(x, y+1, trackHeight, scrollTrackRune, track) p.SetCell(x, y+1+thumbOffset(position, span, total, trackHeight), scrollThumbRune, thumb) } // DrawHScrollBar draws a horizontal scroll bar along a one-cell-tall row. func DrawHScrollBar(p *Painter, x, y, width, position, span, total int, track, thumb tcell.Style) { if width < 2 { return } p.SetCell(x, y, arrowLeftRune, thumb) p.SetCell(x+width-1, y, arrowRightRune, thumb) trackWidth := width - 2 if trackWidth <= 0 { return } p.HLine(x+1, y, trackWidth, scrollTrackRune, track) p.SetCell(x+1+thumbOffset(position, span, total, trackWidth), y, scrollThumbRune, thumb) } // thumbOffset returns where along a track of the given length the thumb sits. // // It is clamped to the track, so a position past the end — which happens for a // moment while the view is being resized — cannot draw outside the bar. func thumbOffset(position, span, total, trackLength int) int { scrollable := total - span if scrollable <= 0 || trackLength <= 1 { return 0 } offset := position * (trackLength - 1) / scrollable return min(max(offset, 0), trackLength-1) }