package main import ( "fmt" "os" "path/filepath" "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 StateMainMenu StateWriteMessage StateDecryptMessage StateShowDecryptedMessage StateManageCiphers StateCreateCipherModal StateImportCipherModal StateExportCipherModal StateConfirmDeleteCipher ) type model struct { state AppState ciphers []string activeCipher string activeConfig *CipherConfig cursor int textInput textinput.Model // Reusable path/name textinput textarea textarea.Model // Plaintext message content filenameInput textinput.Model // Filename input for write screen viewport viewport.Model // Scrolling viewport for decrypted messages focusIndex int // For dual fields (e.g. Write Screen) statusMsg string lang string width int height int ready bool } func NewModel() model { ti := textinput.New() ti.Cursor.Style = cursorStyle ta := textarea.New() ta.SetWidth(60) ta.SetHeight(10) ta.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("62")) fi := textinput.New() fi.Cursor.Style = cursorStyle m := model{ state: StateStartup, textInput: ti, textarea: ta, filenameInput: fi, lang: "cs", focusIndex: 0, } m.loadCiphersAndActivateDefault() return m } func (m *model) loadCiphersAndActivateDefault() { cList, err := ListCiphers() if err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) return } if len(cList) == 0 { err = GenerateNewCipher("default") if err != nil { m.statusMsg = fmt.Sprintf("Error generating default: %v", err) return } cList, _ = ListCiphers() } m.ciphers = cList // Find if activeCipher is still in the list, otherwise select first found := false for _, c := range cList { if c == m.activeCipher { found = true break } } if !found && len(cList) > 0 { m.activeCipher = cList[0] } if m.activeCipher != "" { cfg, err := LoadCipher(m.activeCipher) if err == nil { m.activeConfig = cfg } else { m.statusMsg = fmt.Sprintf("Error: %v", err) } } } func (m model) Init() tea.Cmd { return tea.Batch(textinput.Blink, textarea.Blink, tea.EnterAltScreen) } 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.textarea.SetWidth(msg.Width - 10) m.textarea.SetHeight(msg.Height - 12) vpHeight := msg.Height - 8 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: // Global keys if msg.String() == "ctrl+c" { return m, tea.Quit } switch m.state { case StateStartup: if msg.String() == "enter" { m.state = StateMainMenu m.statusMsg = "" return m, nil } if msg.String() == "q" { return m, tea.Quit } case StateMainMenu: switch msg.String() { case "q", "esc": return m, tea.Quit case "l": if m.lang == "cs" { m.lang = "en" } else { m.lang = "cs" } m.statusMsg = "" return m, nil case "1": m.state = StateWriteMessage m.textarea.SetValue("") m.textarea.Focus() m.filenameInput.SetValue(fmt.Sprintf("zprava_%s.txt", time.Now().Format("20060102_150405"))) m.focusIndex = 0 m.statusMsg = "" return m, nil case "2": m.state = StateDecryptMessage m.textInput.Focus() m.textInput.SetValue("") m.statusMsg = "" return m, nil case "3": m.state = StateManageCiphers m.loadCiphersAndActivateDefault() // Find cursor index for active cipher m.cursor = 0 for idx, val := range m.ciphers { if val == m.activeCipher { m.cursor = idx break } } m.statusMsg = "" return m, nil } case StateWriteMessage: switch msg.String() { case "esc": m.state = StateMainMenu m.statusMsg = "" return m, nil case "tab", "shift+tab": if m.focusIndex == 0 { m.focusIndex = 1 m.textarea.Blur() m.filenameInput.Focus() } else { m.focusIndex = 0 m.filenameInput.Blur() m.textarea.Focus() } return m, nil case "ctrl+s": // Save & encrypt content := m.textarea.Value() filename := strings.TrimSpace(m.filenameInput.Value()) if filename == "" { filename = "zprava.txt" } if m.activeConfig == nil { m.statusMsg = fmt.Sprintf(tr.StatusError, "není vybraná žádná šifra") return m, nil } encrypted := EncryptMessage(content, m.activeConfig) exportDir, err := getExportDir() if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) return m, nil } fullPath := filepath.Join(exportDir, filename) err = os.WriteFile(fullPath, []byte(encrypted), 0644) if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) } else { m.statusMsg = fmt.Sprintf(tr.StatusSaved, fullPath) m.state = StateMainMenu } return m, nil } // Forward keys to the focused input if m.focusIndex == 0 { m.textarea, cmd = m.textarea.Update(msg) } else { m.filenameInput, cmd = m.filenameInput.Update(msg) } return m, cmd case StateDecryptMessage: switch msg.String() { case "esc": m.state = StateMainMenu m.statusMsg = "" return m, nil case "enter": path := strings.TrimSpace(m.textInput.Value()) if path == "" { return m, nil } var fullPath string if filepath.IsAbs(path) { fullPath = path } else { importDir, err := getImportDir() if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) return m, nil } fullPath = filepath.Join(importDir, path) } contentBytes, err := os.ReadFile(fullPath) if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) return m, nil } if m.activeConfig == nil { m.statusMsg = fmt.Sprintf(tr.StatusError, "není vybraná žádná šifra") return m, nil } decrypted := DecryptMessage(string(contentBytes), m.activeConfig) m.viewport.SetContent(decrypted) m.viewport.YOffset = 0 m.state = StateShowDecryptedMessage m.statusMsg = tr.StatusDecrypted return m, nil } m.textInput, cmd = m.textInput.Update(msg) return m, cmd case StateShowDecryptedMessage: switch msg.String() { case "esc", "q": m.state = StateMainMenu m.statusMsg = "" return m, nil } m.viewport, cmd = m.viewport.Update(msg) return m, cmd case StateManageCiphers: switch msg.String() { case "esc", "q": m.state = StateMainMenu m.statusMsg = "" return m, nil case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < len(m.ciphers)-1 { m.cursor++ } case "enter": if len(m.ciphers) > 0 { m.activeCipher = m.ciphers[m.cursor] m.loadCiphersAndActivateDefault() m.statusMsg = fmt.Sprintf(tr.StatusActive, m.activeCipher) m.state = StateMainMenu } case "c": m.state = StateCreateCipherModal m.textInput.SetValue("") m.textInput.Focus() m.statusMsg = "" return m, nil case "i": m.state = StateImportCipherModal m.textInput.SetValue("") m.textInput.Focus() m.statusMsg = "" return m, nil case "e": if m.activeCipher != "" { m.state = StateExportCipherModal m.textInput.SetValue(fmt.Sprintf("%s_export.json", m.activeCipher)) m.textInput.Focus() m.statusMsg = "" return m, nil } case "d": if len(m.ciphers) > 0 { m.state = StateConfirmDeleteCipher m.statusMsg = "" return m, nil } } case StateCreateCipherModal: switch msg.String() { case "esc": m.state = StateManageCiphers m.statusMsg = "" return m, nil case "enter": name := strings.TrimSpace(m.textInput.Value()) if name == "" { return m, nil } err := GenerateNewCipher(name) if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) } else { m.statusMsg = fmt.Sprintf(tr.StatusCreated, name) m.activeCipher = name m.loadCiphersAndActivateDefault() } m.state = StateManageCiphers return m, nil } m.textInput, cmd = m.textInput.Update(msg) return m, cmd case StateImportCipherModal: switch msg.String() { case "esc": m.state = StateManageCiphers m.statusMsg = "" return m, nil case "enter": path := strings.TrimSpace(m.textInput.Value()) if path == "" { return m, nil } name, err := ImportCipher(path) if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) } else { m.statusMsg = fmt.Sprintf(tr.StatusImported, name) m.activeCipher = name m.loadCiphersAndActivateDefault() } m.state = StateManageCiphers return m, nil } m.textInput, cmd = m.textInput.Update(msg) return m, cmd case StateExportCipherModal: switch msg.String() { case "esc": m.state = StateManageCiphers m.statusMsg = "" return m, nil case "enter": path := strings.TrimSpace(m.textInput.Value()) if path == "" { return m, nil } err := ExportCipher(m.activeCipher, path) if err != nil { m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error()) } else { m.statusMsg = fmt.Sprintf(tr.StatusExported, path) } m.state = StateManageCiphers return m, nil } m.textInput, cmd = m.textInput.Update(msg) return m, cmd case StateConfirmDeleteCipher: switch msg.String() { case "y", "Y": if len(m.ciphers) > 0 { selected := m.ciphers[m.cursor] dir, err := getCipherDir() if err == nil { path := filepath.Join(dir, selected+".json") _ = os.Remove(path) } m.loadCiphersAndActivateDefault() m.cursor = 0 } m.state = StateManageCiphers return m, nil case "n", "N", "esc": m.state = StateManageCiphers return m, nil } } } return m, nil } func (m model) View() string { tr := Translations[m.lang] var b strings.Builder // Top Title b.WriteString(titleStyle.Render(tr.Title)) b.WriteString("\n") // Status Line activeName := m.activeCipher if activeName == "" { activeName = "none" } b.WriteString(fmt.Sprintf(tr.ActiveCipher, activeName) + "\n") if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg) + "\n") } b.WriteString("\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-SECRET 🦅")+"\n\n"+ tr.StartupSubtitle+"\n\n"+ tr.StartupHelp+"\n\n"+ "License: MIT | © 2026 Kankys")) case StateMainMenu: b.WriteString(fmt.Sprintf(" %s\n %s\n %s\n\n", tr.MenuWrite, tr.MenuDecrypt, tr.MenuManage)) b.WriteString(fmt.Sprintf(" %s | %s\n", tr.MenuLang, tr.MenuQuit)) case StateWriteMessage: b.WriteString(tr.WriteTitle + "\n\n") // Render content textarea taView := m.textarea.View() if m.focusIndex == 0 { taView = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("205")).Render(taView) } else { taView = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Render(taView) } b.WriteString(tr.WritePromptContent + "\n" + taView + "\n\n") // Render filename input b.WriteString(tr.WritePromptFilename) if m.focusIndex == 1 { b.WriteString(focusedStyle.Render(m.filenameInput.View()) + "\n\n") } else { b.WriteString(m.filenameInput.View() + "\n\n") } b.WriteString(tr.WriteHelp) case StateDecryptMessage: b.WriteString(tr.DecryptTitle + "\n\n") b.WriteString(tr.DecryptPromptPath + m.textInput.View() + "\n\n") b.WriteString(tr.DecryptHelp) case StateShowDecryptedMessage: b.WriteString(tr.DecryptResult + "\n") b.WriteString(confirmBoxStyle.Render(m.viewport.View()) + "\n\n") b.WriteString("q/esc: " + tr.MenuQuit) case StateManageCiphers: b.WriteString(fmt.Sprintf("--- %s ---\n\n", tr.ManageTitle)) if len(m.ciphers) == 0 { b.WriteString(" " + tr.NoCiphers + "\n\n") } else { for i, c := range m.ciphers { cursor := " " if m.cursor == i { cursor = "> " } nameStr := c if c == m.activeCipher { nameStr += " (aktivní)" } if m.cursor == i { b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%s", cursor, nameStr)) + "\n") } else { b.WriteString(fmt.Sprintf("%s%s\n", cursor, nameStr) + "\n") } } } b.WriteString("\n" + fmt.Sprintf("%s | %s | %s | %s | %s\n", tr.ManageCreateKey, tr.ManageImportKey, tr.ManageExportKey, tr.ManageDeleteKey, tr.ManageBackKey)) b.WriteString(tr.ManageHelp) case StateCreateCipherModal: b.WriteString(tr.CreateTitle + "\n\n") b.WriteString(tr.CreatePrompt + m.textInput.View() + "\n\n") b.WriteString(tr.CreateHelp) case StateImportCipherModal: b.WriteString(tr.ImportTitle + "\n\n") b.WriteString(tr.ImportPrompt + m.textInput.View() + "\n\n") b.WriteString(tr.ImportHelp) case StateExportCipherModal: b.WriteString(tr.ExportTitle + "\n\n") b.WriteString(tr.ExportPrompt + m.textInput.View() + "\n\n") b.WriteString(tr.ExportHelp) case StateConfirmDeleteCipher: selected := "" if len(m.ciphers) > m.cursor { selected = m.ciphers[m.cursor] } confirmText := fmt.Sprintf(tr.ConfirmDelete+"\n\n%s | %s", selected, tr.ConfirmYes, tr.ConfirmNo) b.WriteString(confirmBoxStyle.Render(confirmText)) } return appStyle.Render(b.String()) }