From e79d20f7ce92cf7d1e521a049022b11acc02d70d Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Fri, 11 Jul 2025 23:19:49 -0700 Subject: [PATCH] add ability to use editing modes --- README.md | 32 ++++++++------ edit_cell.go | 112 +++++++++++++++++++++++++++++++++++++++++++--- query.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 234 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index d09b4b2..30c87bd 100644 --- a/README.md +++ b/README.md @@ -63,16 +63,21 @@ go run main.go sample.db - `q` or `Ctrl+C`: Quit ### Cell Edit Mode -- Type new value for the selected cell +- **Readline-style Editing**: Full cursor control and advanced editing +- **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation +- **Line Navigation**: `Home`/`Ctrl+A` (start), `End`/`Ctrl+E` (end) +- **Deletion**: `Backspace`, `Delete`/`Ctrl+D`, `Ctrl+W` (word), `Ctrl+K` (to end), `Ctrl+U` (to start) - **Text Wrapping**: Long values are automatically wrapped for better visibility - `Enter`: Save changes to database - `Esc`: Cancel editing and return to row detail -- `Backspace`: Delete characters ### SQL Query Mode -- Type your SQL query (all keys including r, s, h, j, k, l work as input) +- **Advanced Text Editing**: Full readline-style editing controls +- **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation +- **Line Navigation**: `Home`/`Ctrl+A` (start), `End`/`Ctrl+E` (end) +- **Deletion**: `Backspace`, `Delete`/`Ctrl+D`, `Ctrl+W` (word), `Ctrl+K` (to end), `Ctrl+U` (to start) +- Type your SQL query (all keys work as input, no conflicts with navigation) - `Enter`: Execute query -- `Backspace`: Delete characters - `Esc`: Return to table list - `q` or `Ctrl+C`: Quit @@ -85,15 +90,16 @@ go run main.go sample.db 5. **Data Search**: Search within table data across all columns 6. **Row Detail Modal**: 2-column view showing Column | Value for selected row 7. **Cell Editing**: Live editing of individual cell values with database updates -8. **Text Wrapping**: Long values are automatically wrapped in edit and detail views -9. **Primary Key Detection**: Uses primary keys for reliable row updates -10. **Screen-Aware Display**: Content automatically fits terminal size -11. **SQL Query Execution**: Execute custom SQL queries and view results (all keys work as input) -12. **Error Handling**: Displays database errors gracefully -13. **Responsive UI**: Clean, styled interface that adapts to terminal size -14. **Column Information**: Shows column names and handles NULL values -15. **Navigation**: Intuitive keyboard shortcuts for all operations -16. **Dynamic Column Width**: Columns adjust to terminal width +8. **Readline-style Editing**: Full cursor control with word navigation, line navigation, and advanced deletion +9. **Text Wrapping**: Long values are automatically wrapped in edit and detail views +10. **Primary Key Detection**: Uses primary keys for reliable row updates +11. **Screen-Aware Display**: Content automatically fits terminal size +12. **SQL Query Execution**: Execute custom SQL queries with advanced text editing +13. **Error Handling**: Displays database errors gracefully +14. **Responsive UI**: Clean, styled interface that adapts to terminal size +15. **Column Information**: Shows column names and handles NULL values +16. **Navigation**: Intuitive keyboard shortcuts for all operations +17. **Dynamic Column Width**: Columns adjust to terminal width ## Navigation Flow diff --git a/edit_cell.go b/edit_cell.go index 54a3158..5ed3ef2 100644 --- a/edit_cell.go +++ b/edit_cell.go @@ -14,6 +14,7 @@ type editCellModel struct { colIndex int editingValue string originalValue string + cursorPos int } func newEditCellModel(shared *sharedData, rowIndex, colIndex int) *editCellModel { @@ -28,6 +29,7 @@ func newEditCellModel(shared *sharedData, rowIndex, colIndex int) *editCellModel colIndex: colIndex, editingValue: originalValue, originalValue: originalValue, + cursorPos: len(originalValue), // Start cursor at end } } @@ -59,19 +61,104 @@ func (m *editCellModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { } } - case "backspace": - if len(m.editingValue) > 0 { - m.editingValue = m.editingValue[:len(m.editingValue)-1] + // Cursor movement + case "left": + if m.cursorPos > 0 { + m.cursorPos-- } + case "right": + if m.cursorPos < len(m.editingValue) { + m.cursorPos++ + } + + case "ctrl+left": + m.cursorPos = m.wordLeft(m.cursorPos) + + case "ctrl+right": + m.cursorPos = m.wordRight(m.cursorPos) + + case "home", "ctrl+a": + m.cursorPos = 0 + + case "end", "ctrl+e": + m.cursorPos = len(m.editingValue) + + // Deletion + case "backspace": + if m.cursorPos > 0 { + m.editingValue = m.editingValue[:m.cursorPos-1] + m.editingValue[m.cursorPos:] + m.cursorPos-- + } + + case "delete", "ctrl+d": + if m.cursorPos < len(m.editingValue) { + m.editingValue = m.editingValue[:m.cursorPos] + m.editingValue[m.cursorPos+1:] + } + + case "ctrl+w": + // Delete word backward + newPos := m.wordLeft(m.cursorPos) + m.editingValue = m.editingValue[:newPos] + m.editingValue[m.cursorPos:] + m.cursorPos = newPos + + case "ctrl+k": + // Delete from cursor to end of line + m.editingValue = m.editingValue[:m.cursorPos] + + case "ctrl+u": + // Delete from beginning of line to cursor + m.editingValue = m.editingValue[m.cursorPos:] + m.cursorPos = 0 + default: + // Insert character at cursor position if len(msg.String()) == 1 { - m.editingValue += msg.String() + m.editingValue = m.editingValue[:m.cursorPos] + msg.String() + m.editingValue[m.cursorPos:] + m.cursorPos++ } } return m, nil } +// Helper functions for word navigation (same as query model) +func (m *editCellModel) wordLeft(pos int) int { + if pos == 0 { + return 0 + } + + // Skip whitespace + for pos > 0 && isWhitespace(m.editingValue[pos-1]) { + pos-- + } + + // Skip non-whitespace + for pos > 0 && !isWhitespace(m.editingValue[pos-1]) { + pos-- + } + + return pos +} + +func (m *editCellModel) wordRight(pos int) int { + length := len(m.editingValue) + if pos >= length { + return length + } + + // Skip non-whitespace + for pos < length && !isWhitespace(m.editingValue[pos]) { + pos++ + } + + // Skip whitespace + for pos < length && isWhitespace(m.editingValue[pos]) { + pos++ + } + + return pos +} + func (m *editCellModel) View() string { var content strings.Builder @@ -98,17 +185,28 @@ func (m *editCellModel) View() string { content.WriteString("\n") - // Wrap new value + // Wrap new value with cursor content.WriteString("New:") content.WriteString("\n") - newLines := wrapText(m.editingValue+"_", textWidth) // Add cursor + + // Display editing value with cursor + valueWithCursor := "" + if m.cursorPos <= len(m.editingValue) { + before := m.editingValue[:m.cursorPos] + after := m.editingValue[m.cursorPos:] + valueWithCursor = before + "█" + after + } else { + valueWithCursor = m.editingValue + "█" + } + + newLines := wrapText(valueWithCursor, textWidth) for _, line := range newLines { content.WriteString(" " + line) content.WriteString("\n") } content.WriteString("\n") - content.WriteString(helpStyle.Render("Type new value • enter: save • esc: cancel")) + content.WriteString(helpStyle.Render("←/→: move cursor • ctrl+←/→: word nav • home/end: line nav • ctrl+w/k/u: delete • enter: save • esc: cancel")) return content.String() } \ No newline at end of file diff --git a/query.go b/query.go index 13cc00f..0f3a80c 100644 --- a/query.go +++ b/query.go @@ -9,10 +9,11 @@ import ( // Query Model type queryModel struct { - shared *sharedData - queryInput string - results [][]string - columns []string + shared *sharedData + queryInput string + cursorPos int + results [][]string + columns []string } func newQueryModel(shared *sharedData) *queryModel { @@ -43,20 +44,108 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { // Handle error - could set an error field } - case "backspace": - if len(m.queryInput) > 0 { - m.queryInput = m.queryInput[:len(m.queryInput)-1] + // Cursor movement + case "left": + if m.cursorPos > 0 { + m.cursorPos-- } + case "right": + if m.cursorPos < len(m.queryInput) { + m.cursorPos++ + } + + case "ctrl+left": + m.cursorPos = m.wordLeft(m.cursorPos) + + case "ctrl+right": + m.cursorPos = m.wordRight(m.cursorPos) + + case "home", "ctrl+a": + m.cursorPos = 0 + + case "end", "ctrl+e": + m.cursorPos = len(m.queryInput) + + // Deletion + case "backspace": + if m.cursorPos > 0 { + m.queryInput = m.queryInput[:m.cursorPos-1] + m.queryInput[m.cursorPos:] + m.cursorPos-- + } + + case "delete", "ctrl+d": + if m.cursorPos < len(m.queryInput) { + m.queryInput = m.queryInput[:m.cursorPos] + m.queryInput[m.cursorPos+1:] + } + + case "ctrl+w": + // Delete word backward + newPos := m.wordLeft(m.cursorPos) + m.queryInput = m.queryInput[:newPos] + m.queryInput[m.cursorPos:] + m.cursorPos = newPos + + case "ctrl+k": + // Delete from cursor to end of line + m.queryInput = m.queryInput[:m.cursorPos] + + case "ctrl+u": + // Delete from beginning of line to cursor + m.queryInput = m.queryInput[m.cursorPos:] + m.cursorPos = 0 + default: - // In query mode, all single characters should be treated as input + // Insert character at cursor position if len(msg.String()) == 1 { - m.queryInput += msg.String() + m.queryInput = m.queryInput[:m.cursorPos] + msg.String() + m.queryInput[m.cursorPos:] + m.cursorPos++ } } return m, nil } +// Helper functions for word navigation +func (m *queryModel) wordLeft(pos int) int { + if pos == 0 { + return 0 + } + + // Skip whitespace + for pos > 0 && isWhitespace(m.queryInput[pos-1]) { + pos-- + } + + // Skip non-whitespace + for pos > 0 && !isWhitespace(m.queryInput[pos-1]) { + pos-- + } + + return pos +} + +func (m *queryModel) wordRight(pos int) int { + length := len(m.queryInput) + if pos >= length { + return length + } + + // Skip non-whitespace + for pos < length && !isWhitespace(m.queryInput[pos]) { + pos++ + } + + // Skip whitespace + for pos < length && isWhitespace(m.queryInput[pos]) { + pos++ + } + + return pos +} + +func isWhitespace(r byte) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' +} + func (m *queryModel) executeQuery() error { if strings.TrimSpace(m.queryInput) == "" { return nil @@ -113,9 +202,18 @@ func (m *queryModel) View() string { content.WriteString(titleStyle.Render("SQL Query")) content.WriteString("\n\n") + // Display query with cursor content.WriteString("Query: ") - content.WriteString(m.queryInput) - content.WriteString("_") // cursor + if m.cursorPos <= len(m.queryInput) { + before := m.queryInput[:m.cursorPos] + after := m.queryInput[m.cursorPos:] + content.WriteString(before) + content.WriteString("█") // Block cursor + content.WriteString(after) + } else { + content.WriteString(m.queryInput) + content.WriteString("█") + } content.WriteString("\n\n") if len(m.results) > 0 { @@ -169,7 +267,7 @@ func (m *queryModel) View() string { } content.WriteString("\n") - content.WriteString(helpStyle.Render("enter: execute query • esc: back • q: quit")) + content.WriteString(helpStyle.Render("enter: execute • ←/→: move cursor • ctrl+←/→: word nav • home/end: line nav • ctrl+w/k/u: delete • esc: back")) return content.String() } \ No newline at end of file