ui: surface chrome ala tuios — bar workspace + modal + strip hint bertier
CI / test (push) Successful in 27s

Analisa referensi tuios (Charm/BubbleTea): overlay = surface solid + chip
judul aksen, bukan border; hint strip dibuang bertier saat sempit; geometri
satu sumber untuk render & hit-test.

- components.Chip: pill label (latar aksen + teks Base; mode terminal =
  reverse) — dipakai bar workspace & modal.
- Bar workspace jadi surface 3 baris (chip 'workspace', subjudul tautan
  seksi + tombol kanan, pill Cari inset Base) — pola mock draf-tia.
- Modal konverter jadi surface: chip judul, blok inset Base untuk input &
  output (padding & teks dalam satu gaya agar latar tak bolong oleh reset
  SGR), strip tombol Base, baris hint terstyling.
- Footer jadi dock hint: kunci aksen tebal + label redup, hint dibuang
  bertier (dekoratif dulu, esensial bertahan) dengan ellipsis, status
  diagnostik dipangkas ke sisa ruang — teks plain (hit-test) = teks tampil.
- Tes: chip (3 mode tema), footer bertier (plain==styled, batas lebar), e2e
  posisi klik digeser mengikuti bar 3 baris.
This commit is contained in:
oxpoe committed 2026-10-16 03:57:13 +07:00
1 parent 7af6d48a80
commit e903912029
8 files changed
+410 -68

No files matched your search

