#![allow(missing_docs)]
use dracon_terminal_engine::compositor::{Cell, Color, Plane, Styles};
use dracon_terminal_engine::framework::prelude::*;
use dracon_terminal_engine::framework::keybindings::{resolve_keybindings, KeybindingSet, actions};
use dracon_terminal_engine::framework::widget::{Widget, WidgetId};
use dracon_terminal_engine::framework::widgets::{
Column, SearchInput, SplitPane, StatusBar, StatusSegment, Table, Toast, ToastKind,
};
use dracon_terminal_engine::input::event::{KeyCode, KeyEventKind};
use ratatui::layout::Rect;
use std::os::fd::AsFd;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
struct RowData {
cells: Vec<String>,
}
impl std::fmt::Display for RowData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.cells.first().cloned().unwrap_or_default())
}
}
#[derive(Clone, Copy, PartialEq)]
enum Panel {
Tables,
Query,
Results,
}
struct SqliteBrowser {
should_quit: Arc<AtomicBool>,
theme: Theme,
area: Rect,
db_path: String,
tables: Vec<String>,
selected_table: usize,
query: String,
editing_query: bool,
search_input: SearchInput,
results_columns: Vec<Column>,
results_rows: Vec<RowData>,
results_table: Option<Table<RowData>>,
active_panel: Panel,
show_help: bool,
status_bar: StatusBar,
toasts: Vec<Toast>,
dirty: bool,
keybindings: KeybindingSet,
}
impl SqliteBrowser {
fn new(should_quit: Arc<AtomicBool>, theme: Theme, db_path: &str) -> Self {
let search_input = SearchInput::new(WidgetId::new(3)).with_theme(theme.clone());
let mut kb_config = resolve_keybindings();
kb_config.bindings.entry(actions::THEME.to_string()).or_insert_with(|| "t".to_string());
kb_config.bindings.entry(actions::REFRESH.to_string()).or_insert_with(|| "r".to_string());
kb_config.bindings.entry(actions::EDIT.to_string()).or_insert_with(|| "e".to_string());
let keybindings = KeybindingSet::from_config(&kb_config);
let status_bar = StatusBar::new(WidgetId::new(4))
.add_segment(StatusSegment::new("SQLite Browser").with_fg(theme.primary))
.add_segment(
StatusSegment::new(
"Tab: switch | e: edit | r: refresh | t: theme | ?: help | Esc: dismiss | q: quit",
)
.with_fg(theme.fg_muted),
);
let mut app = Self {
should_quit,
theme,
area: Rect::new(0, 0, 80, 24),
db_path: db_path.to_string(),
tables: Vec::new(),
selected_table: 0,
query: "SELECT * FROM users LIMIT 10".to_string(),
editing_query: false,
search_input,
results_columns: Vec::new(),
results_rows: Vec::new(),
results_table: None,
active_panel: Panel::Tables,
show_help: false,
status_bar,
toasts: Vec::new(),
dirty: true,
keybindings,
};
app.refresh();
app
}
fn refresh(&mut self) {
self.tables = self.read_tables();
if !self.tables.is_empty() && self.query.is_empty() {
self.query = format!("SELECT * FROM {} LIMIT 10", self.tables[0]);
}
self.run_query(&self.query.clone());
self.dirty = true;
}
fn cycle_theme(&mut self) {
let themes = Theme::all();
let idx = themes
.iter()
.position(|t| t.name == self.theme.name)
.unwrap_or(0);
self.theme = themes[(idx + 1) % themes.len()].clone();
self.status_bar.on_theme_change(&self.theme);
self.search_input.on_theme_change(&self.theme);
if let Some(ref mut table) = self.results_table {
table.on_theme_change(&self.theme);
}
self.dirty = true;
}
fn read_tables(&mut self) -> Vec<String> {
let output = Command::new("sqlite3")
.args([&self.db_path, ".tables"])
.output();
match output {
Ok(o) if o.status.success() => {
let text = String::from_utf8_lossy(&o.stdout);
text.split_whitespace().map(|s| s.to_string()).collect()
}
_ => self.create_mock_db(),
}
}
fn create_mock_db(&mut self) -> Vec<String> {
let mock_db = format!("/tmp/dracon_mock_{}.db", std::process::id());
let _ = Command::new("sqlite3")
.args([mock_db.clone(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT);".to_string()])
.output();
let inserts = [
"INSERT INTO users VALUES (1, 'Alice', 'alice@example.com', 'admin');",
"INSERT INTO users VALUES (2, 'Bob', 'bob@example.com', 'user');",
"INSERT INTO users VALUES (3, 'Charlie', 'charlie@example.com', 'editor');",
"INSERT INTO users VALUES (4, 'Diana', 'diana@example.com', 'admin');",
"INSERT INTO users VALUES (5, 'Eve', 'eve@example.com', 'user');",
];
for insert in &inserts {
let args: Vec<String> = vec![mock_db.clone(), insert.to_string()];
let _ = Command::new("sqlite3").args(&args).output();
}
let _ = Command::new("sqlite3")
.args([mock_db.clone(), "CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT, author TEXT, views INTEGER);".to_string()])
.output();
let post_inserts = [
"INSERT INTO posts VALUES (1, 'Hello World', 'Alice', 150);",
"INSERT INTO posts VALUES (2, 'Rust Tips', 'Bob', 89);",
"INSERT INTO posts VALUES (3, 'TUI Design', 'Charlie', 234);",
];
for insert in &post_inserts {
let args: Vec<String> = vec![mock_db.clone(), insert.to_string()];
let _ = Command::new("sqlite3").args(&args).output();
}
self.toast(
"Using mock database (sqlite3 not available)",
ToastKind::Warning,
);
vec!["users".to_string(), "posts".to_string()].clone()
}
fn run_query(&mut self, query: &str) {
let output = Command::new("sqlite3")
.args([&self.db_path, query, "-header", "-csv"])
.output();
match output {
Ok(o) if o.status.success() => {
let text = String::from_utf8_lossy(&o.stdout);
self.parse_results(&text);
}
Ok(o) => {
let err = String::from_utf8_lossy(&o.stderr);
self.results_columns = vec![Column {
header: "Error".to_string(),
width: 50,
}];
self.results_rows = vec![RowData {
cells: vec![err.to_string()],
}];
self.toast("Query error", ToastKind::Error);
}
Err(_) => {
self.results_columns = vec![Column {
header: "Info".to_string(),
width: 50,
}];
self.results_rows = vec![RowData {
cells: vec!["SQLite not available. Install sqlite3.".to_string()],
}];
}
}
let columns: Vec<Column> = self
.results_columns
.iter()
.map(|c| Column {
header: c.header.clone(),
width: c.width,
})
.collect();
let rows: Vec<RowData> = self.results_rows.clone();
let mut table = Table::new(columns).with_theme(self.theme.clone()).with_rows(rows);
table.set_visible_count(20);
self.results_table = Some(table);
self.dirty = true;
}
fn parse_results(&mut self, csv: &str) {
let lines: Vec<&str> = csv.lines().collect();
if lines.is_empty() {
self.results_columns = Vec::new();
self.results_rows = Vec::new();
return;
}
let header: Vec<String> = lines[0].split(',').map(|s| s.trim().to_string()).collect();
self.results_columns = header
.iter()
.map(|h| Column {
header: h.clone(),
width: (h.len() + 4).max(10) as u16,
})
.collect();
self.results_rows = lines
.iter()
.skip(1)
.filter(|l| !l.is_empty())
.map(|line| {
let cells: Vec<String> = line.split(',').map(|s| s.trim().to_string()).collect();
RowData { cells }
})
.collect();
}
fn toast(&mut self, msg: &str, kind: ToastKind) {
let toast = Toast::new(WidgetId::new(100 + self.toasts.len()), msg)
.with_kind(kind)
.with_duration(Duration::from_secs(2))
.with_theme(self.theme.clone());
self.toasts.push(toast);
self.dirty = true;
}
}
impl Widget for SqliteBrowser {
fn id(&self) -> WidgetId {
WidgetId::new(0)
}
fn set_id(&mut self, _id: WidgetId) {}
fn area(&self) -> Rect {
self.area
}
fn set_area(&mut self, area: Rect) {
self.area = area;
self.dirty = true;
}
fn z_index(&self) -> u16 {
0
}
fn needs_render(&self) -> bool {
self.dirty
}
fn mark_dirty(&mut self) {
self.dirty = true;
}
fn clear_dirty(&mut self) {
self.dirty = false;
}
fn focusable(&self) -> bool {
true
}
fn on_theme_change(&mut self, theme: &Theme) {
self.theme = theme.clone();
self.status_bar.on_theme_change(theme);
self.search_input.on_theme_change(theme);
if let Some(table) = &mut self.results_table {
table.on_theme_change(theme);
}
for toast in &mut self.toasts {
toast.on_theme_change(theme);
}
self.dirty = true;
}
fn render(&self, area: Rect) -> Plane {
let t = &self.theme;
let mut plane = Plane::new(0, area.width, area.height);
for cell in plane.cells.iter_mut() {
cell.bg = t.bg;
cell.fg = t.fg;
cell.transparent = false;
}
let status_h = 1u16;
let content_h = area.height.saturating_sub(status_h);
let split = SplitPane::new(Orientation::Horizontal).ratio(0.25);
let (left_rect, right_rect) = split.split(Rect::new(0, 0, area.width, content_h));
let left_active = matches!(self.active_panel, Panel::Tables);
draw_rounded_border(&mut plane, 0, 0, left_rect.width, content_h, t, left_active);
let left_bg = if left_active {
t.surface_elevated
} else {
t.surface
};
for y in 1..content_h.saturating_sub(1) {
for x in 1..left_rect.width.saturating_sub(1) {
let idx = (y * area.width + x) as usize;
if idx < plane.cells.len() {
plane.cells[idx].bg = left_bg;
plane.cells[idx].char = ' ';
}
}
}
draw_text(&mut plane, 2, 0, " Tables", t.primary, left_bg, true);
for (i, table) in self.tables.iter().enumerate() {
let row = 2 + i as u16;
let is_selected = self.selected_table == i && left_active;
let fg = if is_selected { t.selection_fg } else { t.fg };
let bg = if is_selected {
t.selection_bg
} else {
left_bg
};
let prefix = if is_selected { "▸ " } else { " " };
draw_text(
&mut plane,
2,
row,
&format!("{}{}", prefix, table),
fg,
bg,
is_selected,
);
}
for y in 0..content_h {
let idx = (y * area.width + left_rect.width) as usize;
if idx < plane.cells.len() {
plane.cells[idx].char = '│';
plane.cells[idx].fg = t.outline;
}
}
let query_h = 3u16;
let results_y = query_h;
let results_h = right_rect.height.saturating_sub(query_h);
let query_active = matches!(self.active_panel, Panel::Query);
draw_rounded_border(
&mut plane,
left_rect.width + 1,
0,
right_rect.width.saturating_sub(1),
query_h + 1,
t,
query_active,
);
let query_bg = if query_active {
t.surface_elevated
} else {
t.surface
};
for y in 1..query_h {
for x in left_rect.width + 2..area.width.saturating_sub(1) {
let idx = (y * area.width + x) as usize;
if idx < plane.cells.len() {
plane.cells[idx].bg = query_bg;
plane.cells[idx].char = ' ';
}
}
}
draw_text(
&mut plane,
left_rect.width + 3,
0,
" Query",
t.primary,
query_bg,
true,
);
let query_text = if self.editing_query {
format!("{}_", self.search_input.query())
} else {
self.query.clone()
};
draw_text(
&mut plane,
left_rect.width + 3,
1,
&query_text,
t.fg,
query_bg,
false,
);
let results_active = matches!(self.active_panel, Panel::Results);
draw_rounded_border(
&mut plane,
left_rect.width + 1,
results_y,
right_rect.width.saturating_sub(1),
results_h,
t,
results_active,
);
let results_bg = if results_active {
t.surface_elevated
} else {
t.surface
};
for y in results_y + 1..content_h.saturating_sub(1) {
for x in left_rect.width + 2..area.width.saturating_sub(1) {
let idx = (y * area.width + x) as usize;
if idx < plane.cells.len() {
plane.cells[idx].bg = results_bg;
plane.cells[idx].char = ' ';
}
}
}
draw_text(
&mut plane,
left_rect.width + 3,
results_y,
" Results",
t.primary,
results_bg,
true,
);
if let Some(ref table) = self.results_table {
let table_plane = table.render(Rect::new(
left_rect.width + 3,
results_y + 1,
right_rect.width.saturating_sub(4),
results_h.saturating_sub(2),
));
for (i, c) in table_plane.cells.iter().enumerate() {
if c.transparent {
continue;
}
let row = i / table_plane.width as usize;
let col = i % table_plane.width as usize;
let dst_x = left_rect.width + 3 + col as u16;
let dst_y = results_y + 1 + row as u16;
let idx = (dst_y * area.width + dst_x) as usize;
if idx < plane.cells.len() {
plane.cells[idx] = *c;
}
}
let count = self.results_rows.len();
let count_text = format!(" {} rows ", count);
let count_x = area.width.saturating_sub(count_text.len() as u16 + 3);
draw_text(
&mut plane,
count_x,
results_y + results_h - 2,
&count_text,
t.selection_fg,
t.primary,
true,
);
} else {
draw_text(
&mut plane,
left_rect.width + 5,
results_y + 3,
"No results to display",
t.fg_muted,
results_bg,
false,
);
draw_text(
&mut plane,
left_rect.width + 5,
results_y + 4,
"Run a query to see data",
t.fg_subtle,
results_bg,
false,
);
}
let status_y = area.height.saturating_sub(1);
let status_plane = self
.status_bar
.render(Rect::new(0, status_y, area.width, status_h));
for (i, c) in status_plane.cells.iter().enumerate() {
if !c.transparent && i < plane.cells.len() {
let base = (status_y * area.width) as usize;
if base + i < plane.cells.len() {
plane.cells[base + i] = *c;
}
}
}
for (i, toast) in self.toasts.iter().enumerate() {
let toast_y = status_y.saturating_sub(2 + i as u16);
let toast_plane = toast.render(Rect::new(2, toast_y, area.width.saturating_sub(4), 1));
for (j, c) in toast_plane.cells.iter().enumerate() {
if !c.transparent && j < plane.cells.len() {
let base = (toast_y * area.width + 2) as usize;
if base + j < plane.cells.len() {
plane.cells[base + j] = *c;
}
}
}
}
if self.show_help {
let hw = 42u16.min(area.width.saturating_sub(4));
let hh = 14u16.min(area.height.saturating_sub(4));
let hx = (area.width - hw) / 2;
let hy = (area.height - hh) / 2;
for y in hy..hy + hh {
for x in hx..hx + hw {
let idx = (y * area.width + x) as usize;
if idx < plane.cells.len() {
plane.cells[idx].bg = t.surface_elevated;
plane.cells[idx].transparent = false;
}
}
}
let corners = [
('╭', hx, hy),
('╮', hx + hw - 1, hy),
('╰', hx, hy + hh - 1),
('╯', hx + hw - 1, hy + hh - 1),
];
for (ch, cx, cy) in corners.iter() {
let idx = (cy * area.width + cx) as usize;
if idx < plane.cells.len() {
plane.cells[idx].char = *ch;
plane.cells[idx].fg = t.outline;
}
}
for x in hx + 1..hx + hw - 1 {
let top_idx = (hy * area.width + x) as usize;
let bot_idx = ((hy + hh - 1) * area.width + x) as usize;
if top_idx < plane.cells.len() {
plane.cells[top_idx].char = '─';
plane.cells[top_idx].fg = t.outline;
}
if bot_idx < plane.cells.len() {
plane.cells[bot_idx].char = '─';
plane.cells[bot_idx].fg = t.outline;
}
}
for y in hy + 1..hy + hh - 1 {
let left_idx = (y * area.width + hx) as usize;
let right_idx = (y * area.width + hx + hw - 1) as usize;
if left_idx < plane.cells.len() {
plane.cells[left_idx].char = '│';
plane.cells[left_idx].fg = t.outline;
}
if right_idx < plane.cells.len() {
plane.cells[right_idx].char = '│';
plane.cells[right_idx].fg = t.outline;
}
}
let title = "SQLite Browser Help";
let tx = hx + (hw - title.len() as u16) / 2;
for (i, c) in title.chars().enumerate() {
let idx = ((hy + 1) * area.width + tx + i as u16) as usize;
if idx < plane.cells.len() {
plane.cells[idx].char = c;
plane.cells[idx].fg = t.primary;
plane.cells[idx].style = Styles::BOLD;
}
}
let kb_edit = self.keybindings.display(actions::EDIT).unwrap_or("e");
let kb_refresh = self.keybindings.display(actions::REFRESH).unwrap_or("r");
let kb_theme = self.keybindings.display(actions::THEME).unwrap_or("t");
let kb_help = self.keybindings.display(actions::HELP).unwrap_or("?");
let kb_quit = self.keybindings.display(actions::QUIT).unwrap_or("q");
let shortcuts = [
("↑/↓/j/k", "Navigate"),
("Enter", "Select / Run query"),
("Tab", "Switch panel"),
(kb_edit, "Edit query"),
(kb_refresh, "Refresh"),
(kb_theme, "Cycle theme"),
(kb_help, "Toggle help"),
(kb_quit, "Quit"),
];
for (i, (key, desc)) in shortcuts.iter().enumerate() {
let row = hy + 3 + i as u16;
for (j, c) in key.chars().enumerate() {
let idx = (row * area.width + hx + 2 + j as u16) as usize;
if idx < plane.cells.len() {
plane.cells[idx].char = c;
plane.cells[idx].fg = t.primary;
}
}
for (j, c) in desc.chars().enumerate() {
let idx = (row * area.width + hx + 14 + j as u16) as usize;
if idx < plane.cells.len() {
plane.cells[idx].char = c;
plane.cells[idx].fg = t.fg;
}
}
}
}
plane
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
if key.kind != KeyEventKind::Press {
return false;
}
if self.editing_query {
if self.keybindings.matches(actions::BACK, &key) {
self.editing_query = false;
self.query = self.search_input.query().to_string();
self.dirty = true;
true
} else if self.keybindings.matches(actions::SUBMIT, &key) {
self.editing_query = false;
self.query = self.search_input.query().to_string();
self.run_query(&self.query.clone());
true
} else {
let handled = self.search_input.handle_key(key);
if handled {
self.dirty = true;
}
handled
}
} else {
if self.keybindings.matches(actions::BACK, &key) && self.show_help {
self.show_help = false;
self.dirty = true;
true
} else if self.keybindings.matches(actions::QUIT, &key) {
self.should_quit.store(true, Ordering::SeqCst);
true
} else if self.keybindings.matches(actions::REFRESH, &key) {
self.refresh();
self.toast("Refreshed", ToastKind::Info);
true
} else if self.keybindings.matches(actions::EDIT, &key) {
self.editing_query = true;
self.search_input = SearchInput::new(WidgetId::new(3)).with_theme(self.theme.clone());
self.active_panel = Panel::Query;
self.dirty = true;
true
} else if self.keybindings.matches(actions::THEME, &key) {
self.cycle_theme();
true
} else if self.keybindings.matches(actions::HELP, &key) {
self.show_help = !self.show_help;
self.dirty = true;
true
} else {
match key.code {
KeyCode::Tab => {
self.active_panel = match self.active_panel {
Panel::Tables => Panel::Query,
Panel::Query => Panel::Results,
Panel::Results => Panel::Tables,
};
self.dirty = true;
true
}
KeyCode::Down | KeyCode::Char('j') => {
match self.active_panel {
Panel::Tables if self.selected_table + 1 < self.tables.len() => {
self.selected_table += 1;
self.dirty = true;
}
Panel::Results => {
if let Some(ref mut table) = self.results_table {
table.handle_key(key);
self.dirty = true;
}
}
_ => {}
}
true
}
KeyCode::Up | KeyCode::Char('k') => {
match self.active_panel {
Panel::Tables if self.selected_table > 0 => {
self.selected_table -= 1;
self.dirty = true;
}
Panel::Results => {
if let Some(ref mut table) = self.results_table {
table.handle_key(key);
self.dirty = true;
}
}
_ => {}
}
true
}
KeyCode::Enter => {
if self.active_panel == Panel::Tables {
if let Some(table) = self.tables.get(self.selected_table) {
self.query = format!("SELECT * FROM {} LIMIT 10", table);
self.run_query(&self.query.clone());
}
}
true
}
_ => false,
}
}
}
}
fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16) -> bool {
if self.editing_query {
return self.search_input.handle_mouse(kind, col, row);
}
match kind {
MouseEventKind::Down(MouseButton::Left) => {
let content_h = self.area.height.saturating_sub(1);
let split = SplitPane::new(Orientation::Horizontal).ratio(0.25);
let (left_rect, _) = split.split(Rect::new(0, 0, self.area.width, content_h));
if col < left_rect.width && row < content_h {
let idx = row as usize;
if idx < self.tables.len() {
self.selected_table = idx;
self.active_panel = Panel::Tables;
self.dirty = true;
true
} else {
false
}
} else {
false
}
}
MouseEventKind::ScrollDown => {
if self.active_panel == Panel::Tables && self.selected_table + 1 < self.tables.len()
{
self.selected_table += 1;
self.dirty = true;
}
true
}
MouseEventKind::ScrollUp => {
if self.active_panel == Panel::Tables && self.selected_table > 0 {
self.selected_table -= 1;
self.dirty = true;
}
true
}
_ => false,
}
}
}
fn draw_rounded_border(plane: &mut Plane, x: u16, y: u16, w: u16, h: u16, t: &Theme, active: bool) {
if w < 3 || h < 2 {
return;
}
let border_color = if active { t.primary } else { t.outline };
for row in y..y + h {
for col in x..x + w {
let idx = (row * plane.width + col) as usize;
if idx >= plane.cells.len() {
continue;
}
let is_border = row == y || row == y + h - 1 || col == x || col == x + w - 1;
let is_corner = (row == y || row == y + h - 1) && (col == x || col == x + w - 1);
if is_border {
plane.cells[idx].fg = if is_corner { t.primary } else { border_color };
if row == y && col == x {
plane.cells[idx].char = '╭';
} else if row == y && col == x + w - 1 {
plane.cells[idx].char = '╮';
} else if row == y + h - 1 && col == x {
plane.cells[idx].char = '╰';
} else if row == y + h - 1 && col == x + w - 1 {
plane.cells[idx].char = '╯';
} else if row == y || row == y + h - 1 {
plane.cells[idx].char = '─';
} else {
plane.cells[idx].char = '│';
}
plane.cells[idx].transparent = false;
}
}
}
}
fn draw_text(plane: &mut Plane, x: u16, y: u16, text: &str, fg: Color, bg: Color, bold: bool) {
for (i, ch) in text.chars().enumerate() {
let idx = (y * plane.width + x + i as u16) as usize;
if idx < plane.cells.len() {
plane.cells[idx] = Cell {
char: ch,
fg,
bg,
style: if bold { Styles::BOLD } else { Styles::empty() },
transparent: false,
skip: false,
};
}
}
}
fn main() -> std::io::Result<()> {
println!("SQLite Browser — Database explorer");
println!("Tab: switch panels | e: edit query | r: refresh | q: quit");
std::thread::sleep(Duration::from_millis(300));
let (w, h) = dracon_terminal_engine::backend::tty::get_window_size(std::io::stdout().as_fd())
.unwrap_or((80, 24));
let db_path = std::env::args()
.nth(1)
.unwrap_or_else(|| ":memory:".to_string());
let should_quit = Arc::new(AtomicBool::new(false));
let quit_check = Arc::clone(&should_quit);
let theme = Theme::from_env_or(Theme::nord());
let browser = SqliteBrowser::new(should_quit, theme.clone(), &db_path);
let mut app = App::new()?.title("SQLite Browser").fps(30).theme(theme);
app.add_widget(Box::new(browser), Rect::new(0, 0, w, h));
app.on_tick(move |ctx, _| {
if quit_check.load(Ordering::SeqCst) {
ctx.stop();
}
})
.run(|_ctx| {})?;
println!("\nSQLite Browser exited cleanly");
Ok(())
}