feat(tui): add bubbletea terminal UI #30
@@ -50,6 +50,11 @@ func renderHelp(width, height int) string {
|
||||
{"enter", "copy resolved"},
|
||||
{"esc", "cancel"},
|
||||
}},
|
||||
{"Split View", [][2]string{
|
||||
{"l", "focus detail pane"},
|
||||
{"h", "focus list pane"},
|
||||
{"esc", "close detail / back"},
|
||||
}},
|
||||
{"Global", [][2]string{
|
||||
{"?", "toggle help"},
|
||||
{"q / ctrl+c", "quit"},
|
||||
|
||||
+100
-1
@@ -1,6 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
@@ -18,6 +21,7 @@ type inputResult struct {
|
||||
type inputModel struct {
|
||||
ti textinput.Model
|
||||
active bool
|
||||
preview *parse.Result
|
||||
}
|
||||
|
||||
func newInputModel() inputModel {
|
||||
@@ -37,6 +41,7 @@ func (i *inputModel) reset() {
|
||||
i.active = false
|
||||
i.ti.SetValue("")
|
||||
i.ti.Blur()
|
||||
i.preview = nil
|
||||
}
|
||||
|
||||
func (i inputModel) submit() *inputResult {
|
||||
@@ -83,9 +88,103 @@ func (i inputModel) submit() *inputResult {
|
||||
|
||||
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||
i.ti, _ = i.ti.Update(msg)
|
||||
val := i.ti.Value()
|
||||
if val != "" {
|
||||
parsed, err := parse.Parse(val)
|
||||
if err == nil {
|
||||
i.preview = parsed
|
||||
} else {
|
||||
i.preview = nil
|
||||
}
|
||||
} else {
|
||||
i.preview = nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i inputModel) view(width int) string {
|
||||
return i.ti.View()
|
||||
var b strings.Builder
|
||||
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(i.ti.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(i.renderPreview(width))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (i inputModel) renderPreview(width int) string {
|
||||
if i.preview == nil {
|
||||
return drawerPreviewStyle.Render("")
|
||||
}
|
||||
|
||||
p := i.preview
|
||||
|
||||
if p.Query {
|
||||
q := "?"
|
||||
if p.Body != "" {
|
||||
q += p.Body
|
||||
}
|
||||
for _, t := range p.FilterTags {
|
||||
q += " #" + t
|
||||
}
|
||||
return drawerPreviewStyle.Render("search: " + q)
|
||||
}
|
||||
|
||||
glyph := glyphForParsed(p.Glyph)
|
||||
body := p.Body
|
||||
if p.Title != nil {
|
||||
body = *p.Title
|
||||
}
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, glyph, body)
|
||||
for _, t := range p.Tags {
|
||||
parts = append(parts, tagStyle.Render("#"+t))
|
||||
}
|
||||
if p.Pin {
|
||||
parts = append(parts, pinnedStyle.Render("•"))
|
||||
}
|
||||
if p.CardSuffix != nil {
|
||||
parts = append(parts, affordanceStyle.Render(*p.CardSuffix))
|
||||
}
|
||||
|
||||
line := strings.Join(parts, " ")
|
||||
maxW := width - 4
|
||||
if maxW > 0 && len(stripAnsi(line)) > maxW {
|
||||
line = truncate(line, maxW)
|
||||
}
|
||||
|
||||
return drawerPreviewStyle.Render(line)
|
||||
}
|
||||
|
||||
func glyphForParsed(glyph string) string {
|
||||
switch glyph {
|
||||
case "todo":
|
||||
return "○"
|
||||
case "event":
|
||||
return "◇"
|
||||
case "reminder":
|
||||
return "△"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
func drawerLines() int {
|
||||
return 3
|
||||
}
|
||||
|
||||
// formatPreviewEntity builds a preview string showing how the entity will appear
|
||||
func formatPreviewEntity(p *parse.Result) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
glyph := glyphForParsed(p.Glyph)
|
||||
body := p.Body
|
||||
if p.Title != nil {
|
||||
body = *p.Title
|
||||
}
|
||||
return fmt.Sprintf("%s %s", glyph, body)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ type keyMap struct {
|
||||
Absorb key.Binding
|
||||
Run key.Binding
|
||||
Fill key.Binding
|
||||
FocusLeft key.Binding
|
||||
FocusRight key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
@@ -58,4 +60,6 @@ var keys = keyMap{
|
||||
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
|
||||
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
|
||||
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
|
||||
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
||||
}
|
||||
|
||||
+22
-30
@@ -93,6 +93,8 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
return l
|
||||
}
|
||||
|
||||
const dateGutterWidth = 9
|
||||
|
||||
func (l listModel) view(width int) string {
|
||||
ents := l.displayEntities()
|
||||
if len(ents) == 0 {
|
||||
@@ -100,22 +102,24 @@ func (l listModel) view(width int) string {
|
||||
}
|
||||
|
||||
groups := groupByDate(ents)
|
||||
entityWidth := width - 4 - dateGutterWidth
|
||||
|
||||
type displayLine struct {
|
||||
text string
|
||||
entityIdx int
|
||||
isHeader bool
|
||||
}
|
||||
|
||||
var lines []displayLine
|
||||
entityIdx := 0
|
||||
for _, g := range groups {
|
||||
lines = append(lines, displayLine{
|
||||
text: dateHeaderStyle.Render("── " + g.label + " ──"),
|
||||
isHeader: true,
|
||||
})
|
||||
for _, e := range g.entities {
|
||||
line := renderEntity(e, width-4)
|
||||
for i, e := range g.entities {
|
||||
var gutter string
|
||||
if i == 0 {
|
||||
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||
} else {
|
||||
gutter = gutterStyle.Render(" │ ")
|
||||
}
|
||||
line := gutter + renderEntity(e, entityWidth)
|
||||
lines = append(lines, displayLine{
|
||||
text: line,
|
||||
entityIdx: entityIdx,
|
||||
@@ -124,21 +128,17 @@ func (l listModel) view(width int) string {
|
||||
}
|
||||
}
|
||||
|
||||
cursorLine := l.cursorDisplayLine(groups)
|
||||
visible := l.visibleCount()
|
||||
|
||||
offset := 0
|
||||
if cursorLine >= visible {
|
||||
offset = cursorLine - visible + 1
|
||||
if l.cursor >= visible {
|
||||
offset = l.cursor - visible + 1
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
end := min(offset+visible, len(lines))
|
||||
for i := offset; i < end; i++ {
|
||||
dl := lines[i]
|
||||
if dl.isHeader {
|
||||
b.WriteString(dl.text)
|
||||
} else if dl.entityIdx == l.cursor {
|
||||
if dl.entityIdx == l.cursor {
|
||||
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||
} else {
|
||||
b.WriteString(listItemStyle.Render(dl.text))
|
||||
@@ -151,22 +151,6 @@ func (l listModel) view(width int) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (l listModel) cursorDisplayLine(groups []dateGroup) int {
|
||||
line := 0
|
||||
entityIdx := 0
|
||||
for _, g := range groups {
|
||||
line++
|
||||
for range g.entities {
|
||||
if entityIdx == l.cursor {
|
||||
return line
|
||||
}
|
||||
line++
|
||||
entityIdx++
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l listModel) visibleCount() int {
|
||||
if l.height <= 0 {
|
||||
return 20
|
||||
@@ -203,6 +187,14 @@ func formatDateLabel(t time.Time) string {
|
||||
return strings.ToLower(t.Format("Jan 2"))
|
||||
}
|
||||
|
||||
func padRight(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) >= n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s + strings.Repeat(" ", n-len(r))
|
||||
}
|
||||
|
||||
func renderEntity(e *db.Entity, maxWidth int) string {
|
||||
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||
style := glyphStyle
|
||||
|
||||
+203
-8
@@ -2,8 +2,10 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
@@ -57,6 +59,13 @@ func (s cardsSort) next() cardsSort {
|
||||
}
|
||||
}
|
||||
|
||||
type focusPane int
|
||||
|
||||
const (
|
||||
focusList focusPane = iota
|
||||
focusDetail
|
||||
)
|
||||
|
||||
type model struct {
|
||||
store *db.Store
|
||||
state viewState
|
||||
@@ -73,6 +82,9 @@ type model struct {
|
||||
absorb absorbModel
|
||||
showHelp bool
|
||||
|
||||
focus focusPane
|
||||
splitDetail bool
|
||||
|
||||
filterTag string
|
||||
confirmID string
|
||||
cardsSort cardsSort
|
||||
@@ -155,10 +167,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.setSize(m.width, m.contentHeight())
|
||||
m.cards.setSize(m.width, m.contentHeight())
|
||||
m.detail.setSize(m.width, m.contentHeight())
|
||||
m.filter.setHeight(m.contentHeight())
|
||||
if !m.isSplit() && m.splitDetail {
|
||||
m.state = stateDetail
|
||||
m.splitDetail = false
|
||||
m.focus = focusList
|
||||
}
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
|
||||
case entitiesLoadedMsg:
|
||||
@@ -176,6 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case entityCreatedMsg:
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.recalcSizes()
|
||||
m.status = "created"
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
|
||||
@@ -279,6 +294,91 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.splitDetail && m.state == stateList {
|
||||
switch msg.String() {
|
||||
case "l":
|
||||
if m.focus == focusList {
|
||||
m.focus = focusDetail
|
||||
return m, nil
|
||||
}
|
||||
case "h":
|
||||
if m.focus == focusDetail {
|
||||
m.focus = focusList
|
||||
return m, nil
|
||||
}
|
||||
case "esc":
|
||||
if m.focus == focusDetail {
|
||||
m.focus = focusList
|
||||
return m, nil
|
||||
}
|
||||
m.splitDetail = false
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.focus == focusDetail {
|
||||
switch msg.String() {
|
||||
case "j", "k", "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d":
|
||||
var cmd tea.Cmd
|
||||
m.detail, cmd = m.detail.update(msg)
|
||||
return m, cmd
|
||||
case "c":
|
||||
if m.detail.entity != nil {
|
||||
return m, copyToClipboard(m.store, m.detail.entity)
|
||||
}
|
||||
return m, nil
|
||||
case "e":
|
||||
if m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||
return m, editInEditor(m.store, m.detail.entity)
|
||||
}
|
||||
return m, nil
|
||||
case "p":
|
||||
if m.detail.entity != nil && m.detail.entity.CardType == nil {
|
||||
m.promote = newPromoteModel(m.detail.entity.ID, m.detail.entity.Body)
|
||||
m.state = statePromote
|
||||
m.splitDetail = false
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case "D":
|
||||
if m.detail.entity != nil && m.detail.entity.CardType != nil {
|
||||
return m, demoteEntity(m.store, m.detail.entity.ID)
|
||||
}
|
||||
return m, nil
|
||||
case "!":
|
||||
if m.detail.entity != nil {
|
||||
return m, pinEntity(m.store, m.detail.entity)
|
||||
}
|
||||
return m, nil
|
||||
case "r":
|
||||
if m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
|
||||
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
|
||||
m.detail.mode = detailRun
|
||||
m.splitDetail = false
|
||||
m.state = stateDetail
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "f":
|
||||
if m.detail.entity != nil && m.detail.mode == detailPreview {
|
||||
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
|
||||
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
|
||||
m.detail.mode = detailFill
|
||||
m.splitDetail = false
|
||||
m.state = stateDetail
|
||||
m.recalcSizes()
|
||||
return m, m.detail.fill.ti.Focus()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
@@ -333,6 +433,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.state == stateList {
|
||||
m.state = stateInput
|
||||
m.input.focus()
|
||||
m.recalcSizes()
|
||||
return m, m.input.ti.Focus()
|
||||
}
|
||||
|
||||
@@ -344,10 +445,29 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
|
||||
}
|
||||
m.detail.mode = detailPreview
|
||||
if m.isSplit() {
|
||||
m.state = stateList
|
||||
m.splitDetail = true
|
||||
m.focus = focusList
|
||||
m.recalcSizes()
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
if m.detail.mode == detailFill {
|
||||
m.detail.mode = detailPreview
|
||||
if m.isSplit() {
|
||||
m.state = stateList
|
||||
m.splitDetail = true
|
||||
m.focus = focusList
|
||||
m.recalcSizes()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.isSplit() {
|
||||
m.state = stateList
|
||||
m.splitDetail = true
|
||||
m.focus = focusList
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
}
|
||||
m.state = stateList
|
||||
@@ -482,9 +602,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.state == stateList {
|
||||
if e := m.selectedEntity(); e != nil {
|
||||
m.detail.setEntity(e)
|
||||
if m.isSplit() {
|
||||
m.splitDetail = true
|
||||
m.focus = focusDetail
|
||||
m.recalcSizes()
|
||||
} else {
|
||||
m.state = stateDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -495,6 +621,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.list = m.list.update(msg)
|
||||
}
|
||||
if m.splitDetail {
|
||||
if e := m.selectedEntity(); e != nil {
|
||||
m.detail.setEntity(e)
|
||||
}
|
||||
}
|
||||
case stateDetail:
|
||||
var cmd tea.Cmd
|
||||
m.detail, cmd = m.detail.update(msg)
|
||||
@@ -508,6 +639,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "esc":
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.recalcSizes()
|
||||
return m, nil
|
||||
case "enter":
|
||||
result := m.input.submit()
|
||||
@@ -519,6 +651,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.searchTags = result.tags
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.recalcSizes()
|
||||
m.applySearch()
|
||||
return m, nil
|
||||
}
|
||||
@@ -601,10 +734,16 @@ func (m model) View() string {
|
||||
var content string
|
||||
switch m.state {
|
||||
case stateList, stateInput, stateConfirm:
|
||||
if m.mode == modeCards {
|
||||
content = m.cards.view(m.width)
|
||||
listContent := m.listContent()
|
||||
if m.splitDetail {
|
||||
lw, rw := m.splitWidths()
|
||||
ch := m.contentHeight()
|
||||
left := lipgloss.NewStyle().Width(lw).Height(ch).Render(listContent)
|
||||
sep := m.renderSeparator()
|
||||
right := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.detail.view(rw))
|
||||
content = lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)
|
||||
} else {
|
||||
content = m.list.view(m.width)
|
||||
content = listContent
|
||||
}
|
||||
case stateDetail:
|
||||
content = m.detail.view(m.width)
|
||||
@@ -622,6 +761,21 @@ func (m model) View() string {
|
||||
return header + "\n" + content + "\n" + footer
|
||||
}
|
||||
|
||||
func (m model) listContent() string {
|
||||
if m.mode == modeCards {
|
||||
lw := m.width
|
||||
if m.splitDetail {
|
||||
lw, _ = m.splitWidths()
|
||||
}
|
||||
return m.cards.view(lw)
|
||||
}
|
||||
lw := m.width
|
||||
if m.splitDetail {
|
||||
lw, _ = m.splitWidths()
|
||||
}
|
||||
return m.list.view(lw)
|
||||
}
|
||||
|
||||
func (m model) headerView() string {
|
||||
header := titleStyle.Render("nib")
|
||||
|
||||
@@ -678,7 +832,48 @@ func (m model) footerView() string {
|
||||
}
|
||||
|
||||
func (m model) contentHeight() int {
|
||||
return m.height - 3
|
||||
return m.height - 3 - m.drawerHeight()
|
||||
}
|
||||
|
||||
func (m model) drawerHeight() int {
|
||||
if m.state == stateInput {
|
||||
return drawerLines()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *model) recalcSizes() {
|
||||
ch := m.contentHeight()
|
||||
if m.isSplit() && m.splitDetail {
|
||||
lw, rw := m.splitWidths()
|
||||
m.list.setSize(lw, ch)
|
||||
m.cards.setSize(lw, ch)
|
||||
m.detail.setSize(rw, ch)
|
||||
} else {
|
||||
m.list.setSize(m.width, ch)
|
||||
m.cards.setSize(m.width, ch)
|
||||
m.detail.setSize(m.width, ch)
|
||||
}
|
||||
m.filter.setHeight(ch)
|
||||
}
|
||||
|
||||
func (m model) isSplit() bool {
|
||||
return m.width >= 100
|
||||
}
|
||||
|
||||
func (m model) splitWidths() (int, int) {
|
||||
left := m.width * 40 / 100
|
||||
right := m.width - left - 1
|
||||
return left, right
|
||||
}
|
||||
|
||||
func (m model) renderSeparator() string {
|
||||
ch := m.contentHeight()
|
||||
lines := make([]string, ch)
|
||||
for i := range lines {
|
||||
lines[i] = "│"
|
||||
}
|
||||
return separatorStyle.Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (m model) selectedEntity() *db.Entity {
|
||||
|
||||
@@ -47,7 +47,7 @@ func contextHints(m model) string {
|
||||
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
|
||||
}
|
||||
case stateInput:
|
||||
return "enter:submit esc:cancel"
|
||||
return ""
|
||||
case stateTagFilter:
|
||||
return "j/k:nav enter:select esc:cancel"
|
||||
case stateConfirm:
|
||||
@@ -57,6 +57,12 @@ func contextHints(m model) string {
|
||||
case stateAbsorb:
|
||||
return "j/k:nav enter:absorb esc:cancel"
|
||||
default:
|
||||
if m.splitDetail {
|
||||
if m.focus == focusDetail {
|
||||
return "h:list c:copy e:edit p:promote D:demote !:pin esc:back"
|
||||
}
|
||||
return "l:detail a:add d:del #:filter esc:close ?:help q:quit"
|
||||
}
|
||||
if m.mode == modeCards {
|
||||
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
||||
}
|
||||
|
||||
@@ -105,4 +105,21 @@ var (
|
||||
searchPillStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
|
||||
Bold(true)
|
||||
|
||||
gutterStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
drawerBorderStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
drawerHintsStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(2)
|
||||
|
||||
drawerPreviewStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}).
|
||||
PaddingLeft(2)
|
||||
|
||||
separatorStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user