ui: surface chrome ala tuios — bar workspace + modal + strip hint bertier
CI / test (push) Successful in 27s
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:
1 parent
7af6d48a80
commit
e903912029
8 files changed
+410
-68
No files matched your search
+4
-4
@@ -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")
|
||||
|
||||
+238
-25
@@ -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")
|
||||
// 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 {
|
||||
hints = i18n.T("hint.footerStacks")
|
||||
} else {
|
||||
hints = i18n.T("hint.footerContainers")
|
||||
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:])))
|
||||
|
||||
rows := []string{
|
||||
b.String() + strings.Repeat(" ", gap) + theme.Style(th.Accent).Render(right),
|
||||
theme.Style(th.Accent).Render("[ " + search + " ]"),
|
||||
b.WriteString(onPaint(bg, th.Subtext, string(runes[prev:])))
|
||||
return b.String()
|
||||
}
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+85
-36
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user