diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 321431c..deed708 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -909,7 +909,7 @@ func TestMouseAndNavigateKeys(t *testing.T) { u.waitContains("Kontainer", 3*time.Second) u.send("j") u.send("k") - u.send("\x1b[<0;10;8M\x1b[<0;10;8m") // klik kiri di baris daftar sidebar + u.send("\x1b[<0;10;7M\x1b[<0;10;7m") // klik kiri di baris daftar sidebar u.send("r") // muat ulang u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { @@ -948,9 +948,9 @@ func TestClickSelectsContainer(t *testing.T) { u.waitContains(short(items[0].Name), 5*time.Second) u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0) - // klik baris item ke-2: xterm y=8 (1-based) → app row 7 → indeks 1 - // (header + bar workspace 4 baris + border panel di atasnya). - u.send("\x1b[<0;10;8M\x1b[<0;10;8m") + // klik baris item ke-2: xterm y=7 (1-based) → app row 6 → indeks 1 + // (header + bar workspace 3 baris + border panel di atasnya). + u.send("\x1b[<0;10;7M\x1b[<0;10;7m") u.waitHighlighted(short(items[1].Name), 3*time.Second) u.send("q") diff --git a/internal/app/app.go b/internal/app/app.go index 3a06b5f..bd30feb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -799,11 +799,11 @@ func (m Model) toastRows() int { return 0 } -// toolRows — bar atas workspace (judul + subjudul + cari + tombol) di view +// toolRows — bar atas workspace (chip judul · subjudul+tombol · cari) di view // daftar; 0 di beranda. func (m Model) toolRows() int { if m.view == viewList { - return 4 // border atas · subjudul+tombol · pill cari · border bawah + return 3 } return 0 } @@ -979,23 +979,216 @@ func (m Model) View() tea.View { return v } -func (m Model) footerPlain() string { - hints := i18n.T("hint.footerHome") - if m.view == viewList { - if m.section == sectionStacks { - hints = i18n.T("hint.footerStacks") - } else { - hints = i18n.T("hint.footerContainers") +// styleOn — style dengan latar bg opsional (Surface/Base) + warna teks fg. +// Latar kosong (mode terminal) = tanpa latar, biarkan terminal pengguna. +func styleOn(bg, fg theme.Color) lipgloss.Style { + st := theme.Style(fg) + if bg != "" { + st = st.Background(lipgloss.Color(string(bg))) + } + return st +} + +// onPaint — teks dengan latar bg + warna fg. +func onPaint(bg, fg theme.Color, s string) string { return styleOn(bg, fg).Render(s) } + +// fill — pad/truncate s ke lebar w sel; padding-nya sendiri diberi latar bg +// (segmen di dalam s sudah membawa latarnya masing-masing — reset SGR di +// dalam s menghapus latar induk, jadi padding tidak boleh ikut ditumpuk). +func fill(s string, w int, bg theme.Color) string { + s = ansiTruncate(s, w) + if gap := w - lipgloss.Width(s); gap > 0 { + if bg != "" { + return s + styleOn(bg, "").Render(strings.Repeat(" ", gap)) + } + return s + strings.Repeat(" ", gap) + } + return s +} + +// hint — satu pasangan tombol/label di strip hint (footer & modal), pola tuios. +type hint struct { + key string // kosong = label saja + label string +} + +// parseHints — "[u] jalankan · [s] henti" → daftar hint (kunci + label). +func parseHints(text string) []hint { + var out []hint + for _, seg := range strings.Split(text, " · ") { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + if strings.HasPrefix(seg, "[") { + if i := strings.IndexByte(seg, ']'); i > 0 { + out = append(out, hint{key: seg[1:i], label: strings.TrimSpace(seg[i+1:])}) + continue + } + } + out = append(out, hint{label: seg}) + } + return out +} + +// hintTier — prioritas saat strip tidak muat (pola tuios: essential bertahan, +// dekoratif dibuang lebih dulu). +func hintTier(key string) int { + key = strings.Split(key, "/")[0] + switch key { + case "q", "esc", "enter", "1", "2", "3", "4", "5", "1-5", "0", "/", "c", + "u", "s", "R", "p", "D": + return 2 // keluar, navigasi dasar, aksi utama seksi + case "T", "t", "a": + return 0 // dekoratif + default: + return 1 + } +} + +const hintSep = " · " + +func hintWidth(h hint) int { + if h.key == "" { + return len([]rune(h.label)) + } + return len([]rune(h.key)) + len([]rune(h.label)) + 3 // "[k] label" +} + +func hintPlainText(hs []hint, dropped bool) string { + parts := make([]string, 0, len(hs)) + for _, h := range hs { + if h.key == "" { + parts = append(parts, h.label) + continue + } + parts = append(parts, "["+h.key+"] "+h.label) + } + out := strings.Join(parts, hintSep) + if dropped { + out += hintSep + "…" + } + return out +} + +func hintsWidth(hs []hint, dropped bool) int { + w := 0 + for i, h := range hs { + if i > 0 { + w += len([]rune(hintSep)) + } + w += hintWidth(h) + } + if dropped && len(hs) > 0 { + w += len([]rune(hintSep)) + 1 + } + return w +} + +// fitHints — buang hint dari tier terendah (paling kanan lebih dulu) sampai +// muat; kembalikan yang bertahan + tanda ada yang dibuang (ellipsis). +func fitHints(hs []hint, width int) ([]hint, bool) { + out := append([]hint(nil), hs...) + if hintsWidth(out, false) <= width { + return out, false + } + for len(out) > 1 { + minTier := hintTier(out[0].key) + for _, h := range out[1:] { + if t := hintTier(h.key); t < minTier { + minTier = t + } + } + idx := -1 + for i := len(out) - 1; i >= 0; i-- { + if hintTier(out[i].key) == minTier { + idx = i + break + } + } + if idx < 0 { + break + } + out = append(out[:idx], out[idx+1:]...) + if hintsWidth(out, true) <= width { + return out, true } } + return out, true +} + +// hintStyledText — strip hint bergaya: tombol aksen tebal, label redup. +// Lebar tampilan identik dengan hintPlainText (styles tidak mengubah lebar). +func hintStyledText(hs []hint, dropped bool, bg theme.Color) string { + keyStyle := styleOn(bg, theme.Current().Accent).Bold(true) + labelStyle := styleOn(bg, theme.Current().Subtext) + sep := labelStyle.Render(hintSep) + var b strings.Builder + for i, h := range hs { + if i > 0 { + b.WriteString(sep) + } + if h.key == "" { + b.WriteString(labelStyle.Render(h.label)) + continue + } + b.WriteString(keyStyle.Render("[" + h.key + "]")) + if h.label != "" { + b.WriteString(labelStyle.Render(" " + h.label)) + } + } + if dropped { + b.WriteString(sep + labelStyle.Render("…")) + } + return b.String() +} + +func (m Model) hintText() string { + if m.view == viewList { + if m.section == sectionStacks { + return i18n.T("hint.footerStacks") + } + return i18n.T("hint.footerContainers") + } + return i18n.T("hint.footerHome") +} + +func (m Model) footerStatus() string { anim := "—" if m.animating { anim = fmt.Sprintf("%d", m.fps) } - return truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width) + return i18n.Tf("footer.statusShort", anim, m.sound.Status()) } -func (m Model) footerLine() string { return dim(m.footerPlain()) } +// footerLayout — baris status bergaya dock: hint di kiri (dibuang bertier saat +// sempit), status diagnostik di kanan (dipangkas ke sisa ruang). Plain & +// styled dibangun bersama supaya hit-test klik selalu cocok dengan yang tampak. +func (m Model) footerLayout() (plain, styled string) { + kept, dropped := fitHints(parseHints(m.hintText()), max(m.width-12, 8)) + hintPlain := hintPlainText(kept, dropped) + hintStyled := hintStyledText(kept, dropped, "") + + status := m.footerStatus() + room := m.width - len([]rune(hintPlain)) - 2 + if room < 8 { + return truncate(hintPlain, m.width), ansiTruncate(hintStyled, m.width) + } + status = truncate(status, room) + gap := max(m.width-len([]rune(hintPlain))-len([]rune(status)), 1) + return hintPlain + strings.Repeat(" ", gap) + status, + hintStyled + strings.Repeat(" ", gap) + dim(status) +} + +func (m Model) footerPlain() string { + plain, _ := m.footerLayout() + return plain +} + +func (m Model) footerLine() string { + _, styled := m.footerLayout() + return styled +} // mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang // tampak seperti tombol/tab/hint HARUS sampai di sini. @@ -1009,8 +1202,8 @@ func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) { } return nil, false } - // 2) Bar workspace (di bawah header): cari + tombol stack. - if m.view == viewList && y >= m.headerRows()-3 && y <= m.headerRows()-2 { + // 2) Bar workspace (di bawah header): cari + tombol stack + tautan seksi. + if m.view == viewList && y >= m.headerRows()-2 && y <= m.headerRows()-1 { return m.workspaceBarKeyAt(x, y), true } // 3) Tab bar detail (view kontainers). @@ -1075,7 +1268,7 @@ func (m Model) workspaceSubsections() (sub string, spans [][2]int) { // workspaceBarParts — potongan bar atas workspace (mock draf-tia): subjudul // seksi, tombol kanan, pill cari. Satu sumber untuk render & hit-test klik. func (m Model) workspaceBarParts() (sub string, spans [][2]int, right, search string, gap int) { - w := max(m.width-4, 10) + w := max(m.width-2, 10) sub, spans = m.workspaceSubsections() right = "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]" gap = w - lipgloss.Width(sub) - lipgloss.Width(right) @@ -1117,34 +1310,54 @@ func (m Model) workspaceSectionAt(x int) (section, bool) { return 0, false } -// workspaceBar — bar atas workspace: judul di border, subjudul seksi (nama -// seksi = tautan, klik = pindah seksi) + tombol stack/konversi, pill cari. +// workspaceBar — bar atas workspace sebagai surface (pola tuios overlay): +// baris chip judul, subjudul seksi (nama seksi = tautan) + tombol kanan, pill +// cari inset. 3 baris, latar token Surface; mode terminal tanpa latar. func (m Model) workspaceBar() string { th := theme.Current() sub, spans, right, search, gap := m.workspaceBarParts() + row1 := onPaint(th.Surface, "", " ") + components.Chip(i18n.T("workspace.title")) + row2 := onPaint(th.Surface, "", " ") + styledSubtitle(th.Surface, sub, spans) + + onPaint(th.Surface, "", strings.Repeat(" ", gap)) + + styleOn(th.Surface, th.Accent).Render(right) + row3 := onPaint(th.Surface, "", " ") + m.searchPill(th.Surface, search) + + return fill(row1, m.width, th.Surface) + "\n" + + fill(row2, m.width, th.Surface) + "\n" + + fill(row3, m.width, th.Surface) +} + +// styledSubtitle — nama seksi (span) bergaya aksen = tautan, pemisah redup. +func styledSubtitle(bg theme.Color, sub string, spans [][2]int) string { + th := theme.Current() runes := []rune(sub) var b strings.Builder prev := 0 for _, sp := range spans { - b.WriteString(dim(string(runes[prev:sp[0]]))) - b.WriteString(theme.Style(th.Accent).Render(string(runes[sp[0]:sp[1]]))) + b.WriteString(onPaint(bg, th.Subtext, string(runes[prev:sp[0]]))) + b.WriteString(styleOn(bg, th.Accent).Render(string(runes[sp[0]:sp[1]]))) prev = sp[1] } - b.WriteString(dim(string(runes[prev:]))) + b.WriteString(onPaint(bg, th.Subtext, string(runes[prev:]))) + return b.String() +} - rows := []string{ - b.String() + strings.Repeat(" ", gap) + theme.Style(th.Accent).Render(right), - theme.Style(th.Accent).Render("[ " + search + " ]"), +// searchPill — pill cari: blok inset (latar Base) di atas surface bar. +func (m Model) searchPill(surface theme.Color, search string) string { + th := theme.Current() + inner := "[ " + search + " ]" + if th.Base == "" { // mode terminal: tak ada warna dasar kanvas + return styleOn(surface, th.Accent).Render(inner) } - return panel(i18n.T("workspace.title"), "", rows, m.width, 4, false) + return styleOn(th.Base, th.Accent).Render(" " + inner + " ") } // workspaceBarKeyAt — klik di bar workspace: nama seksi, pill Cari, tombol stack. func (m *Model) workspaceBarKeyAt(x, y int) tea.Cmd { - x -= 2 // bar dalam panel: konten mulai kolom 2 + x -= 1 // bar surface: konten mulai kolom 1 sub, search := m.workspaceBarPlain() - if y == m.headerRows()-3 { + if y == m.headerRows()-2 { if s, ok := m.workspaceSectionAt(x); ok { m.jumpSection(s) m.searching, m.query = false, "" @@ -1153,7 +1366,7 @@ func (m *Model) workspaceBarKeyAt(x, y int) tea.Cmd { } } line := sub - if y == m.headerRows()-2 { + if y == m.headerRows()-1 { line = search } label, ok := labelZoneAt(line, x) diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index 3ba9440..4de7965 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -5,6 +5,7 @@ import ( "testing" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" @@ -39,7 +40,7 @@ func TestWorkspaceBar(t *testing.T) { if !strings.Contains(searchLine, "[ / "+i18n.T("toolbar.search")+" ]") { t.Fatalf("w=%d: pill cari tidak ada: %q", width, searchLine) } - inner := width - 4 + inner := width - 2 // bar surface: padding 1 sel tiap sisi if w := lipgloss.Width(subLine); w > inner { t.Fatalf("w=%d: subjudul+tombol lebar %d > %d", width, w, inner) } @@ -110,6 +111,37 @@ func TestPanelTitleFits(t *testing.T) { } } +// TestFooterHintTiers — strip hint footer menyusut bertier: hint dekoratif +// dibuang lebih dulu (dengan ellipsis), status dipangkas ke sisa ruang; teks +// tampil (styled) harus identik dengan teks hit-test (plain). +func TestFooterHintTiers(t *testing.T) { + m := Model{width: 200, view: viewList, section: sectionContainers} + plain, styled := m.footerLayout() + if got := ansi.Strip(styled); got != plain { + t.Fatalf("plain != styled:\n%q\n%q", plain, got) + } + if !strings.Contains(plain, "[q] keluar") || !strings.Contains(plain, "audio:") { + t.Fatalf("lebar 200 harus memuat semua hint + status: %q", plain) + } + + for _, w := range []int{100, 60, 40} { + m.width = w + plain, styled = m.footerLayout() + if got := ansi.Strip(styled); got != plain { + t.Fatalf("w=%d: plain != styled:\n%q\n%q", w, plain, got) + } + if got := lipgloss.Width(plain); got > w { + t.Fatalf("w=%d: baris footer %d sel", w, got) + } + if w >= 60 && !strings.Contains(plain, "[q] keluar") { + t.Fatalf("w=%d: hint esensial [q] keluar ikut terbuang: %q", w, plain) + } + if w < 40 && !strings.Contains(plain, "…") { + t.Fatalf("w=%d: hint yang dibuang harus ditandai ellipsis: %q", w, plain) + } + } +} + // TestSearchJump — pencarian melompat ke item pertama yang cocok (daftar tetap // utuh), dan nama item mengikuti seksi aktif. func TestSearchJump(t *testing.T) { diff --git a/internal/app/modal.go b/internal/app/modal.go index 8e0f4ee..3217d2e 100644 --- a/internal/app/modal.go +++ b/internal/app/modal.go @@ -14,6 +14,7 @@ import ( "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme" ) @@ -210,17 +211,20 @@ func (m *Model) closeModal() { func (c *converterModal) geom(w, h int) (top, left, boxW, boxH int) { boxW = min(100, max(w-6, 30)) - boxH = min(26, max(h-4, 12)) + boxH = min(28, max(h-4, 16)) top = max(0, (h-boxH)/2) left = max(0, (w-boxW)/2) return } -// layout — baris-baris konten (0-based, relatif terhadap isi panel). -func (c *converterModal) layout(boxH int) (prevH, nameRow, btnRow, hintRow int) { - inner := boxH - 2 - prevH = max(3, inner-11) - nameRow = 1 + modalInputLines + 2 + prevH +// layout — baris-baris konten (0-based, relatif terhadap blok modal): +// chip · kosong · label input · blok input · kosong · label output · blok +// output · nama · tombol · hint. +func (c *converterModal) layout(boxH int) (inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow int) { + inputTop, inputRows = 3, modalInputLines + prevH = max(3, boxH-13) + prevTop = inputTop + inputRows + 2 + nameRow = prevTop + prevH btnRow = nameRow + 1 hintRow = btnRow + 1 return @@ -245,10 +249,10 @@ func (m *Model) modalClick(x, y int) tea.Cmd { m.closeModal() return nil } - row := y - (top + 1) - _, nameRow, btnRow, _ := c.layout(boxH) + row := y - top + inputTop, inputRows, _, _, nameRow, btnRow, _ := c.layout(boxH) switch { - case row >= 1 && row < 1+modalInputLines: + case row >= inputTop && row < inputTop+inputRows: c.focus = 0 case row == nameRow: c.focus = 1 @@ -297,26 +301,73 @@ func labelZoneAt(line string, x int) (string, bool) { // --- render --- +// view — modal sebagai surface (pola tuios overlay): chip judul, blok inset +// (latar Base) untuk input & output, strip tombol, baris hint tombol. func (c *converterModal) view(w, h int) string { + th := theme.Current() _, _, boxW, boxH := c.geom(w, h) - inner := boxW - 4 - prevH, _, _, _ := c.layout(boxH) + inner := boxW - 4 // padding 2 sel tiap sisi (pola tuios) + inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow := c.layout(boxH) - rows := []string{dim(i18n.T("modal.inputLabel"))} - rows = append(rows, c.inputLines(inner)...) + rows := make([]string, boxH) + put := func(i int, s string) { + if i >= 0 && i < boxH { + rows[i] = s + } + } + // block — satu baris blok inset: teks + padding dalam SATU gaya (latar Base + // + warna teks) supaya latar tidak bolong karena reset SGR di tengah baris. + block := func(text string, fg theme.Color) string { + row := " " + text + if gap := inner - lipgloss.Width(row); gap > 0 { + row += strings.Repeat(" ", gap) + } + return onPaint(th.Base, "", " ") + + styleOn(th.Base, fg).Render(ansiTruncate(row, inner)) + + onPaint(th.Base, "", " ") + } - label := dim(i18n.T("modal.previewLabel")) + put(0, onPaint(th.Surface, "", " ")+components.Chip(i18n.T("modal.convertTitle"))) + put(inputTop-1, onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.inputLabel"))) + + inLines := c.inputLines(inner - 2) + for i := 0; i < inputRows; i++ { + put(inputTop+i, block(inLines[i], th.Text)) + } + + put(prevTop-1, onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.previewLabel"))+c.previewNote()) + pvLevel := th.Subtext + if c.previewErr != "" { + pvLevel = th.Danger + } + pv := c.previewLines(prevH) + for i := 0; i < prevH; i++ { + put(prevTop+i, block(pv[i], pvLevel)) + } + + put(nameRow, onPaint(th.Surface, "", " ")+c.nameLine(th.Surface)) + put(btnRow, fill(onPaint(th.Base, "", " ")+ + styleOn(th.Base, th.Accent).Render(c.buttonsPlain()), boxW, th.Base)) + put(hintRow, onPaint(th.Surface, "", " ")+ + hintStyledText(parseHints(i18n.T("modal.hint")), false, th.Surface)) + + out := make([]string, 0, boxH) + for _, r := range rows { + out = append(out, fill(r, boxW, th.Surface)) + } + return strings.Join(out, "\n") +} + +// previewNote — catatan status pratinjau (notice/stale) di baris label. +func (c *converterModal) previewNote() string { + th := theme.Current() switch { case c.notice != "": - label += " " + red(c.notice) + return onPaint(th.Surface, th.Danger, " "+c.notice) case c.stale(): - label += " " + dim(i18n.T("modal.noPreview")) + return onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.noPreview")) } - rows = append(rows, "", label) - rows = append(rows, c.previewLines(prevH)...) - - rows = append(rows, c.nameLine(), c.buttonsLine(), dim(i18n.T("modal.hint"))) - return panel(i18n.T("modal.convertTitle"), "esc", rows, boxW, boxH, true) + return "" } func (c *converterModal) inputLines(inner int) []string { @@ -333,44 +384,42 @@ func (c *converterModal) inputLines(inner int) []string { for len(lines) < modalInputLines { lines = append(lines, "") } - out := make([]string, 0, len(lines)) - for _, l := range lines { - out = append(out, text(l)) - } - return out + return lines } +// previewLines — baris pratinjau mentah (tanpa gaya); warna dipasang oleh blok +// inset lewat previewErr → Danger, sisanya Subtext. func (c *converterModal) previewLines(rows int) []string { out := make([]string, 0, rows) switch { case c.previewErr != "": - out = append(out, red(truncate(c.previewErr, 120))) + out = append(out, truncate(c.previewErr, 120)) case len(c.preview) == 0: - out = append(out, dim(i18n.T("modal.noPreview"))) + out = append(out, i18n.T("modal.noPreview")) default: for i := c.prevScroll; i < len(c.preview) && len(out) < rows; i++ { - out = append(out, dim(c.preview[i])) + out = append(out, c.preview[i]) } } - // WAJIB setinggi alokasi: panel() mem-pad di bawah, bukan di dalam blok — - // tanpa ini baris nama/tombol bergeser dari hit-test (bug nyata). + // WAJIB setinggi alokasi: baris nama/tombol harus tetap di posisi yang + // dihitung layout() (hit-test klik bergantung padanya — bug nyata). for len(out) < rows { out = append(out, "") } return out } -func (c *converterModal) nameLine() string { - marker := " " - style := theme.Style(theme.Current().Text) +func (c *converterModal) nameLine(bg theme.Color) string { + th := theme.Current() + marker := onPaint(bg, "", " ") if c.focus == 1 { - marker = theme.Style(theme.Current().Accent).Render("▶ ") + marker = onPaint(bg, th.Accent, "▶ ") } val := c.name if c.focus == 1 { val += "▌" } - return marker + dim(i18n.T("modal.nameLabel")+" ") + style.Render("[ "+val+" ]") + return marker + onPaint(bg, th.Subtext, i18n.T("modal.nameLabel")+" ") + onPaint(bg, th.Text, "[ "+val+" ]") } func (c *converterModal) buttonsLine() string { diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 910b32b..cd2e479 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -152,7 +152,7 @@ hint: footerContainers: "[1-5] sections · [j/k] select · [/] search · [Tab] focus · [h/l] tab · [T] theme · [r] reload · [0] home · [q] quit" footer: - status: "%s animation %s fps · audio: %s" + statusShort: "anim %s fps · audio: %s" toast: demoTitle: "✔ [ SUCCESS ] Conversion Saved" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index b65f877..1a4b4b8 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -152,7 +152,7 @@ hint: footerContainers: "[1-5] bagian · [j/k] pilih · [/] cari · [Tab] fokus · [h/l] tab · [T] tema · [r] muat ulang · [0] beranda · [q] keluar" footer: - status: "%s animasi %s fps · audio: %s" + statusShort: "animasi %s fps · audio: %s" toast: demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan" diff --git a/internal/ui/components/chip.go b/internal/ui/components/chip.go new file mode 100644 index 0000000..282bd2f --- /dev/null +++ b/internal/ui/components/chip.go @@ -0,0 +1,22 @@ +package components + +import ( + "charm.land/lipgloss/v2" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme" +) + +// Chip — label di dalam pill (pola tuios overlay): latar aksen, teks warna +// base, bold, padding 1 sel. Mode terminal (Base kosong = latar diserahkan ke +// terminal pengguna) memakai reverse agar kontras tetap terjaga. +func Chip(label string) string { + th := theme.Current() + st := lipgloss.NewStyle().Bold(true).Padding(0, 1) + if th.Base == "" { + return st.Reverse(true).Render(label) + } + return st. + Background(lipgloss.Color(string(th.Accent))). + Foreground(lipgloss.Color(string(th.Base))). + Render(label) +} diff --git a/internal/ui/components/chip_test.go b/internal/ui/components/chip_test.go new file mode 100644 index 0000000..fe1953e --- /dev/null +++ b/internal/ui/components/chip_test.go @@ -0,0 +1,26 @@ +package components + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme" +) + +// TestChip — ukuran chip = label + 2 sel padding, isi label utuh di semua mode +// tema (mode terminal memakai reverse, bukan latar warna). +func TestChip(t *testing.T) { + for _, mode := range []theme.Mode{theme.ModeDark, theme.ModeLight, theme.ModeTerminal} { + theme.Set(mode, "mauve") + out := Chip("workspace") + if got := ansi.Strip(out); got != " workspace " { + t.Fatalf("mode %s: teks chip = %q", mode, got) + } + if !strings.Contains(out, "workspace") { + t.Fatalf("mode %s: label hilang di %q", mode, out) + } + } + theme.Set(theme.ModeDark, "mauve") +}