+4 -4
View File
@@ -909,7 +909,7 @@ func TestMouseAndNavigateKeys(t *testing.T) {
u.waitContains("Kontainer", 3*time.Second) u.waitContains("Kontainer", 3*time.Second)
u.send("j") u.send("j")
u.send("k") 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("r") // muat ulang
u.send("q") u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 { 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.waitContains(short(items[0].Name), 5*time.Second)
u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0) 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 // klik baris item ke-2: xterm y=7 (1-based) → app row 6 → indeks 1
// (header + bar workspace 4 baris + border panel di atasnya). // (header + bar workspace 3 baris + border panel di atasnya).
u.send("\x1b[<0;10;8M\x1b[<0;10;8m") u.send("\x1b[<0;10;7M\x1b[<0;10;7m")
u.waitHighlighted(short(items[1].Name), 3*time.Second) u.waitHighlighted(short(items[1].Name), 3*time.Second)
u.send("q") u.send("q")
+238 -25
View File
@@ -799,11 +799,11 @@ func (m Model) toastRows() int {
return 0 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. // daftar; 0 di beranda.
func (m Model) toolRows() int { func (m Model) toolRows() int {
if m.view == viewList { if m.view == viewList {
return 4 // border atas · subjudul+tombol · pill cari · border bawah return 3
} }
return 0 return 0
} }
@@ -979,23 +979,216 @@ func (m Model) View() tea.View {
return v return v
} }
func (m Model) footerPlain() string { // styleOn — style dengan latar bg opsional (Surface/Base) + warna teks fg.
hints := i18n.T("hint.footerHome") // 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.view == viewList {
if m.section == sectionStacks { if m.section == sectionStacks {
hints = i18n.T("hint.footerStacks") return i18n.T("hint.footerStacks")
} else {
hints = i18n.T("hint.footerContainers")
} }
return i18n.T("hint.footerContainers")
} }
return i18n.T("hint.footerHome")
}
func (m Model) footerStatus() string {
anim := "—" anim := "—"
if m.animating { if m.animating {
anim = fmt.Sprintf("%d", m.fps) 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 // mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
// tampak seperti tombol/tab/hint HARUS sampai di sini. // 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 return nil, false
} }
// 2) Bar workspace (di bawah header): cari + tombol stack. // 2) Bar workspace (di bawah header): cari + tombol stack + tautan seksi.
if m.view == viewList && y >= m.headerRows()-3 && y <= m.headerRows()-2 { if m.view == viewList && y >= m.headerRows()-2 && y <= m.headerRows()-1 {
return m.workspaceBarKeyAt(x, y), true return m.workspaceBarKeyAt(x, y), true
} }
// 3) Tab bar detail (view kontainers). // 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 // workspaceBarParts — potongan bar atas workspace (mock draf-tia): subjudul
// seksi, tombol kanan, pill cari. Satu sumber untuk render & hit-test klik. // 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) { 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() sub, spans = m.workspaceSubsections()
right = "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]" right = "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]"
gap = w - lipgloss.Width(sub) - lipgloss.Width(right) gap = w - lipgloss.Width(sub) - lipgloss.Width(right)
@@ -1117,34 +1310,54 @@ func (m Model) workspaceSectionAt(x int) (section, bool) {
return 0, false return 0, false
} }
// workspaceBar — bar atas workspace: judul di border, subjudul seksi (nama // workspaceBar — bar atas workspace sebagai surface (pola tuios overlay):
// seksi = tautan, klik = pindah seksi) + tombol stack/konversi, pill cari. // 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 { func (m Model) workspaceBar() string {
th := theme.Current() th := theme.Current()
sub, spans, right, search, gap := m.workspaceBarParts() 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) runes := []rune(sub)
var b strings.Builder var b strings.Builder
prev := 0 prev := 0
for _, sp := range spans { for _, sp := range spans {
b.WriteString(dim(string(runes[prev:sp[0]]))) b.WriteString(onPaint(bg, th.Subtext, string(runes[prev:sp[0]])))
b.WriteString(theme.Style(th.Accent).Render(string(runes[sp[0]:sp[1]]))) b.WriteString(styleOn(bg, th.Accent).Render(string(runes[sp[0]:sp[1]])))
prev = 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 + " ]"),
} }
return panel(i18n.T("workspace.title"), "", rows, m.width, 4, false)
// 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 styleOn(th.Base, th.Accent).Render(" " + inner + " ")
} }
// workspaceBarKeyAt — klik di bar workspace: nama seksi, pill Cari, tombol stack. // workspaceBarKeyAt — klik di bar workspace: nama seksi, pill Cari, tombol stack.
func (m *Model) workspaceBarKeyAt(x, y int) tea.Cmd { 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() sub, search := m.workspaceBarPlain()
if y == m.headerRows()-3 { if y == m.headerRows()-2 {
if s, ok := m.workspaceSectionAt(x); ok { if s, ok := m.workspaceSectionAt(x); ok {
m.jumpSection(s) m.jumpSection(s)
m.searching, m.query = false, "" m.searching, m.query = false, ""
@@ -1153,7 +1366,7 @@ func (m *Model) workspaceBarKeyAt(x, y int) tea.Cmd {
} }
} }
line := sub line := sub
if y == m.headerRows()-2 { if y == m.headerRows()-1 {
line = search line = search
} }
label, ok := labelZoneAt(line, x) label, ok := labelZoneAt(line, x)
+33 -1
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"charm.land/lipgloss/v2" "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/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" "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")+" ]") { if !strings.Contains(searchLine, "[ / "+i18n.T("toolbar.search")+" ]") {
t.Fatalf("w=%d: pill cari tidak ada: %q", width, searchLine) 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 { if w := lipgloss.Width(subLine); w > inner {
t.Fatalf("w=%d: subjudul+tombol lebar %d > %d", width, 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 // TestSearchJump — pencarian melompat ke item pertama yang cocok (daftar tetap
// utuh), dan nama item mengikuti seksi aktif. // utuh), dan nama item mengikuti seksi aktif.
func TestSearchJump(t *testing.T) { func TestSearchJump(t *testing.T) {
+85 -36
View File
@@ -14,6 +14,7 @@ import (
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" "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/i18n"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" "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" "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) { func (c *converterModal) geom(w, h int) (top, left, boxW, boxH int) {
boxW = min(100, max(w-6, 30)) 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) top = max(0, (h-boxH)/2)
left = max(0, (w-boxW)/2) left = max(0, (w-boxW)/2)
return return
} }
// layout — baris-baris konten (0-based, relatif terhadap isi panel). // layout — baris-baris konten (0-based, relatif terhadap blok modal):
func (c *converterModal) layout(boxH int) (prevH, nameRow, btnRow, hintRow int) { // chip · kosong · label input · blok input · kosong · label output · blok
inner := boxH - 2 // output · nama · tombol · hint.
prevH = max(3, inner-11) func (c *converterModal) layout(boxH int) (inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow int) {
nameRow = 1 + modalInputLines + 2 + prevH inputTop, inputRows = 3, modalInputLines
prevH = max(3, boxH-13)
prevTop = inputTop + inputRows + 2
nameRow = prevTop + prevH
btnRow = nameRow + 1 btnRow = nameRow + 1
hintRow = btnRow + 1 hintRow = btnRow + 1
return return
@@ -245,10 +249,10 @@ func (m *Model) modalClick(x, y int) tea.Cmd {
m.closeModal() m.closeModal()
return nil return nil
} }
row := y - (top + 1) row := y - top
_, nameRow, btnRow, _ := c.layout(boxH) inputTop, inputRows, _, _, nameRow, btnRow, _ := c.layout(boxH)
switch { switch {
case row >= 1 && row < 1+modalInputLines: case row >= inputTop && row < inputTop+inputRows:
c.focus = 0 c.focus = 0
case row == nameRow: case row == nameRow:
c.focus = 1 c.focus = 1
@@ -297,26 +301,73 @@ func labelZoneAt(line string, x int) (string, bool) {
// --- render --- // --- 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 { func (c *converterModal) view(w, h int) string {
th := theme.Current()
_, _, boxW, boxH := c.geom(w, h) _, _, boxW, boxH := c.geom(w, h)
inner := boxW - 4 inner := boxW - 4 // padding 2 sel tiap sisi (pola tuios)
prevH, _, _, _ := c.layout(boxH) inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow := c.layout(boxH)
rows := []string{dim(i18n.T("modal.inputLabel"))} rows := make([]string, boxH)
rows = append(rows, c.inputLines(inner)...) 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 { switch {
case c.notice != "": case c.notice != "":
label += " " + red(c.notice) return onPaint(th.Surface, th.Danger, " "+c.notice)
case c.stale(): case c.stale():
label += " " + dim(i18n.T("modal.noPreview")) return onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.noPreview"))
} }
rows = append(rows, "", label) return ""
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)
} }
func (c *converterModal) inputLines(inner int) []string { func (c *converterModal) inputLines(inner int) []string {
@@ -333,44 +384,42 @@ func (c *converterModal) inputLines(inner int) []string {
for len(lines) < modalInputLines { for len(lines) < modalInputLines {
lines = append(lines, "") lines = append(lines, "")
} }
out := make([]string, 0, len(lines)) return lines
for _, l := range lines {
out = append(out, text(l))
}
return out
} }
// previewLines — baris pratinjau mentah (tanpa gaya); warna dipasang oleh blok
// inset lewat previewErr → Danger, sisanya Subtext.
func (c *converterModal) previewLines(rows int) []string { func (c *converterModal) previewLines(rows int) []string {
out := make([]string, 0, rows) out := make([]string, 0, rows)
switch { switch {
case c.previewErr != "": case c.previewErr != "":
out = append(out, red(truncate(c.previewErr, 120))) out = append(out, truncate(c.previewErr, 120))
case len(c.preview) == 0: case len(c.preview) == 0:
out = append(out, dim(i18n.T("modal.noPreview"))) out = append(out, i18n.T("modal.noPreview"))
default: default:
for i := c.prevScroll; i < len(c.preview) && len(out) < rows; i++ { 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 — // WAJIB setinggi alokasi: baris nama/tombol harus tetap di posisi yang
// tanpa ini baris nama/tombol bergeser dari hit-test (bug nyata). // dihitung layout() (hit-test klik bergantung padanya — bug nyata).
for len(out) < rows { for len(out) < rows {
out = append(out, "") out = append(out, "")
} }
return out return out
} }
func (c *converterModal) nameLine() string { func (c *converterModal) nameLine(bg theme.Color) string {
marker := " " th := theme.Current()
style := theme.Style(theme.Current().Text) marker := onPaint(bg, "", " ")
if c.focus == 1 { if c.focus == 1 {
marker = theme.Style(theme.Current().Accent).Render("▶ ") marker = onPaint(bg, th.Accent, "▶ ")
} }
val := c.name val := c.name
if c.focus == 1 { if c.focus == 1 {
val += "▌" 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 { func (c *converterModal) buttonsLine() string {
+1 -1
View File
@@ -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" footerContainers: "[1-5] sections · [j/k] select · [/] search · [Tab] focus · [h/l] tab · [T] theme · [r] reload · [0] home · [q] quit"
footer: footer:
status: "%s animation %s fps · audio: %s" statusShort: "anim %s fps · audio: %s"
toast: toast:
demoTitle: "✔ [ SUCCESS ] Conversion Saved" demoTitle: "✔ [ SUCCESS ] Conversion Saved"
+1 -1
View File
@@ -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" footerContainers: "[1-5] bagian · [j/k] pilih · [/] cari · [Tab] fokus · [h/l] tab · [T] tema · [r] muat ulang · [0] beranda · [q] keluar"
footer: footer:
status: "%s animasi %s fps · audio: %s" statusShort: "animasi %s fps · audio: %s"
toast: toast:
demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan" demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan"
+22
View File
@@ -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)
}
+26
View File
@@ -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")
}