Files
tia-love/internal/app/modal.go
T
oxpoe e903912029
CI / test (push) Successful in 27s
ui: surface chrome ala tuios — bar workspace + modal + strip hint bertier
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.
2026-10-16 03:57:13 +07:00

465 lines
12 KiB
Go

package app
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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"
)
// converterModal — modal konversi docker run → compose (PRD §5.1.3).
// Mouse-native: semua tombol bisa diklik; keyboard: Enter/Tab/Ctrl+S/Ctrl+R/Esc.
type converterModal struct {
input string // perintah mentah (multi-baris dari paste)
name string // nama stack tujuan
focus int // 0 = input, 1 = nama
preview []string
previewErr string
computedFor string // input saat pratinjau dihitung (deteksi basi)
prevScroll int
notice string // pesan validasi (mis. nama terpakai)
}
const modalInputLines = 5
func newConverterModal() *converterModal { return &converterModal{} }
// compute — jalankan konverter untuk pratinjau.
func (c *converterModal) compute() {
cmd := strings.TrimSpace(c.input)
c.computedFor = c.input
c.preview, c.previewErr, c.notice = nil, "", ""
c.prevScroll = 0
if cmd == "" {
return
}
res, err := stack.Convert(cmd)
if err != nil {
c.previewErr = err.Error()
return
}
c.preview = strings.Split(res.Output, "\n")
if strings.TrimSpace(c.name) == "" {
c.name = res.ServiceName
}
}
func (c *converterModal) stale() bool { return c.computedFor != c.input }
func (c *converterModal) previewText() string {
if len(c.preview) == 0 {
return ""
}
return strings.Join(c.preview, "\n") + "\n"
}
// --- pesan & perintah ---
type saveStackMsg struct {
name string
dir string
run bool
err error
}
type copyYAMLMsg struct{ err error }
var stackNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// saveFromModal — validasi di UI, penulisan file di dalam tea.Cmd (async).
func (m *Model) saveFromModal(run bool) tea.Cmd {
c := m.modal
if c == nil {
return nil
}
if c.stale() {
c.compute()
}
if len(c.preview) == 0 || c.previewErr != "" {
c.notice = i18n.T("modal.invalid")
return nil
}
name := strings.TrimSpace(c.name)
if !stackNameRE.MatchString(name) {
c.notice = i18n.T("modal.badName")
return nil
}
dir := filepath.Join(m.stacksDir, name)
content := c.previewText()
return func() tea.Msg {
if _, err := os.Stat(dir); err == nil {
return saveStackMsg{name: name, dir: dir, run: run, err: fmt.Errorf(i18n.T("modal.nameTaken"), name)}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return saveStackMsg{name: name, dir: dir, run: run, err: err}
}
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte(content), 0o644); err != nil {
return saveStackMsg{name: name, dir: dir, run: run, err: err}
}
return saveStackMsg{name: name, dir: dir, run: run}
}
}
// copyYAML — salin via OSC52 (bekerja juga lewat SSH/tmux; tanpa dependency).
func copyYAML(text string) tea.Cmd {
return func() tea.Msg {
if text == "" {
return copyYAMLMsg{err: fmt.Errorf("pratinjau kosong")}
}
seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
_, err := os.Stdout.WriteString(seq)
return copyYAMLMsg{err: err}
}
}
// --- input keyboard di modal ---
func (m *Model) modalKeyMsg(msg tea.KeyPressMsg) tea.Cmd {
c := m.modal
if msg.Mod&tea.ModCtrl != 0 {
switch msg.Code {
case 's':
return m.saveFromModal(false)
case 'r':
return m.saveFromModal(true)
case 'c':
m.closeModal()
}
return nil
}
switch msg.Code {
case tea.KeyEsc:
m.closeModal()
case tea.KeyEnter:
if c.focus == 0 {
c.compute()
c.focus = 1
} else {
return m.saveFromModal(false)
}
case tea.KeyTab:
c.focus = 1 - c.focus
case tea.KeyBackspace:
c.backspace()
case tea.KeyUp:
if c.prevScroll > 0 {
c.prevScroll--
}
case tea.KeyDown:
if c.prevScroll < max(0, len(c.preview)-1) {
c.prevScroll++
}
default:
if msg.Text != "" {
c.typeText(msg.Text)
}
}
return nil
}
func (c *converterModal) typeText(t string) {
c.notice = ""
if c.focus == 0 {
c.input += t
return
}
c.name += strings.NewReplacer("\n", "", " ", "").Replace(t) // nama: tanpa spasi/baris
}
func (c *converterModal) backspace() {
del := func(s string) string {
r := []rune(s)
if len(r) == 0 {
return s
}
return string(r[:len(r)-1])
}
if c.focus == 0 {
c.input = del(c.input)
} else {
c.name = del(c.name)
}
}
// modalPaste — teks tempel (bisa multi-baris) masuk ke field yang fokus.
func (m *Model) modalPaste(text string) {
c := m.modal
if c == nil || text == "" {
return
}
c.typeText(text)
}
func (m *Model) closeModal() {
m.modal = nil
m.sound.Play(audio.ModalClose)
}
// --- geometri & hit-test ---
func (c *converterModal) geom(w, h int) (top, left, boxW, boxH int) {
boxW = min(100, max(w-6, 30))
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 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
}
func (c *converterModal) buttonsPlain() string {
parts := []string{
"[ " + i18n.T("modal.btnCopy") + " ]",
"[ " + i18n.T("modal.btnSave") + " ]",
"[ " + i18n.T("modal.btnRun") + " ]",
"[ " + i18n.T("modal.btnCancel") + " ]",
}
return strings.Join(parts, " ")
}
// modalClick — klik: field → fokus; tombol → aksi; luar kotak → tutup.
func (m *Model) modalClick(x, y int) tea.Cmd {
c := m.modal
top, left, boxW, boxH := c.geom(m.width, m.height)
m.sound.Play(audio.Click)
if x < left || x >= left+boxW || y < top || y >= top+boxH {
m.closeModal()
return nil
}
row := y - top
inputTop, inputRows, _, _, nameRow, btnRow, _ := c.layout(boxH)
switch {
case row >= inputTop && row < inputTop+inputRows:
c.focus = 0
case row == nameRow:
c.focus = 1
case row == btnRow:
if act, ok := labelZoneAt(c.buttonsPlain(), x-left-2); ok {
switch act {
case i18n.T("modal.btnCopy"):
return copyYAML(c.previewText())
case i18n.T("modal.btnSave"):
return m.saveFromModal(false)
case i18n.T("modal.btnRun"):
return m.saveFromModal(true)
case i18n.T("modal.btnCancel"):
m.closeModal()
}
}
}
return nil
}
// labelZoneAt — cari segmen "[ label ]" pada baris; kembalikan label (trim).
func labelZoneAt(line string, x int) (string, bool) {
runes := []rune(line)
for i := 0; i < len(runes); i++ {
if runes[i] != '[' {
continue
}
j := i + 1
for j < len(runes) && runes[j] != ']' {
j++
}
if j >= len(runes) {
return "", false
}
end := j + 1
for end < len(runes) && runes[end] != '[' {
end++
}
if x >= i && x < end {
return strings.TrimSpace(string(runes[i+1 : j])), true
}
i = j
}
return "", false
}
// --- 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 // padding 2 sel tiap sisi (pola tuios)
inputTop, inputRows, prevTop, prevH, nameRow, btnRow, hintRow := c.layout(boxH)
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, "", " ")
}
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 != "":
return onPaint(th.Surface, th.Danger, " "+c.notice)
case c.stale():
return onPaint(th.Surface, th.Subtext, " "+i18n.T("modal.noPreview"))
}
return ""
}
func (c *converterModal) inputLines(inner int) []string {
shown := c.input
if c.focus == 0 {
shown += "▌"
} else {
shown += " "
}
lines := wrapText(shown, max(10, inner))
if len(lines) > modalInputLines {
lines = lines[len(lines)-modalInputLines:] // cursor selalu terlihat
}
for len(lines) < modalInputLines {
lines = append(lines, "")
}
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, truncate(c.previewErr, 120))
case len(c.preview) == 0:
out = append(out, i18n.T("modal.noPreview"))
default:
for i := c.prevScroll; i < len(c.preview) && len(out) < rows; i++ {
out = append(out, c.preview[i])
}
}
// 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(bg theme.Color) string {
th := theme.Current()
marker := onPaint(bg, "", " ")
if c.focus == 1 {
marker = onPaint(bg, th.Accent, "▶ ")
}
val := c.name
if c.focus == 1 {
val += "▌"
}
return marker + onPaint(bg, th.Subtext, i18n.T("modal.nameLabel")+" ") + onPaint(bg, th.Text, "[ "+val+" ]")
}
func (c *converterModal) buttonsLine() string {
return theme.Style(theme.Current().Accent).Render(c.buttonsPlain())
}
// wrapText — bungkus teks per karakter ke lebar w (pertahankan newline).
func wrapText(s string, w int) []string {
var out []string
for _, raw := range strings.Split(s, "\n") {
r := []rune(raw)
if len(r) == 0 {
out = append(out, "")
continue
}
for len(r) > w {
out = append(out, string(r[:w]))
r = r[w:]
}
out = append(out, string(r))
}
return out
}
// overlayModal — timpa baris tengah layar dengan kotak modal.
func (m Model) overlayModal(base string) string {
if m.modal == nil {
return base
}
W, H := max(m.width, 20), max(m.height, 10)
top, _, _, _ := m.modal.geom(W, H)
box := strings.Split(m.modal.view(W, H), "\n")
lines := strings.Split(base, "\n")
for i, bl := range box {
row := top + i
if row < 0 || row >= len(lines) {
continue
}
lines[row] = lipgloss.PlaceHorizontal(W, lipgloss.Center, bl)
}
return strings.Join(lines, "\n")
}