Editable Tables Widget
May 11, 2026 Β· View on GitHub
Version: v0.2.5
Overview
The EditableTable widget provides a fully interactive table editing experience in the WYSIWYG markdown editor. Users can edit cell content, add/remove rows and columns, and control column alignment - all while the widget automatically regenerates valid GitHub Flavored Markdown (GFM) table syntax.
Features
- Editable Cells: Each cell renders as a
TextEditfield for inline editing - Deferred Updates: Cell edits are buffered and only committed when focus leaves the table
- Keyboard Navigation: Tab/Enter/Escape to navigate between cells
- Dynamic Cell Width: Columns auto-size based on content (min 60px, max 400px)
- Add/Remove Rows:
β Add rowbutton and π delete buttons per row - Add/Remove Columns:
βbutton to add columns,Γbuttons to delete columns - Automatic Markdown Generation: All changes regenerate valid GFM table syntax
- Theme-Aware Styling: Adapts colors for dark and light modes
- Persistent State: Table data persists across frames using egui's memory
Note: Column alignment controls are available via the alignment buttons in the UI.
Edit Behavior
Deferred Updates
Table cells use a deferred update model to prevent focus loss during editing:
- While typing: Edits are stored in memory but NOT committed to source
- On focus loss: When clicking outside the table, all changes are committed
- On keyboard navigation: Tab/Enter move focus between cells without committing
- On structural changes: Add/remove row/column commits immediately
This prevents the re-parsing loop that would otherwise cause cursor focus loss after each keystroke.
Keyboard Navigation
| Key | Action |
|---|---|
| Tab | Move to next cell (right, then wrap to next row) |
| Shift+Tab | Move to previous cell (left, then wrap to previous row) |
| Enter | Move to next row (same column) |
| Escape | Exit table editing (commits changes) |
Inside the table each cell uses TextEdit::lock_focus(true) so Tab stays in-tab instead of advancing eguiβs global tab order (otherwise focus jumps out and the next cell never enters edit mode). Tab is consumed before rendering so \t is not inserted into cell text. Shift+Tab must be consumed in code before plain Tab: egui matches consume_key(NONE, Tab) to Shift+Tab as well (matches_logically), so the wrong branch would run otherwise.
Focus State Tracking
The TableEditState struct tracks focus across frames:
pub struct TableEditState {
pub focused_cell: Option<(usize, usize)>, // Currently focused cell
pub pending_focus: Option<(usize, usize)>, // Cell to focus next frame
pub had_focus_last_frame: bool, // For focus loss detection
pub content_modified: bool, // Track if edits were made
}
Empty cells β display hit area (v0.3.0+)
Cells that are not focused paint their body in display mode (formatted galley instead of raw TextEdit). An empty markdown cell layouts to a galley with no extent; a clickable Label built from it had zero interactive size, so users could not click into new empty slots (new row/column) even though row height and striping drew a visible cell (issue #131). EditableTable now reserves the padded inner rectangle with allocate_exact_size(.., Sense::click()) and paints the galley there. Edit mode still uses TextEdit::multiline(&mut cell.text) keyed by (row, col); an empty buffer is represented as String::new() on TableCellData, matching the deferred βcommit on leaving the tableβ model.
Architecture
Core Types
TableCellData
pub struct TableCellData {
pub text: String,
}
Represents a single cell's content.
TableData
pub struct TableData {
pub rows: Vec<Vec<TableCellData>>, // First row is header
pub alignments: Vec<TableAlignment>,
pub num_columns: usize,
}
Complete table state including rows, alignments, and column count.
EditableTable
pub struct EditableTable<'a> {
data: &'a mut TableData,
font_size: f32,
colors: Option<WidgetColors>,
show_controls: bool,
show_alignment_controls: bool,
id: Option<egui::Id>,
}
The egui widget that renders the interactive table.
File Locations
-
Widget Implementation:
src/markdown/widgets.rsTableCellData,TableData,EditableTablestructs- Table manipulation methods (add/remove/insert row/column)
- Markdown generation (
to_markdown()) - Cell focus / Tab / empty-cell hits:
table-cell-focus-navigation.md
-
Editor Integration:
src/markdown/editor.rsrender_table()function usesEditableTableupdate_table_in_source()for source synchronization
Usage
Creating from Markdown AST Node
use crate::markdown::widgets::{EditableTable, TableData};
let mut table_data = TableData::from_node(&table_node);
let output = EditableTable::new(&mut table_data)
.font_size(14.0)
.with_controls(true)
.with_alignment_controls(true)
.show(ui);
if output.changed {
// output.markdown contains regenerated table syntax
update_source(source, &output.markdown);
}
Creating Programmatically
let mut table = TableData::new(3, 2); // 3 columns, 2 rows
table.rows[0][0].text = "Header 1".to_string();
table.rows[0][1].text = "Header 2".to_string();
table.rows[0][2].text = "Header 3".to_string();
table.rows[1][0].text = "Data 1".to_string();
table.rows[1][1].text = "Data 2".to_string();
table.rows[1][2].text = "Data 3".to_string();
table.set_column_alignment(0, TableAlignment::Left);
table.set_column_alignment(1, TableAlignment::Center);
table.set_column_alignment(2, TableAlignment::Right);
Markdown Generation
The to_markdown() method generates valid GFM table syntax:
Input State
TableData {
rows: [
["Left", "Center", "Right"],
["A", "B", "C"],
],
alignments: [Left, Center, Right],
num_columns: 3,
}
Generated Markdown
| Left | Center | Right |
|:-------|:------:|------:|
| A | B | C |
Alignment Markers
| Alignment | Separator Format |
|---|---|
| None | --- |
| Left | :--- |
| Center | :---: |
| Right | ---: |
Table Manipulation Methods
Row Operations
// Add row at end
table.add_row();
// Insert row at specific index
table.insert_row(1); // Insert after header
// Remove row (protects last row)
table.remove_row(2);
Column Operations
// Add column at end
table.add_column();
// Insert column at specific index
table.insert_column(1);
// Remove column (protects last column)
table.remove_column(0);
Alignment Control
// Set specific alignment
table.set_column_alignment(0, TableAlignment::Center);
// Cycle through: None β Left β Center β Right β None
table.cycle_column_alignment(0);
UI Layout
$ βββββββββββββββββββββββββββββββββββββββββββββββββββ β \text{Align}: [β¬
] [β¬] [β‘] β β \text{Alignment} \text{controls} βββββββββββββββββββββββββββββββββββββββββββββββββββ€ β [π] β \text{Header} 1 β \text{Header} 2 β \text{Header} 3 β [β] β β \text{Header} \text{row} + \text{add} \text{column} β [π] β \text{Cell} 1 β \text{Cell} 2 β \text{Cell} 3 β β β \text{Data} \text{row} β [π] β \text{Cell} 4 β \text{Cell} 5 β \text{Cell} 6 β β β \text{Data} \text{row} βββββββββββββββββββββββββββββββββββββββββββββββββββ€ β [β \text{Add} \text{row}] β β \text{Add} \text{row} \text{button} βββββββββββββββββββββββββββββββββββββββββββββββββββ€ β \text{Del} \text{col}: [ \times ] [ \times ] [ \times ] β β \text{Column} \text{delete} \text{buttons} βββββββββββββββββββββββββββββββββββββββββββββββββββ $
Integration with Editor
The render_table() function in editor.rs integrates EditableTable:
- Creates unique ID from table's source line position
- Stores
TableDatain egui's frame-persistent memory - Renders
EditableTablewith appropriate styling - Syncs changes back to markdown source when modified
fn render_table(ui, node, source, edit_state, colors, font_size) {
let table_id = ui.id().with("table").with(node.start_line);
// Retrieve or create persistent table data
let mut table_data = ui.memory_mut(|mem| {
mem.data.get_temp_mut_or_insert_with(table_id, || {
TableData::from_node(node)
}).clone()
});
// Show widget
let output = EditableTable::new(&mut table_data)
.font_size(font_size)
.show(ui);
// Update source on change
if output.changed {
update_table_in_source(source, node.start_line, node.end_line, &output.markdown);
}
}
Tests
The widget includes comprehensive tests in src/markdown/widgets.rs:
test_table_cell_data_new- Cell creationtest_table_data_new- Table initializationtest_table_data_add_row/test_table_data_insert_row- Row additiontest_table_data_remove_row/test_table_data_remove_row_protects_last- Row removaltest_table_data_add_column/test_table_data_insert_column- Column additiontest_table_data_remove_column/test_table_data_remove_column_protects_last- Column removaltest_table_data_set_alignment/test_table_data_cycle_alignment- Alignment controltest_table_data_to_markdown_basic/test_table_data_to_markdown_with_alignment- Markdown generationtest_table_row_count/test_table_has_header- Table properties
Run tests with:
cargo test table
Future Enhancements
Potential improvements for future iterations:
- Column Resizing: Drag-to-resize column widths
- Row Reordering: Drag-and-drop to reorder rows
- Cell Formatting: Support for bold/italic/code within cells
- Multi-select: Select multiple cells for bulk operations
- Copy/Paste: Clipboard support for table data
- Column Sorting: Sort rows by column values
- Live Preview: Show changes in split view while typing (currently deferred until focus loss)
Related Documentation
- Table Editing Focus Fix - Details on the deferred update solution
- WYSIWYG Editor - Overall editor architecture
- Editable Widgets - Other editable widgets (headings, lists, etc.)
- Markdown Parser - AST structure for tables
- Split View - Split view mode with editable preview pane