| 🛟 Updated. 28d5985 k33g 16h ago | 1 | package ui |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "unicode" |
| 6 | |
| 7 | "github.com/gdamore/tcell/v2" |
| 8 | ) |
| 9 | |
| 10 | // FrameKind picks the box-drawing characters a frame is made of. Turbo Vision |
| 11 | // drew the focused window with a double line and every other one with a single |
| 12 | // line, which is how the eye finds the active window without any colour. |
| 13 | type FrameKind int |
| 14 | |
| 15 | // The two frame styles. |
| 16 | const ( |
| 17 | FrameSingle FrameKind = iota |
| 18 | FrameDouble |
| 19 | ) |
| 20 | |
| 21 | // frameRunes is one set of box-drawing characters. |
| 22 | type frameRunes struct { |
| 23 | topLeft, top, topRight rune |
| 24 | left, right rune |
| 25 | bottomLeft, bottom, bottomRight rune |
| 26 | } |
| 27 | |
| 28 | // runesFor returns the characters a frame kind is drawn with. |
| 29 | func (k FrameKind) runesFor() frameRunes { |
| 30 | if k == FrameDouble { |
| 31 | return frameRunes{'╔', '═', '╗', '║', '║', '╚', '═', '╝'} |
| 32 | } |
| 33 | return frameRunes{'┌', '─', '┐', '│', '│', '└', '─', '┘'} |
| 34 | } |
| 35 | |
| 36 | // Horizontal returns the character this frame kind draws horizontal runs with, |
| 37 | // which a title bar needs in order to blend into the frame around it. |
| 38 | func (k FrameKind) Horizontal() rune { return k.runesFor().top } |
| 39 | |
| 40 | // DrawFrame draws a box around the edge of r, leaving its interior untouched. |
| 41 | // |
| 42 | // p.DrawFrame(p.Size(), ui.FrameDouble, style) |
| 43 | func DrawFrame(p *Painter, r Rect, kind FrameKind, style tcell.Style) { |
| 44 | if r.W < 2 || r.H < 2 { |
| 45 | return |
| 46 | } |
| 47 | runes := kind.runesFor() |
| 48 | |
| 49 | p.HLine(r.X+1, r.Y, r.W-2, runes.top, style) |
| 50 | p.HLine(r.X+1, r.Bottom()-1, r.W-2, runes.bottom, style) |
| 51 | p.VLine(r.X, r.Y+1, r.H-2, runes.left, style) |
| 52 | p.VLine(r.Right()-1, r.Y+1, r.H-2, runes.right, style) |
| 53 | |
| 54 | p.SetCell(r.X, r.Y, runes.topLeft, style) |
| 55 | p.SetCell(r.Right()-1, r.Y, runes.topRight, style) |
| 56 | p.SetCell(r.X, r.Bottom()-1, runes.bottomLeft, style) |
| 57 | p.SetCell(r.Right()-1, r.Bottom()-1, runes.bottomRight, style) |
| 58 | } |
| 59 | |
| 60 | // ShadowWidth and ShadowHeight are how far a window's shadow falls: two cells |
| 61 | // to the right, one below. Terminal cells are about twice as tall as they are |
| 62 | // wide, so this is what looks square. |
| 63 | const ( |
| 64 | ShadowWidth = 2 |
| 65 | ShadowHeight = 1 |
| 66 | ) |
| 67 | |
| 68 | // DrawShadow dims the cells to the bottom right of r, as a window casts a |
| 69 | // shadow onto the desktop behind it. It must be called before the window |
| 70 | // itself is drawn, since it restyles whatever is already there. |
| 71 | func DrawShadow(p *Painter, r Rect, style tcell.Style) { |
| 72 | p.Shade(Rect{X: r.Right(), Y: r.Y + ShadowHeight, W: ShadowWidth, H: r.H}, style) |
| 73 | p.Shade(Rect{X: r.X + ShadowWidth, Y: r.Bottom(), W: r.W - ShadowWidth, H: ShadowHeight}, style) |
| 74 | } |
| 75 | |
| 76 | // HotKeyMarker surrounds the letter a label can be reached by, the way Turbo |
| 77 | // Vision wrote "~F~ile" for a File menu opened with Alt-F. |
| 78 | const HotKeyMarker = '~' |
| 79 | |
| 80 | // SplitHotKey takes a label written with tilde markers and returns the text to |
| 81 | // draw, the rune the label answers to, and where that rune sits in the text. |
| 82 | // |
| 83 | // A label with no marker has no hot key, and the index is then -1. |
| 84 | // |
| 85 | // text, key, at := ui.SplitHotKey("~F~ile") // "File", 'f', 0 |
| 86 | func SplitHotKey(label string) (text string, key rune, index int) { |
| 87 | var out []rune |
| 88 | index = -1 |
| 89 | |
| 90 | runes := []rune(label) |
| 91 | for i := 0; i < len(runes); i++ { |
| 92 | if runes[i] != HotKeyMarker { |
| 93 | out = append(out, runes[i]) |
| 94 | continue |
| 95 | } |
| 96 | // The marker before the hot letter is the one that names it; the |
| 97 | // closing marker just disappears. |
| 98 | if index < 0 && i+1 < len(runes) { |
| 99 | index = len(out) |
| 100 | key = unicode.ToLower(runes[i+1]) |
| 101 | } |
| 102 | } |
| 103 | return string(out), key, index |
| 104 | } |
| 105 | |
| 106 | // PlainLabel returns a label with its hot-key markers removed, which is what |
| 107 | // measuring the width of a label needs. |
| 108 | func PlainLabel(label string) string { |
| 109 | return strings.ReplaceAll(label, string(HotKeyMarker), "") |
| 110 | } |
| 111 | |
| 112 | // LabelWidth returns how many cells a label occupies once drawn. |
| 113 | func LabelWidth(label string) int { |
| 114 | return len([]rune(PlainLabel(label))) |
| 115 | } |
| 116 | |
| 117 | // DrawLabel draws a label, showing its hot key in a style of its own, and |
| 118 | // returns the column just after the last character. |
| 119 | func DrawLabel(p *Painter, x, y int, label string, normal, hot tcell.Style) int { |
| 120 | text, _, index := SplitHotKey(label) |
| 121 | |
| 122 | for i, r := range []rune(text) { |
| 123 | style := normal |
| 124 | if i == index { |
| 125 | style = hot |
| 126 | } |
| 127 | p.SetCell(x+i, y, r, style) |
| 128 | } |
| 129 | return x + len([]rune(text)) |
| 130 | } |
| 131 | |
| 132 | // MatchesHotKey reports whether a key press selects the given label. |
| 133 | // The comparison ignores case, as Alt-F and Alt-f are the same request. |
| 134 | func MatchesHotKey(label string, key rune) bool { |
| 135 | _, hot, index := SplitHotKey(label) |
| 136 | return index >= 0 && hot == unicode.ToLower(key) |
| 137 | } |
| 138 | |
| 139 | // Scroll-bar characters: a dotted track, a solid thumb, and the arrows at |
| 140 | // either end that Turbo Vision let you click on. |
| 141 | const ( |
| 142 | scrollTrackRune = '░' |
| 143 | scrollThumbRune = '▓' |
| 144 | arrowUpRune = '▲' |
| 145 | arrowDownRune = '▼' |
| 146 | arrowLeftRune = '◄' |
| 147 | arrowRightRune = '►' |
| 148 | ) |
| 149 | |
| 150 | // DrawVScrollBar draws a vertical scroll bar down a one-cell-wide column. |
| 151 | // |
| 152 | // position is the first visible line, span how many lines are visible and |
| 153 | // total how many there are. A bar with nothing to scroll is drawn as an empty |
| 154 | // track rather than hidden, so the window's edge does not change shape. |
| 155 | func DrawVScrollBar(p *Painter, x, y, height, position, span, total int, track, thumb tcell.Style) { |
| 156 | if height < 2 { |
| 157 | return |
| 158 | } |
| 159 | |
| 160 | p.SetCell(x, y, arrowUpRune, thumb) |
| 161 | p.SetCell(x, y+height-1, arrowDownRune, thumb) |
| 162 | |
| 163 | trackHeight := height - 2 |
| 164 | if trackHeight <= 0 { |
| 165 | return |
| 166 | } |
| 167 | p.VLine(x, y+1, trackHeight, scrollTrackRune, track) |
| 168 | p.SetCell(x, y+1+thumbOffset(position, span, total, trackHeight), scrollThumbRune, thumb) |
| 169 | } |
| 170 | |
| 171 | // DrawHScrollBar draws a horizontal scroll bar along a one-cell-tall row. |
| 172 | func DrawHScrollBar(p *Painter, x, y, width, position, span, total int, track, thumb tcell.Style) { |
| 173 | if width < 2 { |
| 174 | return |
| 175 | } |
| 176 | |
| 177 | p.SetCell(x, y, arrowLeftRune, thumb) |
| 178 | p.SetCell(x+width-1, y, arrowRightRune, thumb) |
| 179 | |
| 180 | trackWidth := width - 2 |
| 181 | if trackWidth <= 0 { |
| 182 | return |
| 183 | } |
| 184 | p.HLine(x+1, y, trackWidth, scrollTrackRune, track) |
| 185 | p.SetCell(x+1+thumbOffset(position, span, total, trackWidth), y, scrollThumbRune, thumb) |
| 186 | } |
| 187 | |
| 188 | // thumbOffset returns where along a track of the given length the thumb sits. |
| 189 | // |
| 190 | // It is clamped to the track, so a position past the end — which happens for a |
| 191 | // moment while the view is being resized — cannot draw outside the bar. |
| 192 | func thumbOffset(position, span, total, trackLength int) int { |
| 193 | scrollable := total - span |
| 194 | if scrollable <= 0 || trackLength <= 1 { |
| 195 | return 0 |
| 196 | } |
| 197 | |
| 198 | offset := position * (trackLength - 1) / scrollable |
| 199 | return min(max(offset, 0), trackLength-1) |
| 200 | } |