Files
doxa/doxa-cli/tui.go
T
2026-08-07 09:48:59 +02:00

749 lines
17 KiB
Go

package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
appStyle = lipgloss.NewStyle().
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62"))
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1).
MarginBottom(1)
selectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205"))
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
cursorStyle = focusedStyle.Copy()
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("205")).
Padding(1, 2).Align(lipgloss.Center)
)
type AppState int
const (
StateStartup AppState = iota
StateViewNotes
StateViewNoteModal
StateAddNote
StateEditNote
StateSearch
StateBrowseTags
StateConfirmDelete
)
type model struct {
notes []Note
filteredNotes []Note
cursor int
state AppState
browseTags []string
filterText string
textInputs []textinput.Model
contentInput textarea.Model
searchInput textinput.Model
viewport viewport.Model
ready bool
focusIndex int
selectedNoteID int64
statusMsg string
lang string
width int
height int
}
func NewModel() model {
inputs := make([]textinput.Model, 2)
for i := range inputs {
t := textinput.New()
t.Cursor.Style = cursorStyle
inputs[i] = t
}
inputs[0].Focus()
inputs[0].PromptStyle = focusedStyle
inputs[0].TextStyle = focusedStyle
ta := textarea.New()
ta.SetWidth(60)
ta.SetHeight(8)
ta.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("62"))
si := textinput.New()
si.Prompt = "🔍 "
si.Placeholder = "Hledat..."
m := model{
notes: loadNotes(),
cursor: 0,
state: StateStartup,
textInputs: inputs,
contentInput: ta,
searchInput: si,
focusIndex: 0,
lang: "cs",
}
m.applyFilter()
m.updatePlaceholders()
return m
}
func (m model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink, tea.EnterAltScreen)
}
func (m *model) applyFilter() {
if m.filterText == "" {
m.filteredNotes = m.notes
} else {
m.filteredNotes = []Note{}
query := strings.ToLower(m.filterText)
for _, n := range m.notes {
if strings.Contains(strings.ToLower(n.Name), query) ||
strings.Contains(strings.ToLower(n.Content), query) ||
strings.Contains(strings.ToLower(strings.Join(n.Tags, " ")), query) {
m.filteredNotes = append(m.filteredNotes, n)
}
}
}
if m.cursor >= len(m.filteredNotes) {
m.cursor = 0
if len(m.filteredNotes) > 0 {
m.cursor = len(m.filteredNotes) - 1
}
}
}
func (m *model) getBrowseTags() {
set := make(map[string]bool)
for _, n := range m.notes {
for _, t := range n.Tags {
if t != "" {
set[t] = true
}
}
}
m.browseTags = []string{}
for k := range set {
m.browseTags = append(m.browseTags, k)
}
// Manual sort
for i := 0; i < len(m.browseTags); i++ {
for j := i + 1; j < len(m.browseTags); j++ {
if m.browseTags[i] > m.browseTags[j] {
m.browseTags[i], m.browseTags[j] = m.browseTags[j], m.browseTags[i]
}
}
}
m.cursor = 0
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
tr := Translations[m.lang]
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.contentInput.SetWidth(msg.Width - 25)
m.contentInput.SetHeight(m.height/2 - 5)
headerHeight := 6
footerHeight := 3
vpHeight := msg.Height - headerHeight - footerHeight
if vpHeight < 1 {
vpHeight = 1
}
if !m.ready {
m.viewport = viewport.New(msg.Width-6, vpHeight)
m.ready = true
} else {
m.viewport.Width = msg.Width - 6
m.viewport.Height = vpHeight
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.state == StateConfirmDelete {
m.state = StateViewNotes
m.statusMsg = ""
return m, nil
}
if m.state == StateViewNotes {
if m.filterText != "" {
m.filterText = ""
m.applyFilter()
return m, nil
}
return m, tea.Quit
}
m.resetToView()
return m, nil
case "q":
if m.state == StateStartup {
return m, tea.Quit
}
if m.state == StateConfirmDelete {
m.state = StateViewNotes
m.statusMsg = ""
return m, nil
}
if m.state == StateViewNoteModal {
m.state = StateViewNotes
// Reset viewport to full list size
headerHeight := 6
footerHeight := 3
vpHeight := m.height - headerHeight - footerHeight
if vpHeight < 1 { vpHeight = 1 }
m.viewport.Width = m.width - 6
m.viewport.Height = vpHeight
return m, nil
}
if m.state == StateViewNotes {
if m.filterText != "" {
m.filterText = ""
m.applyFilter()
return m, nil
}
return m, tea.Quit
}
m.resetToView()
return m, nil
case "l":
if m.state == StateViewNotes {
if m.lang == "cs" {
m.lang = "en"
} else {
m.lang = "cs"
}
m.updatePlaceholders()
return m, nil
}
case "/":
if m.state == StateViewNotes {
m.state = StateSearch
m.searchInput.Focus()
m.searchInput.SetValue("")
return m, nil
}
case "tab", "shift+tab":
if m.state == StateAddNote || m.state == StateEditNote {
if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > 2 {
m.focusIndex = 0
}
if m.focusIndex < 0 {
m.focusIndex = 2
}
return m.updateFocus()
}
case "up", "k":
if m.state == StateViewNotes && m.cursor > 0 {
m.cursor--
m.viewport.LineUp(1)
}
if m.state == StateViewNoteModal {
m.viewport.LineUp(1)
}
if m.state == StateBrowseTags && m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.state == StateViewNotes {
max := len(m.filteredNotes)
if m.cursor < max-1 {
m.cursor++
m.viewport.LineDown(1)
}
}
if m.state == StateViewNoteModal {
m.viewport.LineDown(1)
}
if m.state == StateBrowseTags {
max := len(m.browseTags)
if m.cursor < max-1 {
m.cursor++
}
}
case "enter":
switch m.state {
case StateStartup:
m.state = StateViewNotes
case StateViewNotes:
if len(m.filteredNotes) > 0 {
m.state = StateViewNoteModal
m.selectedNoteID = m.filteredNotes[m.cursor].ID
var n Note
for _, note := range m.notes {
if note.ID == m.selectedNoteID {
n = note
break
}
}
modalContent := fmt.Sprintf("%s\n---\n[%d] %s\n\n%s\n\nTagy: %s",
tr.HelpModal,
n.ID, n.Name, n.Content, strings.Join(n.Tags, ", "),
)
// Set viewport size for modal (smaller than full screen)
modalWidth := m.width - 20
if modalWidth < 40 { modalWidth = 40 }
modalHeight := m.height - 10
if modalHeight < 10 { modalHeight = 10 }
m.viewport.Width = modalWidth
m.viewport.Height = modalHeight
m.viewport.SetContent(modalContent)
m.viewport.YOffset = 0
}
case StateSearch:
m.filterText = m.searchInput.Value()
m.applyFilter()
m.state = StateViewNotes
case StateBrowseTags:
if len(m.browseTags) > 0 {
m.filterText = m.browseTags[m.cursor]
m.applyFilter()
}
m.state = StateViewNotes
case StateAddNote:
if m.focusIndex != 1 {
m.handleAddNote()
}
case StateEditNote:
if m.focusIndex != 1 {
m.handleEditNote()
}
case StateConfirmDelete:
if m.focusIndex == 0 {
m.handleDeleteNote()
m.state = StateViewNotes
m.statusMsg = tr.StatusDeleted
return m, nil
} else {
m.state = StateViewNotes
m.statusMsg = ""
return m, nil
}
}
case "b":
if m.state == StateViewNotes {
m.state = StateBrowseTags
m.getBrowseTags()
return m, nil
}
case "1":
if m.state == StateViewNotes {
m.state = StateAddNote
m.resetInputs()
m.textInputs[0].SetValue("")
m.updatePlaceholders()
return m, nil
}
case "2":
if m.state == StateViewNotes && len(m.filteredNotes) > 0 {
m.state = StateEditNote
m.selectedNoteID = m.filteredNotes[m.cursor].ID
m.populateInputs(m.filteredNotes[m.cursor])
return m, nil
}
if m.state == StateViewNoteModal {
for _, n := range m.notes {
if n.ID == m.selectedNoteID {
m.state = StateEditNote
m.populateInputs(n)
break
}
}
return m, nil
}
case "3":
if m.state == StateViewNotes && len(m.filteredNotes) > 0 {
exportNote(m.notes, m.filteredNotes[m.cursor].ID)
m.statusMsg = tr.StatusExported
}
case "4":
if m.state == StateViewNotes && len(m.filteredNotes) > 0 {
m.state = StateConfirmDelete
m.selectedNoteID = m.filteredNotes[m.cursor].ID
m.focusIndex = 0
}
}
}
if m.state == StateSearch {
m.searchInput, cmd = m.searchInput.Update(msg)
return m, cmd
}
if m.state == StateAddNote || m.state == StateEditNote {
if m.focusIndex == 1 {
m.contentInput, cmd = m.contentInput.Update(msg)
} else {
idx := m.focusIndex
if idx > 1 {
idx--
}
m.textInputs[idx], cmd = m.textInputs[idx].Update(msg)
}
return m, cmd
}
if m.state == StateConfirmDelete {
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "y":
m.handleDeleteNote()
m.state = StateViewNotes
m.statusMsg = tr.StatusDeleted
return m, nil
case "n", "esc":
m.state = StateViewNotes
m.statusMsg = ""
return m, nil
}
}
}
return m, nil
}
func (m *model) updateFocus() (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
for i := range m.textInputs {
m.textInputs[i].Blur()
m.textInputs[i].PromptStyle = lipgloss.NewStyle()
}
m.contentInput.Blur()
m.searchInput.Blur()
switch m.focusIndex {
case 0:
cmds = append(cmds, m.textInputs[0].Focus())
m.textInputs[0].PromptStyle = focusedStyle
case 1:
cmds = append(cmds, m.contentInput.Focus())
case 2:
cmds = append(cmds, m.textInputs[1].Focus())
m.textInputs[1].PromptStyle = focusedStyle
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
tr := Translations[m.lang]
var b strings.Builder
b.WriteString(titleStyle.Render(tr.Title))
b.WriteString("\n")
if m.statusMsg != "" {
b.WriteString(statusStyle.Render(m.statusMsg) + "\n\n")
}
switch m.state {
case StateStartup:
eagle := `
_
( ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("`") + `
\ ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("\\") + `
/ / _
__ / / ( \
( "---" / / /
\ / / /
\ /-----' /
\ / /
\ / /
'-------'
`
b.WriteString(lipgloss.Place(m.width-12, 14, lipgloss.Center, lipgloss.Center,
eagle + "\n" +
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4")).Render("DOXA-CLI 🦅") + "\n\n" +
"Local-First Notes & Sovereignty\n\n" +
"Enter: Start | q: Quit\n\n" +
"License: MIT | © 2026 Kankys"))
case StateSearch:
b.WriteString(tr.SearchTitle + "\n" + m.searchInput.View() + "\n\n" + tr.HelpForm)
case StateBrowseTags:
b.WriteString(fmt.Sprintf("--- %s ---\n", tr.BrowseTags))
b.WriteString("Enter: Filtrovat | q: Zpět\n\n")
for i, item := range m.browseTags {
cursor := " "
if m.cursor == i {
cursor = "> "
}
b.WriteString(fmt.Sprintf("%s%s\n", cursor, item))
}
case StateViewNotes:
filterInfo := ""
if m.filterText != "" {
filterInfo = fmt.Sprintf(" [Filtr: %s]", m.filterText)
}
var listBuilder strings.Builder
if len(m.filteredNotes) == 0 {
listBuilder.WriteString(tr.NoNotes + "\n")
} else {
for i, n := range m.filteredNotes {
if m.cursor == i {
listBuilder.WriteString(selectedStyle.Render(fmt.Sprintf("> [%d] %s", n.ID, n.Name)) + "\n")
} else {
listBuilder.WriteString(fmt.Sprintf(" [%d] %s\n", n.ID, n.Name))
}
}
}
if m.ready {
m.viewport.SetContent(listBuilder.String())
}
b.WriteString(fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s\n\n",
tr.MenuAdd, tr.MenuEdit, tr.MenuExport, tr.MenuDelete, tr.MenuSearch, tr.MenuBrowse, tr.MenuLang))
b.WriteString(tr.ListTitle + filterInfo + "\n")
if m.ready {
b.WriteString(m.viewport.View() + "\n")
} else {
b.WriteString(listBuilder.String())
}
b.WriteString("\n" + tr.HelpList)
case StateAddNote, StateEditNote:
title := tr.AddTitle
if m.state == StateEditNote {
title = fmt.Sprintf("%s (%d)", tr.EditTitle, m.selectedNoteID)
}
b.WriteString(title + "\n\n")
b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelName, m.textInputs[0].View()))
b.WriteString(fmt.Sprintf("%-8s\n%s\n\n", tr.LabelContent, m.contentInput.View()))
b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelTags, m.textInputs[1].View()))
b.WriteString("\n" + tr.HelpForm)
case StateViewNoteModal:
if m.ready {
b.WriteString(confirmBoxStyle.Render(m.viewport.View()))
} else {
b.WriteString(confirmBoxStyle.Render("Načítání..."))
}
case StateConfirmDelete:
var noteToDelete Note
found := false
for _, n := range m.notes {
if n.ID == m.selectedNoteID {
noteToDelete = n
found = true
break
}
}
if !found {
m.state = StateViewNotes
m.statusMsg = "Chyba: Poznámka k smazání nebyla nalezena!"
return ""
}
confirmText := fmt.Sprintf("%s\n\n[%d] %s\nObsah: %s\n\n%s | %s",
tr.ConfirmDelete,
noteToDelete.ID,
noteToDelete.Name,
truncate(noteToDelete.Content, 40),
tr.ConfirmYes, tr.ConfirmNo,
)
b.WriteString(confirmBoxStyle.Render(confirmText))
}
content := b.String()
if m.width > 0 && m.height > 0 {
return appStyle.Width(m.width - 6).Height(m.height - 4).Render(content)
}
return appStyle.Render(content)
}
func (m *model) updatePlaceholders() {
tr := Translations[m.lang]
m.textInputs[0].Placeholder = tr.PlaceholderName
m.contentInput.Placeholder = tr.PlaceholderContent
m.textInputs[1].Placeholder = tr.PlaceholderTags
}
func (m *model) resetToView() {
m.state = StateViewNotes
m.statusMsg = ""
m.filterText = ""
m.applyFilter()
m.resetInputs()
}
func (m *model) resetInputs() {
for i := range m.textInputs {
m.textInputs[i].SetValue("")
m.textInputs[i].Blur()
}
m.contentInput.SetValue("")
m.searchInput.SetValue("")
m.focusIndex = 0
m.updateFocus()
}
func (m *model) populateInputs(n Note) {
m.textInputs[0].SetValue(n.Name)
m.contentInput.SetValue(n.Content)
m.textInputs[1].SetValue(strings.Join(n.Tags, ", "))
m.focusIndex = 0
m.updateFocus()
}
func (m *model) handleAddNote() {
tr := Translations[m.lang]
name := m.textInputs[0].Value()
content := m.contentInput.Value()
if name == "" || content == "" {
m.statusMsg = tr.ErrRequired
return
}
tags := []string{}
if m.textInputs[1].Value() != "" {
tags = strings.Split(m.textInputs[1].Value(), ",")
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
}
now := time.Now()
m.notes = append(m.notes, Note{
ID: now.UnixMilli(), Name: name, Content: content, Folder: "", Tags: tags, CreatedAt: now, ModifiedAt: now,
})
saveNotes(m.notes)
m.applyFilter()
m.statusMsg = tr.StatusSaved
m.state = StateViewNotes
}
func (m *model) handleEditNote() {
tr := Translations[m.lang]
for i, n := range m.notes {
if n.ID == m.selectedNoteID {
m.notes[i].Name = m.textInputs[0].Value()
m.notes[i].Content = m.contentInput.Value()
m.notes[i].Folder = ""
tags := []string{}
if m.textInputs[1].Value() != "" {
tags = strings.Split(m.textInputs[1].Value(), ",")
for j := range tags {
tags[j] = strings.TrimSpace(tags[j])
}
}
m.notes[i].Tags = tags
m.notes[i].ModifiedAt = time.Now()
break
}
}
saveNotes(m.notes)
m.applyFilter()
m.statusMsg = tr.StatusEdited
m.state = StateViewNotes
}
func (m *model) handleDeleteNote() {
noteIndex := -1
for i, n := range m.notes {
if n.ID == m.selectedNoteID {
noteIndex = i
break
}
}
if noteIndex != -1 {
m.notes = append(m.notes[:noteIndex], m.notes[noteIndex+1:]...)
saveNotes(m.notes)
}
m.applyFilter()
}
func exportNote(notes []Note, id int64) {
for _, note := range notes {
if note.ID == id {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Printf("Chyba při zjišťování domovského adresáře: %v\n", err)
return
}
exportDir := fmt.Sprintf("%s/doxa-cli-txt", homeDir)
if err := os.MkdirAll(exportDir, 0755); err != nil {
fmt.Printf("Chyba při vytváření adresáře pro export: %v\n", err)
return
}
fileName := fmt.Sprintf("%s_%d.txt", strings.ReplaceAll(note.Name, " ", "_"), note.ID)
fullPath := fmt.Sprintf("%s/%s", exportDir, fileName)
file, err := os.Create(fullPath)
if err != nil {
fmt.Printf("Chyba při vytváření souboru pro export: %v\n", err)
return
}
defer file.Close()
content := fmt.Sprintf("Název: %s\nTagy: %s\nVytvořeno: %s\n\n%s",
note.Name, strings.Join(note.Tags, ", "), note.CreatedAt.Format("2006-01-02 15:04"), note.Content)
_, _ = file.WriteString(content)
return
}
}
}
func truncate(s string, max int) string {
if len(s) > max {
return strings.TrimSpace(s[:max]) + "..."
}
return s
}
type errMsg error