From d7c9c055a0a8a132e85b4c0de507297cadf6a11e Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sun, 13 Jul 2025 20:40:18 -0700 Subject: [PATCH] update layout --- go.mod | 2 + go.sum | 8 +- internal/app/app.go | 89 ++++++++++- internal/app/edit_cell.go | 226 +++++++++++---------------- internal/app/edit_cell_keys.go | 77 ++++++++++ internal/app/query.go | 264 ++++++++++++++------------------ internal/app/query_keys.go | 114 ++++++++++++++ internal/app/row_detail.go | 192 +++++++++++++++-------- internal/app/row_detail_keys.go | 61 ++++++++ internal/app/table_data.go | 164 ++++++++++++++------ internal/app/table_data_keys.go | 87 +++++++++++ internal/app/table_list.go | 157 ++++++++++++++----- internal/app/table_list_keys.go | 82 ++++++++++ 13 files changed, 1080 insertions(+), 443 deletions(-) create mode 100644 internal/app/edit_cell_keys.go create mode 100644 internal/app/query_keys.go create mode 100644 internal/app/row_detail_keys.go create mode 100644 internal/app/table_data_keys.go create mode 100644 internal/app/table_list_keys.go diff --git a/go.mod b/go.mod index db203a9..cdd6e69 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/taigrr/teaqlite go 1.24.5 require ( + github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/fang v0.3.0 github.com/charmbracelet/lipgloss v1.1.0 @@ -11,6 +12,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect diff --git a/go.sum b/go.sum index be1c271..c666a88 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= @@ -20,8 +24,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e h1:s github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e h1:D0tltuLCSvxMznOpQg7f3MArp8ImU0zALbakI47ffkw= github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e/go.mod h1:hk/GyTELmEgX54pBAOHcFvH8Xed53JWo/g8kJXFo/PI= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/app/app.go b/internal/app/app.go index 5c8c8e9..00cd9e5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,8 +5,10 @@ import ( "fmt" "slices" "strings" + "sync/atomic" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/lipgloss" _ "modernc.org/sqlite" // Import SQLite driver ) @@ -15,6 +17,35 @@ const ( PageSize = 20 ) +var lastID int64 + +func nextID() int { + return int(atomic.AddInt64(&lastID, 1)) +} + +// Common message types +type blinkMsg struct{} + +// KeyMap defines the keybindings for the application +type AppKeyMap struct { + Quit key.Binding + Suspend key.Binding +} + +// DefaultAppKeyMap returns the default keybindings +func DefaultAppKeyMap() AppKeyMap { + return AppKeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Suspend: key.NewBinding( + key.WithKeys("ctrl+z"), + key.WithHelp("ctrl+z", "suspend"), + ), + } +} + // Custom message types type ( SwitchToTableListMsg struct{} @@ -45,6 +76,26 @@ type Model struct { width int height int err error + keyMap AppKeyMap + focused bool +} + +// Option is a functional option for configuring the Model +type Option func(*Model) + +// WithKeyMap sets the key map for the application +func WithKeyMap(km AppKeyMap) Option { + return func(m *Model) { + m.keyMap = km + } +} + +// WithDimensions sets the initial dimensions +func WithDimensions(width, height int) Option { + return func(m *Model) { + m.width = width + m.height = height + } } // SharedData that all models need access to @@ -478,18 +529,42 @@ func Max(a, b int) int { return b } -func InitialModel(db *sql.DB) *Model { +func InitialModel(db *sql.DB, opts ...Option) *Model { shared := NewSharedData(db) if err := shared.LoadTables(); err != nil { return &Model{err: err} } - return &Model{ + m := &Model{ db: db, currentView: NewTableListModel(shared), width: 80, height: 24, + keyMap: DefaultAppKeyMap(), + focused: true, } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// Focus sets the focus state of the application +func (m *Model) Focus() { + m.focused = true +} + +// Blur removes focus from the application +func (m *Model) Blur() { + m.focused = false +} + +// Focused returns the focus state +func (m Model) Focused() bool { + return m.focused } func (m *Model) Init() tea.Cmd { @@ -497,6 +572,10 @@ func (m *Model) Init() tea.Cmd { } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -509,10 +588,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Add similar updates for other model types as needed case tea.KeyMsg: - if msg.String() == "ctrl+c" { + switch { + case key.Matches(msg, m.keyMap.Quit): return m, tea.Quit - } - if msg.String() == "ctrl+z" { + case key.Matches(msg, m.keyMap.Suspend): return m, tea.Suspend } diff --git a/internal/app/edit_cell.go b/internal/app/edit_cell.go index c7c5bd2..3f1b85d 100644 --- a/internal/app/edit_cell.go +++ b/internal/app/edit_cell.go @@ -3,21 +3,34 @@ package app import ( "fmt" "time" - "unicode" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" ) type EditCellModel struct { Shared *SharedData rowIndex int colIndex int - value string - cursor int + input textinput.Model blinkState bool + keyMap EditCellKeyMap + help help.Model + focused bool + id int } -type blinkMsg struct{} +// EditCellOption is a functional option for configuring EditCellModel +type EditCellOption func(*EditCellModel) + +// WithEditCellKeyMap sets the key map +func WithEditCellKeyMap(km EditCellKeyMap) EditCellOption { + return func(m *EditCellModel) { + m.keyMap = km + } +} func blinkCmd() tea.Cmd { return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { @@ -25,85 +38,104 @@ func blinkCmd() tea.Cmd { }) } -func NewEditCellModel(shared *SharedData, rowIndex, colIndex int) *EditCellModel { +func NewEditCellModel(shared *SharedData, rowIndex, colIndex int, opts ...EditCellOption) *EditCellModel { value := "" if rowIndex < len(shared.FilteredData) && colIndex < len(shared.FilteredData[rowIndex]) { value = shared.FilteredData[rowIndex][colIndex] } - return &EditCellModel{ + input := textinput.New() + input.SetValue(value) + input.Width = 50 + input.Focus() + + m := &EditCellModel{ Shared: shared, rowIndex: rowIndex, colIndex: colIndex, - value: value, - cursor: len(value), + input: input, blinkState: true, + keyMap: DefaultEditCellKeyMap(), + help: help.New(), + focused: true, + id: nextID(), } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// ID returns the unique ID of the model +func (m EditCellModel) ID() int { + return m.id +} + +// Focus sets the focus state +func (m *EditCellModel) Focus() { + m.focused = true + m.input.Focus() +} + +// Blur removes focus +func (m *EditCellModel) Blur() { + m.focused = false + m.input.Blur() +} + +// Focused returns the focus state +func (m EditCellModel) Focused() bool { + return m.focused } func (m *EditCellModel) Init() tea.Cmd { - return blinkCmd() + return tea.Batch( + textinput.Blink, + blinkCmd(), + ) } func (m *EditCellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + + var cmds []tea.Cmd + switch msg := msg.(type) { case blinkMsg: m.blinkState = !m.blinkState - return m, blinkCmd() - - case tea.KeyMsg: - switch msg.String() { - case "esc": - return m, func() tea.Msg { return SwitchToRowDetailMsg{RowIndex: m.rowIndex} } + cmds = append(cmds, blinkCmd()) - case "enter": + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keyMap.Save): return m, func() tea.Msg { return UpdateCellMsg{ RowIndex: m.rowIndex, ColIndex: m.colIndex, - Value: m.value, + Value: m.input.Value(), } } - case "backspace": - if m.cursor > 0 { - m.value = m.value[:m.cursor-1] + m.value[m.cursor:] - m.cursor-- - } - - case "left": - if m.cursor > 0 { - m.cursor-- - } - - case "right": - if m.cursor < len(m.value) { - m.cursor++ - } - - case "home", "ctrl+a": - m.cursor = 0 - - case "end", "ctrl+e": - m.cursor = len(m.value) - - case "ctrl+left": - m.cursor = m.wordLeft(m.value, m.cursor) - - case "ctrl+right": - m.cursor = m.wordRight(m.value, m.cursor) - - case "ctrl+w": - m.deleteWordLeft() - - default: - if len(msg.String()) == 1 { - m.value = m.value[:m.cursor] + msg.String() + m.value[m.cursor:] - m.cursor++ + case key.Matches(msg, m.keyMap.Cancel): + return m, func() tea.Msg { + return SwitchToRowDetailMsg{RowIndex: m.rowIndex} } } } - return m, nil + + // Update the input for all other messages + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) } func (m *EditCellModel) View() string { @@ -112,89 +144,9 @@ func (m *EditCellModel) View() string { columnName = m.Shared.Columns[m.colIndex] } - content := TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName)) + "\n\n" - - // Display value with properly positioned cursor like bubbles textinput - content += "Value: " - value := m.value - pos := m.cursor - - // Text before cursor - if pos > 0 { - content += value[:pos] - } - - // Cursor and character at cursor position - if pos < len(value) { - // Cursor over existing character - char := string(value[pos]) - if m.blinkState { - content += SelectedStyle.Render(char) // Highlight the character - } else { - content += char - } - // Text after cursor - if pos+1 < len(value) { - content += value[pos+1:] - } - } else { - // Cursor at end of text - if m.blinkState { - content += "|" - } - } - - content += "\n\n" - content += HelpStyle.Render("enter: save • esc: cancel • ctrl+w: delete word • ctrl+arrows: word nav") + content := fmt.Sprintf("%s\n\n", TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName))) + content += fmt.Sprintf("Value: %s\n\n", m.input.View()) + content += m.help.View(m.keyMap) return content -} - -// wordLeft finds the position of the start of the word to the left of the cursor -func (m *EditCellModel) wordLeft(text string, pos int) int { - if pos == 0 { - return 0 - } - - // Move left past any whitespace - for pos > 0 && unicode.IsSpace(rune(text[pos-1])) { - pos-- - } - - // Move left past the current word - for pos > 0 && !unicode.IsSpace(rune(text[pos-1])) { - pos-- - } - - return pos -} - -// wordRight finds the position of the start of the word to the right of the cursor -func (m *EditCellModel) wordRight(text string, pos int) int { - if pos >= len(text) { - return len(text) - } - - // Move right past the current word - for pos < len(text) && !unicode.IsSpace(rune(text[pos])) { - pos++ - } - - // Move right past any whitespace - for pos < len(text) && unicode.IsSpace(rune(text[pos])) { - pos++ - } - - return pos -} - -// deleteWordLeft deletes the word to the left of the cursor -func (m *EditCellModel) deleteWordLeft() { - if m.cursor == 0 { - return - } - - newPos := m.wordLeft(m.value, m.cursor) - m.value = m.value[:newPos] + m.value[m.cursor:] - m.cursor = newPos -} +} \ No newline at end of file diff --git a/internal/app/edit_cell_keys.go b/internal/app/edit_cell_keys.go new file mode 100644 index 0000000..969ea7e --- /dev/null +++ b/internal/app/edit_cell_keys.go @@ -0,0 +1,77 @@ +package app + +import "github.com/charmbracelet/bubbles/key" + +// EditCellKeyMap defines keybindings for the edit cell view +type EditCellKeyMap struct { + Save key.Binding + Cancel key.Binding + CursorLeft key.Binding + CursorRight key.Binding + WordLeft key.Binding + WordRight key.Binding + LineStart key.Binding + LineEnd key.Binding + DeleteWord key.Binding + DeleteChar key.Binding +} + +// DefaultEditCellKeyMap returns the default keybindings for edit cell +func DefaultEditCellKeyMap() EditCellKeyMap { + return EditCellKeyMap{ + Save: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "save"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + CursorLeft: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "cursor left"), + ), + CursorRight: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "cursor right"), + ), + WordLeft: key.NewBinding( + key.WithKeys("ctrl+left"), + key.WithHelp("ctrl+←", "word left"), + ), + WordRight: key.NewBinding( + key.WithKeys("ctrl+right"), + key.WithHelp("ctrl+→", "word right"), + ), + LineStart: key.NewBinding( + key.WithKeys("home", "ctrl+a"), + key.WithHelp("home/ctrl+a", "line start"), + ), + LineEnd: key.NewBinding( + key.WithKeys("end", "ctrl+e"), + key.WithHelp("end/ctrl+e", "line end"), + ), + DeleteWord: key.NewBinding( + key.WithKeys("ctrl+w"), + key.WithHelp("ctrl+w", "delete word"), + ), + DeleteChar: key.NewBinding( + key.WithKeys("backspace"), + key.WithHelp("backspace", "delete char"), + ), + } +} + +// ShortHelp returns keybindings to be shown in the mini help view +func (k EditCellKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Save, k.Cancel} +} + +// FullHelp returns keybindings for the expanded help view +func (k EditCellKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Save, k.Cancel}, + {k.CursorLeft, k.CursorRight, k.WordLeft, k.WordRight}, + {k.LineStart, k.LineEnd, k.DeleteWord, k.DeleteChar}, + } +} \ No newline at end of file diff --git a/internal/app/query.go b/internal/app/query.go index 2f64bff..22cfc58 100644 --- a/internal/app/query.go +++ b/internal/app/query.go @@ -4,15 +4,16 @@ import ( "fmt" "strings" "time" - "unicode" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" ) type QueryModel struct { Shared *SharedData - query string - cursor int + queryInput textarea.Model FocusOnInput bool selectedRow int results [][]string @@ -20,30 +21,95 @@ type QueryModel struct { err error blinkState bool gPressed bool + keyMap QueryKeyMap + help help.Model + focused bool + id int } -func NewQueryModel(shared *SharedData) *QueryModel { - return &QueryModel{ - Shared: shared, - FocusOnInput: true, - selectedRow: 0, - blinkState: true, +// QueryOption is a functional option for configuring QueryModel +type QueryOption func(*QueryModel) + +// WithQueryKeyMap sets the key map +func WithQueryKeyMap(km QueryKeyMap) QueryOption { + return func(m *QueryModel) { + m.keyMap = km } } +func NewQueryModel(shared *SharedData, opts ...QueryOption) *QueryModel { + queryInput := textarea.New() + queryInput.Placeholder = "Enter SQL query..." + queryInput.SetWidth(60) + queryInput.SetHeight(3) + queryInput.Focus() + + m := &QueryModel{ + Shared: shared, + queryInput: queryInput, + FocusOnInput: true, + selectedRow: 0, + blinkState: true, + keyMap: DefaultQueryKeyMap(), + help: help.New(), + focused: true, + id: nextID(), + } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// ID returns the unique ID of the model +func (m QueryModel) ID() int { + return m.id +} + +// Focus sets the focus state +func (m *QueryModel) Focus() { + m.focused = true + if m.FocusOnInput { + m.queryInput.Focus() + } +} + +// Blur removes focus +func (m *QueryModel) Blur() { + m.focused = false + m.queryInput.Blur() +} + +// Focused returns the focus state +func (m QueryModel) Focused() bool { + return m.focused +} + func (m *QueryModel) Init() tea.Cmd { - return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { - return blinkMsg{} - }) + return tea.Batch( + textarea.Blink, + tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { + return blinkMsg{} + }), + ) } func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + + var cmds []tea.Cmd + switch msg := msg.(type) { case blinkMsg: m.blinkState = !m.blinkState - return m, tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { + cmds = append(cmds, tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { return blinkMsg{} - }) + })) case tea.KeyMsg: if m.FocusOnInput { @@ -51,66 +117,44 @@ func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.handleResultsNavigation(msg) } - return m, nil + + // Update query input for non-key messages when focused on input + if m.FocusOnInput { + var cmd tea.Cmd + m.queryInput, cmd = m.queryInput.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) } func (m *QueryModel) handleQueryInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": + switch { + case key.Matches(msg, m.keyMap.Escape): return m, func() tea.Msg { return SwitchToTableListClearMsg{} } - case "enter": - if strings.TrimSpace(m.query) != "" { + case key.Matches(msg, m.keyMap.Execute): + if strings.TrimSpace(m.queryInput.Value()) != "" { return m, m.executeQuery() } - case "backspace": - if m.cursor > 0 { - m.query = m.query[:m.cursor-1] + m.query[m.cursor:] - m.cursor-- - } - - case "left": - if m.cursor > 0 { - m.cursor-- - } - - case "right": - if m.cursor < len(m.query) { - m.cursor++ - } - - case "home", "ctrl+a": - m.cursor = 0 - - case "end", "ctrl+e": - m.cursor = len(m.query) - - case "ctrl+left": - m.cursor = m.wordLeft(m.query, m.cursor) - - case "ctrl+right": - m.cursor = m.wordRight(m.query, m.cursor) - - case "ctrl+w": - m.deleteWordLeft() - default: - if len(msg.String()) == 1 { - m.query = m.query[:m.cursor] + msg.String() + m.query[m.cursor:] - m.cursor++ - } + var cmd tea.Cmd + m.queryInput, cmd = m.queryInput.Update(msg) + return m, cmd } return m, nil } func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc", "q": + switch { + case key.Matches(msg, m.keyMap.Escape), key.Matches(msg, m.keyMap.Back): m.gPressed = false return m, func() tea.Msg { return SwitchToTableListClearMsg{} } - case "g": + case key.Matches(msg, m.keyMap.GoToStart): if m.gPressed { // Second g - go to beginning m.selectedRow = 0 @@ -121,7 +165,7 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd } return m, nil - case "G": + case key.Matches(msg, m.keyMap.GoToEnd): // Go to end if len(m.results) > 0 { m.selectedRow = len(m.results) - 1 @@ -129,12 +173,13 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd m.gPressed = false return m, nil - case "i": + case key.Matches(msg, m.keyMap.EditQuery): m.gPressed = false m.FocusOnInput = true + m.queryInput.Focus() return m, nil - case "enter": + case key.Matches(msg, m.keyMap.Enter): m.gPressed = false if len(m.results) > 0 { return m, func() tea.Msg { @@ -142,13 +187,13 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd } } - case "up", "k": + case key.Matches(msg, m.keyMap.Up): m.gPressed = false if m.selectedRow > 0 { m.selectedRow-- } - case "down", "j": + case key.Matches(msg, m.keyMap.Down): m.gPressed = false if m.selectedRow < len(m.results)-1 { m.selectedRow++ @@ -273,7 +318,7 @@ func (m *QueryModel) getTablePrimaryKeys(tableName string) []string { func (m *QueryModel) executeQuery() tea.Cmd { return func() tea.Msg { // Modify query to always include ID columns if it's a SELECT statement - modifiedQuery := m.ensureIDColumns(m.query) + modifiedQuery := m.ensureIDColumns(m.queryInput.Value()) rows, err := m.Shared.DB.Query(modifiedQuery) if err != nil { @@ -334,59 +379,11 @@ func (m *QueryModel) handleQueryCompletion(msg QueryCompletedMsg) { m.Shared.IsQueryResult = true m.FocusOnInput = false + m.queryInput.Blur() m.selectedRow = 0 m.err = nil } -// wordLeft finds the position of the start of the word to the left of the cursor -func (m *QueryModel) wordLeft(text string, pos int) int { - if pos == 0 { - return 0 - } - - // Move left past any whitespace - for pos > 0 && unicode.IsSpace(rune(text[pos-1])) { - pos-- - } - - // Move left past the current word - for pos > 0 && !unicode.IsSpace(rune(text[pos-1])) { - pos-- - } - - return pos -} - -// wordRight finds the position of the start of the word to the right of the cursor -func (m *QueryModel) wordRight(text string, pos int) int { - if pos >= len(text) { - return len(text) - } - - // Move right past the current word - for pos < len(text) && !unicode.IsSpace(rune(text[pos])) { - pos++ - } - - // Move right past any whitespace - for pos < len(text) && unicode.IsSpace(rune(text[pos])) { - pos++ - } - - return pos -} - -// deleteWordLeft deletes the word to the left of the cursor -func (m *QueryModel) deleteWordLeft() { - if m.cursor == 0 { - return - } - - newPos := m.wordLeft(m.query, m.cursor) - m.query = m.query[:newPos] + m.query[m.cursor:] - m.cursor = newPos -} - func (m *QueryModel) View() string { var content strings.Builder @@ -394,41 +391,8 @@ func (m *QueryModel) View() string { content.WriteString("\n\n") // Query input - content.WriteString("Query: ") - if m.FocusOnInput { - // Display query with properly positioned cursor like bubbles textinput - query := m.query - pos := m.cursor - - // Text before cursor - before := "" - if pos > 0 { - before = query[:pos] - } - content.WriteString(before) - - // Cursor and character at cursor position - if pos < len(query) { - // Cursor over existing character - char := string(query[pos]) - if m.blinkState { - content.WriteString(SelectedStyle.Render(char)) // Highlight the character - } else { - content.WriteString(char) - } - // Text after cursor - if pos+1 < len(query) { - content.WriteString(query[pos+1:]) - } - } else { - // Cursor at end of text - if m.blinkState { - content.WriteString("|") - } - } - } else { - content.WriteString(m.query) - } + content.WriteString("Query:\n") + content.WriteString(m.queryInput.View()) content.WriteString("\n\n") // Error display @@ -487,10 +451,10 @@ func (m *QueryModel) View() string { content.WriteString("\n") if m.FocusOnInput { - content.WriteString(HelpStyle.Render("enter: execute • esc: back • ctrl+w: delete word • ctrl+arrows: word nav")) + content.WriteString(HelpStyle.Render("enter: execute • esc: back")) } else { - content.WriteString(HelpStyle.Render("↑/↓: navigate • enter: details • i: edit query • gg/G: first/last • q: back")) + content.WriteString(m.help.View(m.keyMap)) } return content.String() -} +} \ No newline at end of file diff --git a/internal/app/query_keys.go b/internal/app/query_keys.go new file mode 100644 index 0000000..b161600 --- /dev/null +++ b/internal/app/query_keys.go @@ -0,0 +1,114 @@ +package app + +import "github.com/charmbracelet/bubbles/key" + +// QueryKeyMap defines keybindings for the query view +type QueryKeyMap struct { + // Input mode keys + Execute key.Binding + Escape key.Binding + CursorLeft key.Binding + CursorRight key.Binding + WordLeft key.Binding + WordRight key.Binding + LineStart key.Binding + LineEnd key.Binding + DeleteWord key.Binding + + // Results mode keys + Up key.Binding + Down key.Binding + Enter key.Binding + EditQuery key.Binding + GoToStart key.Binding + GoToEnd key.Binding + Back key.Binding +} + +// DefaultQueryKeyMap returns the default keybindings for query view +func DefaultQueryKeyMap() QueryKeyMap { + return QueryKeyMap{ + // Input mode + Execute: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "execute query"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + CursorLeft: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "cursor left"), + ), + CursorRight: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "cursor right"), + ), + WordLeft: key.NewBinding( + key.WithKeys("ctrl+left"), + key.WithHelp("ctrl+←", "word left"), + ), + WordRight: key.NewBinding( + key.WithKeys("ctrl+right"), + key.WithHelp("ctrl+→", "word right"), + ), + LineStart: key.NewBinding( + key.WithKeys("home", "ctrl+a"), + key.WithHelp("home/ctrl+a", "line start"), + ), + LineEnd: key.NewBinding( + key.WithKeys("end", "ctrl+e"), + key.WithHelp("end/ctrl+e", "line end"), + ), + DeleteWord: key.NewBinding( + key.WithKeys("ctrl+w"), + key.WithHelp("ctrl+w", "delete word"), + ), + + // Results mode + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "view details"), + ), + EditQuery: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "edit query"), + ), + GoToStart: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("gg", "go to start"), + ), + GoToEnd: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "go to end"), + ), + Back: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "back"), + ), + } +} + +// ShortHelp returns keybindings to be shown in the mini help view +func (k QueryKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Execute, k.Up, k.Down, k.Enter} +} + +// FullHelp returns keybindings for the expanded help view +func (k QueryKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Execute, k.Escape, k.EditQuery, k.Back}, + {k.Up, k.Down, k.Enter, k.GoToStart, k.GoToEnd}, + {k.CursorLeft, k.CursorRight, k.WordLeft, k.WordRight}, + {k.LineStart, k.LineEnd, k.DeleteWord}, + } +} \ No newline at end of file diff --git a/internal/app/row_detail.go b/internal/app/row_detail.go index 273cf2e..ebe94d1 100644 --- a/internal/app/row_detail.go +++ b/internal/app/row_detail.go @@ -5,6 +5,8 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" ) type RowDetailModel struct { @@ -13,14 +15,60 @@ type RowDetailModel struct { selectedCol int FromQuery bool gPressed bool + keyMap RowDetailKeyMap + help help.Model + focused bool + id int } -func NewRowDetailModel(shared *SharedData, rowIndex int) *RowDetailModel { - return &RowDetailModel{ +// RowDetailOption is a functional option for configuring RowDetailModel +type RowDetailOption func(*RowDetailModel) + +// WithRowDetailKeyMap sets the key map +func WithRowDetailKeyMap(km RowDetailKeyMap) RowDetailOption { + return func(m *RowDetailModel) { + m.keyMap = km + } +} + +func NewRowDetailModel(shared *SharedData, rowIndex int, opts ...RowDetailOption) *RowDetailModel { + m := &RowDetailModel{ Shared: shared, rowIndex: rowIndex, selectedCol: 0, + FromQuery: false, + keyMap: DefaultRowDetailKeyMap(), + help: help.New(), + focused: true, + id: nextID(), } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// ID returns the unique ID of the model +func (m RowDetailModel) ID() int { + return m.id +} + +// Focus sets the focus state +func (m *RowDetailModel) Focus() { + m.focused = true +} + +// Blur removes focus +func (m *RowDetailModel) Blur() { + m.focused = false +} + +// Focused returns the focus state +func (m RowDetailModel) Focused() bool { + return m.focused } func (m *RowDetailModel) Init() tea.Cmd { @@ -28,59 +76,66 @@ func (m *RowDetailModel) Init() tea.Cmd { } func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "q", "esc": - m.gPressed = false - if m.FromQuery { - return m, func() tea.Msg { return ReturnToQueryMsg{} } - } - return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.Shared.SelectedTable} } + return m.handleNavigation(msg) + } + return m, nil +} - case "g": - if m.gPressed { - // Second g - go to beginning - m.selectedCol = 0 - m.gPressed = false - } else { - // First g - wait for second g - m.gPressed = true - } - return m, nil - - case "G": - // Go to end - if len(m.Shared.Columns) > 0 { - m.selectedCol = len(m.Shared.Columns) - 1 - } - m.gPressed = false - return m, nil - - case "e": - m.gPressed = false - if len(m.Shared.FilteredData) > m.rowIndex && len(m.Shared.Columns) > m.selectedCol { - return m, func() tea.Msg { - return SwitchToEditCellMsg{RowIndex: m.rowIndex, ColIndex: m.selectedCol} - } - } - - case "up", "k": - m.gPressed = false - if m.selectedCol > 0 { - m.selectedCol-- - } - - case "down", "j": - m.gPressed = false - if m.selectedCol < len(m.Shared.Columns)-1 { - m.selectedCol++ - } - - default: - // Any other key resets the g state - m.gPressed = false +func (m *RowDetailModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.Escape), key.Matches(msg, m.keyMap.Back): + m.gPressed = false + if m.FromQuery { + return m, func() tea.Msg { return ReturnToQueryMsg{} } } + return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.Shared.SelectedTable} } + + case key.Matches(msg, m.keyMap.GoToStart): + if m.gPressed { + // Second g - go to beginning + m.selectedCol = 0 + m.gPressed = false + } else { + // First g - wait for second g + m.gPressed = true + } + return m, nil + + case key.Matches(msg, m.keyMap.GoToEnd): + // Go to end + if len(m.Shared.Columns) > 0 { + m.selectedCol = len(m.Shared.Columns) - 1 + } + m.gPressed = false + return m, nil + + case key.Matches(msg, m.keyMap.Enter): + m.gPressed = false + return m, func() tea.Msg { + return SwitchToEditCellMsg{RowIndex: m.rowIndex, ColIndex: m.selectedCol} + } + + case key.Matches(msg, m.keyMap.Up): + m.gPressed = false + if m.selectedCol > 0 { + m.selectedCol-- + } + + case key.Matches(msg, m.keyMap.Down): + m.gPressed = false + if m.selectedCol < len(m.Shared.Columns)-1 { + m.selectedCol++ + } + + default: + // Any other key resets the g state + m.gPressed = false } return m, nil } @@ -92,27 +147,36 @@ func (m *RowDetailModel) View() string { content.WriteString("\n\n") if m.rowIndex >= len(m.Shared.FilteredData) { - content.WriteString("Invalid row index") + content.WriteString("Row not found") return content.String() } - // Show current row position - content.WriteString(fmt.Sprintf("Row %d of %d\n\n", m.rowIndex+1, len(m.Shared.FilteredData))) - row := m.Shared.FilteredData[m.rowIndex] + + // Show each column and its value for i, col := range m.Shared.Columns { - if i < len(row) { - if i == m.selectedCol { - content.WriteString(SelectedStyle.Render(fmt.Sprintf("> %s: %s", col, row[i]))) - } else { - content.WriteString(NormalStyle.Render(fmt.Sprintf(" %s: %s", col, row[i]))) - } - content.WriteString("\n") + if i >= len(row) { + break } + + value := row[i] + if len(value) > 50 { + // Wrap long values + lines := WrapText(value, 50) + value = strings.Join(lines, "\n ") + } + + line := fmt.Sprintf("%s: %s", col, value) + if i == m.selectedCol { + content.WriteString(SelectedStyle.Render("> " + line)) + } else { + content.WriteString(NormalStyle.Render(" " + line)) + } + content.WriteString("\n") } content.WriteString("\n") - content.WriteString(HelpStyle.Render("↑/↓: navigate columns • e: edit • gg/G: first/last • q: back")) + content.WriteString(m.help.View(m.keyMap)) return content.String() -} +} \ No newline at end of file diff --git a/internal/app/row_detail_keys.go b/internal/app/row_detail_keys.go new file mode 100644 index 0000000..674caeb --- /dev/null +++ b/internal/app/row_detail_keys.go @@ -0,0 +1,61 @@ +package app + +import "github.com/charmbracelet/bubbles/key" + +// RowDetailKeyMap defines keybindings for the row detail view +type RowDetailKeyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding + Back key.Binding + GoToStart key.Binding + GoToEnd key.Binding +} + +// DefaultRowDetailKeyMap returns the default keybindings for row detail +func DefaultRowDetailKeyMap() RowDetailKeyMap { + return RowDetailKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit cell"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Back: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "back"), + ), + GoToStart: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("gg", "go to start"), + ), + GoToEnd: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "go to end"), + ), + } +} + +// ShortHelp returns keybindings to be shown in the mini help view +func (k RowDetailKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Enter, k.Back} +} + +// FullHelp returns keybindings for the expanded help view +func (k RowDetailKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Enter}, + {k.Escape, k.Back, k.GoToStart, k.GoToEnd}, + } +} \ No newline at end of file diff --git a/internal/app/table_data.go b/internal/app/table_data.go index ad66678..7019aa6 100644 --- a/internal/app/table_data.go +++ b/internal/app/table_data.go @@ -6,28 +6,92 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" ) type TableDataModel struct { Shared *SharedData - selectedRow int - searchInput string + searchInput textinput.Model searching bool + selectedRow int gPressed bool + keyMap TableDataKeyMap + help help.Model + focused bool + id int } -func NewTableDataModel(shared *SharedData) *TableDataModel { - return &TableDataModel{ - Shared: shared, - selectedRow: 0, +// TableDataOption is a functional option for configuring TableDataModel +type TableDataOption func(*TableDataModel) + +// WithTableDataKeyMap sets the key map +func WithTableDataKeyMap(km TableDataKeyMap) TableDataOption { + return func(m *TableDataModel) { + m.keyMap = km } } +func NewTableDataModel(shared *SharedData, opts ...TableDataOption) *TableDataModel { + searchInput := textinput.New() + searchInput.Placeholder = "Search rows..." + searchInput.CharLimit = 50 + searchInput.Width = 30 + + m := &TableDataModel{ + Shared: shared, + searchInput: searchInput, + selectedRow: 0, + keyMap: DefaultTableDataKeyMap(), + help: help.New(), + focused: true, + id: nextID(), + } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// ID returns the unique ID of the model +func (m TableDataModel) ID() int { + return m.id +} + +// Focus sets the focus state +func (m *TableDataModel) Focus() { + m.focused = true + if m.searching { + m.searchInput.Focus() + } +} + +// Blur removes focus +func (m *TableDataModel) Blur() { + m.focused = false + m.searchInput.Blur() +} + +// Focused returns the focus state +func (m TableDataModel) Focused() bool { + return m.focused +} + func (m *TableDataModel) Init() tea.Cmd { return nil } func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + + var cmds []tea.Cmd + switch msg := msg.(type) { case tea.KeyMsg: if m.searching { @@ -35,45 +99,57 @@ func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.handleNavigation(msg) } - return m, nil + + // Update search input for non-key messages when searching + if m.searching { + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + // Update filter when search input changes + m.filterData() + } + + return m, tea.Batch(cmds...) } func (m *TableDataModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc", "enter": + switch { + case key.Matches(msg, m.keyMap.Escape): m.searching = false + m.searchInput.Blur() + m.filterData() + case key.Matches(msg, m.keyMap.Enter): + m.searching = false + m.searchInput.Blur() m.filterData() - case "backspace": - if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] - m.filterData() - } default: - if len(msg.String()) == 1 { - m.searchInput += msg.String() - m.filterData() - } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.filterData() + return m, cmd } return m, nil } func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q": + switch { + case key.Matches(msg, m.keyMap.Back): m.gPressed = false return m, func() tea.Msg { return SwitchToTableListClearMsg{} } - case "esc": + case key.Matches(msg, m.keyMap.Escape): m.gPressed = false - if m.searchInput != "" { + if m.searchInput.Value() != "" { // Clear search filter - m.searchInput = "" + m.searchInput.SetValue("") m.filterData() return m, nil } return m, func() tea.Msg { return SwitchToTableListClearMsg{} } - case "g": + case key.Matches(msg, m.keyMap.GoToStart): if m.gPressed { // Second g - go to absolute beginning m.Shared.CurrentPage = 0 @@ -87,7 +163,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil - case "G": + case key.Matches(msg, m.keyMap.GoToEnd): // Go to absolute end maxPage := (m.Shared.TotalRows - 1) / PageSize m.Shared.CurrentPage = maxPage @@ -97,7 +173,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.gPressed = false return m, nil - case "enter": + case key.Matches(msg, m.keyMap.Enter): m.gPressed = false if len(m.Shared.FilteredData) > 0 { return m, func() tea.Msg { @@ -105,23 +181,24 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } - case "/": + case key.Matches(msg, m.keyMap.Search): m.gPressed = false m.searching = true - m.searchInput = "" + m.searchInput.SetValue("") + m.searchInput.Focus() return m, nil - case "s": + case key.Matches(msg, m.keyMap.SQLMode): m.gPressed = false return m, func() tea.Msg { return SwitchToQueryMsg{} } - case "r": + case key.Matches(msg, m.keyMap.Refresh): m.gPressed = false if err := m.Shared.LoadTableData(); err == nil { m.filterData() } - case "up", "k": + case key.Matches(msg, m.keyMap.Up): m.gPressed = false if m.selectedRow > 0 { m.selectedRow-- @@ -133,7 +210,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.selectedRow = len(m.Shared.FilteredData) - 1 // Go to last row of previous page } - case "down", "j": + case key.Matches(msg, m.keyMap.Down): m.gPressed = false if m.selectedRow < len(m.Shared.FilteredData)-1 { m.selectedRow++ @@ -148,7 +225,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } - case "left", "h": + case key.Matches(msg, m.keyMap.Left): m.gPressed = false if m.Shared.CurrentPage > 0 { m.Shared.CurrentPage-- @@ -156,7 +233,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.selectedRow = 0 } - case "right", "l": + case key.Matches(msg, m.keyMap.Right): m.gPressed = false maxPage := (m.Shared.TotalRows - 1) / PageSize if m.Shared.CurrentPage < maxPage { @@ -173,7 +250,8 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *TableDataModel) filterData() { - if m.searchInput == "" { + searchValue := m.searchInput.Value() + if searchValue == "" { m.Shared.FilteredData = make([][]string, len(m.Shared.TableData)) copy(m.Shared.FilteredData, m.Shared.TableData) } else { @@ -184,7 +262,7 @@ func (m *TableDataModel) filterData() { } var matches []rowMatch - searchLower := strings.ToLower(m.searchInput) + searchLower := strings.ToLower(searchValue) for _, row := range m.Shared.TableData { bestScore := 0 @@ -298,11 +376,11 @@ func (m *TableDataModel) View() string { content.WriteString("\n") if m.searching { - content.WriteString("\nSearch: " + m.searchInput + "_") + content.WriteString("\nSearch: " + m.searchInput.View()) content.WriteString("\n") - } else if m.searchInput != "" { + } else if m.searchInput.Value() != "" { content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d rows)", - m.searchInput, len(m.Shared.FilteredData), len(m.Shared.TableData))) + m.searchInput.Value(), len(m.Shared.FilteredData), len(m.Shared.TableData))) content.WriteString("\n") } @@ -334,9 +412,7 @@ func (m *TableDataModel) View() string { if totalRows > visibleCount && m.selectedRow >= visibleCount { startIdx = m.selectedRow - visibleCount + 1 // Ensure we don't scroll past the end - if startIdx > totalRows-visibleCount { - startIdx = totalRows - visibleCount - } + startIdx = min(startIdx, totalRows-visibleCount) } endIdx := Min(totalRows, startIdx+visibleCount) @@ -364,8 +440,8 @@ func (m *TableDataModel) View() string { if m.searching { content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search")) } else { - content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: details • s: SQL • r: refresh • gg/G: first/last • q: back")) + content.WriteString(m.help.View(m.keyMap)) } return content.String() -} +} \ No newline at end of file diff --git a/internal/app/table_data_keys.go b/internal/app/table_data_keys.go new file mode 100644 index 0000000..c6487d9 --- /dev/null +++ b/internal/app/table_data_keys.go @@ -0,0 +1,87 @@ +package app + +import "github.com/charmbracelet/bubbles/key" + +// TableDataKeyMap defines keybindings for the table data view +type TableDataKeyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Enter key.Binding + Search key.Binding + Escape key.Binding + Back key.Binding + GoToStart key.Binding + GoToEnd key.Binding + Refresh key.Binding + SQLMode key.Binding +} + +// DefaultTableDataKeyMap returns the default keybindings for table data +func DefaultTableDataKeyMap() TableDataKeyMap { + return TableDataKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "prev page"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "next page"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "view details"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back/clear"), + ), + Back: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "back to tables"), + ), + GoToStart: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("gg", "go to start"), + ), + GoToEnd: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "go to end"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + SQLMode: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "SQL mode"), + ), + } +} + +// ShortHelp returns keybindings to be shown in the mini help view +func (k TableDataKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Enter, k.Search} +} + +// FullHelp returns keybindings for the expanded help view +func (k TableDataKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.Enter, k.Search, k.Escape, k.Back}, + {k.GoToStart, k.GoToEnd, k.Refresh, k.SQLMode}, + } +} \ No newline at end of file diff --git a/internal/app/table_list.go b/internal/app/table_list.go index 5bb3abb..129d505 100644 --- a/internal/app/table_list.go +++ b/internal/app/table_list.go @@ -6,23 +6,81 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" ) type TableListModel struct { Shared *SharedData - searchInput string + searchInput textinput.Model searching bool selectedTable int currentPage int gPressed bool + keyMap TableListKeyMap + help help.Model + focused bool + id int } -func NewTableListModel(shared *SharedData) *TableListModel { - return &TableListModel{ +// TableListOption is a functional option for configuring TableListModel +type TableListOption func(*TableListModel) + +// WithTableListKeyMap sets the key map +func WithTableListKeyMap(km TableListKeyMap) TableListOption { + return func(m *TableListModel) { + m.keyMap = km + } +} + +func NewTableListModel(shared *SharedData, opts ...TableListOption) *TableListModel { + searchInput := textinput.New() + searchInput.Placeholder = "Search tables..." + searchInput.CharLimit = 50 + searchInput.Width = 30 + + m := &TableListModel{ Shared: shared, + searchInput: searchInput, selectedTable: 0, currentPage: 0, + keyMap: DefaultTableListKeyMap(), + help: help.New(), + focused: true, + id: nextID(), } + + // Apply options + for _, opt := range opts { + opt(m) + } + + return m +} + +// ID returns the unique ID of the model +func (m TableListModel) ID() int { + return m.id +} + +// Focus sets the focus state +func (m *TableListModel) Focus() { + m.focused = true + if m.searching { + m.searchInput.Focus() + } +} + +// Blur removes focus +func (m *TableListModel) Blur() { + m.focused = false + m.searchInput.Blur() +} + +// Focused returns the focus state +func (m TableListModel) Focused() bool { + return m.focused } func (m *TableListModel) Init() tea.Cmd { @@ -30,6 +88,12 @@ func (m *TableListModel) Init() tea.Cmd { } func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + + var cmds []tea.Cmd + switch msg := msg.(type) { case tea.KeyMsg: if m.searching { @@ -37,41 +101,50 @@ func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.handleNavigation(msg) } - return m, nil + + // Update search input for non-key messages when searching + if m.searching { + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + // Update filter when search input changes + m.filterTables() + } + + return m, tea.Batch(cmds...) } func (m *TableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": + switch { + case key.Matches(msg, m.keyMap.Escape): m.searching = false + m.searchInput.Blur() // If there's an existing filter, clear it - if m.searchInput != "" { - m.searchInput = "" + if m.searchInput.Value() != "" { + m.searchInput.SetValue("") m.filterTables() } - case "enter": + case key.Matches(msg, m.keyMap.Enter): m.searching = false + m.searchInput.Blur() m.filterTables() - case "backspace": - if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] - m.filterTables() - } default: - if len(msg.String()) == 1 { - m.searchInput += msg.String() - m.filterTables() - } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.filterTables() + return m, cmd } return m, nil } func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": - if m.searchInput != "" { + switch { + case key.Matches(msg, m.keyMap.Escape): + if m.searchInput.Value() != "" { // Clear search filter - m.searchInput = "" + m.searchInput.SetValue("") m.filterTables() m.gPressed = false return m, nil @@ -80,13 +153,14 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.gPressed = false return m, nil - case "/": + case key.Matches(msg, m.keyMap.Search): m.searching = true - m.searchInput = "" + m.searchInput.SetValue("") + m.searchInput.Focus() m.gPressed = false return m, nil - case "g": + case key.Matches(msg, m.keyMap.GoToStart): if m.gPressed { // Second g - go to beginning m.selectedTable = 0 @@ -98,7 +172,7 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil - case "G": + case key.Matches(msg, m.keyMap.GoToEnd): // Go to end if len(m.Shared.FilteredTables) > 0 { m.selectedTable = len(m.Shared.FilteredTables) - 1 @@ -107,7 +181,7 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.gPressed = false return m, nil - case "enter": + case key.Matches(msg, m.keyMap.Enter): m.gPressed = false if len(m.Shared.FilteredTables) > 0 { return m, func() tea.Msg { @@ -115,38 +189,38 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } - case "s": + case key.Matches(msg, m.keyMap.SQLMode): m.gPressed = false return m, func() tea.Msg { return SwitchToQueryMsg{} } - case "r": + case key.Matches(msg, m.keyMap.Refresh): m.gPressed = false if err := m.Shared.LoadTables(); err == nil { m.filterTables() } - case "up", "k": + case key.Matches(msg, m.keyMap.Up): m.gPressed = false if m.selectedTable > 0 { m.selectedTable-- m.adjustPage() } - case "down", "j": + case key.Matches(msg, m.keyMap.Down): m.gPressed = false if m.selectedTable < len(m.Shared.FilteredTables)-1 { m.selectedTable++ m.adjustPage() } - case "left", "h": + case key.Matches(msg, m.keyMap.Left): m.gPressed = false if m.currentPage > 0 { m.currentPage-- m.selectedTable = m.currentPage * m.getVisibleCount() } - case "right", "l": + case key.Matches(msg, m.keyMap.Right): m.gPressed = false maxPage := (len(m.Shared.FilteredTables) - 1) / m.getVisibleCount() if m.currentPage < maxPage { @@ -165,7 +239,8 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *TableListModel) filterTables() { - if m.searchInput == "" { + searchValue := m.searchInput.Value() + if searchValue == "" { m.Shared.FilteredTables = make([]string, len(m.Shared.Tables)) copy(m.Shared.FilteredTables, m.Shared.Tables) } else { @@ -176,7 +251,7 @@ func (m *TableListModel) filterTables() { } var matches []tableMatch - searchLower := strings.ToLower(m.searchInput) + searchLower := strings.ToLower(searchValue) for _, table := range m.Shared.Tables { score := m.fuzzyScore(strings.ToLower(table), searchLower) @@ -291,17 +366,17 @@ func (m *TableListModel) View() string { content.WriteString("\n") if m.searching { - content.WriteString("\nSearch: " + m.searchInput + "_") + content.WriteString("\nSearch: " + m.searchInput.View()) content.WriteString("\n") - } else if m.searchInput != "" { + } else if m.searchInput.Value() != "" { content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d tables)", - m.searchInput, len(m.Shared.FilteredTables), len(m.Shared.Tables))) + m.searchInput.Value(), len(m.Shared.FilteredTables), len(m.Shared.Tables))) content.WriteString("\n") } content.WriteString("\n") if len(m.Shared.FilteredTables) == 0 { - if m.searchInput != "" { + if m.searchInput.Value() != "" { content.WriteString("No tables match your search") } else { content.WriteString("No tables found in database") @@ -331,8 +406,8 @@ func (m *TableListModel) View() string { if m.searching { content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search")) } else { - content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: view • s: SQL • r: refresh • gg/G: first/last • ctrl+c: quit")) + content.WriteString(m.help.View(m.keyMap)) } return content.String() -} +} \ No newline at end of file diff --git a/internal/app/table_list_keys.go b/internal/app/table_list_keys.go new file mode 100644 index 0000000..83cfd9b --- /dev/null +++ b/internal/app/table_list_keys.go @@ -0,0 +1,82 @@ +package app + +import "github.com/charmbracelet/bubbles/key" + +// TableListKeyMap defines keybindings for the table list view +type TableListKeyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Enter key.Binding + Search key.Binding + Escape key.Binding + GoToStart key.Binding + GoToEnd key.Binding + Refresh key.Binding + SQLMode key.Binding +} + +// DefaultTableListKeyMap returns the default keybindings for table list +func DefaultTableListKeyMap() TableListKeyMap { + return TableListKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "prev page"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "next page"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "view table"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + GoToStart: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("gg", "go to start"), + ), + GoToEnd: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "go to end"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + SQLMode: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "SQL mode"), + ), + } +} + +// ShortHelp returns keybindings to be shown in the mini help view +func (k TableListKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Enter, k.Search} +} + +// FullHelp returns keybindings for the expanded help view +func (k TableListKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.Enter, k.Search, k.Escape, k.Refresh}, + {k.GoToStart, k.GoToEnd, k.SQLMode}, + } +} \ No newline at end of file