use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::core::virtual_fs::SyncState;
use crate::ui::state::{AppState, Focus, Mode, Overlay};
use crate::ui::tabs::{SubView, WorkspaceTab};
use crate::ui::theme::Theme;
use crate::ui::widgets;
const SIDEBAR_MIN_WIDTH: u16 = 22;
fn fill_bg(frame: &mut Frame, area: Rect, style: Style) {
let fill = " ".repeat(area.width as usize);
let lines: Vec<Line> = (0..area.height)
.map(|_| Line::from(Span::styled(fill.clone(), style)))
.collect();
frame.render_widget(Paragraph::new(lines), area);
}
pub fn render(frame: &mut Frame, state: &mut AppState, theme: &Theme) {
let area = frame.area();
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(1),
])
.split(area);
render_topbar(frame, state, theme, root[0]);
let sidebar_width = (area.width / 5).max(SIDEBAR_MIN_WIDTH).min(area.width / 3);
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(sidebar_width), Constraint::Min(20)])
.split(root[1]);
let sidebar_split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(main[0]);
widgets::sidebar::render(frame, &mut *state, theme, sidebar_split[0]);
render_scripts_panel(frame, state, theme, sidebar_split[1]);
render_center(frame, state, theme, main[1]);
widgets::statusbar::render(frame, state, theme, root[2]);
match &state.overlay {
Some(Overlay::ConnectionDialog) => {
widgets::connection_dialog::render(
frame,
&state.connection_form,
&state.saved_connections,
theme,
);
}
Some(Overlay::Help) => {
widgets::help::render(frame, theme);
}
Some(Overlay::ConnectionMenu) => {
widgets::conn_menu::render(frame, &state.conn_menu, theme);
}
Some(Overlay::GroupMenu) => {
widgets::group_menu::render(frame, &state.group_menu, theme);
}
Some(Overlay::ObjectFilter) => {
widgets::schema_filter::render(frame, &mut state.object_filter, theme);
}
Some(Overlay::ConfirmClose) => {
render_confirm_close(frame, theme, area);
}
Some(Overlay::ConfirmQuit) => {
render_confirm_quit(frame, state, theme, area);
}
Some(Overlay::SaveScriptName) => {
render_save_script_name(frame, state, theme, area);
}
Some(Overlay::ScriptConnection) => {
render_script_conn_picker(frame, state, theme, area);
}
Some(Overlay::ThemePicker) => {
render_theme_picker(frame, state, theme, area);
}
Some(Overlay::BindVariables) => {
render_bind_variables(frame, state, theme, area);
}
Some(Overlay::SaveGridChanges) => {
render_save_grid_confirm(frame, state, theme, area);
}
Some(Overlay::ConfirmDropObject) => {
render_confirm_drop(frame, state, theme, area);
}
Some(Overlay::RenameObject) => {
render_rename_object(frame, state, theme, area);
}
Some(Overlay::ConfirmCompile) => {
render_confirm_compile(frame, state, theme, area);
}
_ => {}
}
if state.leader_help_visible {
let level = if state.leader_b_pending {
2
} else if state.leader_leader_pending {
3
} else if state.leader_w_pending {
4
} else if state.leader_s_pending {
5
} else {
1
};
render_leader_help(frame, theme, area, level);
}
}
fn render_leader_help(frame: &mut Frame, theme: &Theme, area: Rect, level: usize) {
use ratatui::style::Color;
let key_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(theme.status_fg);
let header_style = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD);
let (title, entries) = match level {
2 => ("Leader > b", vec![("d", "close buffer")]),
3 => ("Leader > Leader", vec![("s", "compile to DB")]),
4 => ("Leader > w", vec![("d", "close result tab")]),
5 => ("Leader > s", vec![("s", "SELECT template")]),
_ => (
"Leader (Space)",
vec![
("Enter", "execute query"),
("/", "execute → new tab"),
("c", "connection"),
("t", "theme"),
("s", "+snippets..."),
("b", "+buffer..."),
("w", "+result..."),
("Spc", "+compile..."),
],
),
};
let mut lines = vec![
Line::from(Span::styled(format!(" {title}"), header_style)),
Line::from(""),
];
for (key, desc) in &entries {
lines.push(Line::from(vec![
Span::styled(format!(" {key:<8} "), key_style),
Span::styled(*desc, desc_style),
]));
}
let height = (lines.len() as u16 + 2).min(area.height);
let width = 28_u16.min(area.width);
let x = area.width.saturating_sub(width + 1);
let y = area.height.saturating_sub(height + 2);
let popup = Rect::new(x, y, width, height);
frame.render_widget(ratatui::widgets::Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_focused))
.style(Style::default().bg(Color::Rgb(25, 25, 35)));
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, popup);
}
fn render_script_conn_picker(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
use crate::ui::state::PickerItem;
let picker = match &state.script_conn_picker {
Some(p) => p,
None => return,
};
let visible = picker.visible_items();
let count = visible.len();
let height = (count as u16 + 2).min(14).min(area.height);
let width = 38_u16.min(area.width);
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width, height);
frame.render_widget(ratatui::widgets::Clear, popup);
let block = Block::default()
.title(" Select Connection ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.dialog_bg));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let items: Vec<ratatui::widgets::ListItem> = visible
.iter()
.map(|item| match item {
PickerItem::Active(name) => ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled("● ", Style::default().fg(theme.conn_connected)),
Span::styled(name.as_str(), Style::default().fg(theme.topbar_fg)),
])),
PickerItem::OthersHeader => {
let arrow = if picker.others_expanded { "▼" } else { "▶" };
ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{arrow} Others"),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
),
]))
}
PickerItem::Other(name) => ratatui::widgets::ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled("○ ", Style::default().fg(theme.dim)),
Span::styled(name.as_str(), Style::default().fg(theme.dim)),
])),
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(picker.cursor));
let list = ratatui::widgets::List::new(items)
.highlight_style(
Style::default()
.bg(theme.tree_selected_bg)
.fg(theme.tree_selected_fg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▸ ");
frame.render_stateful_widget(list, inner, &mut list_state);
}
fn render_scripts_panel(frame: &mut Frame, state: &mut AppState, theme: &Theme, area: Rect) {
use crate::ui::state::{ScriptNode, ScriptsMode};
let is_focused = state.focus == Focus::ScriptsPanel;
let border_style = theme.border_style(is_focused, &state.mode);
let script_count = state
.scripts_tree
.iter()
.filter(|n| matches!(n, ScriptNode::Script { .. }))
.count();
let mode_hint = match &state.scripts_mode {
ScriptsMode::PendingD => " [d]",
ScriptsMode::PendingY => " [y]",
_ => "",
};
let title = format!(" Scripts ({script_count}){mode_hint} ");
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style)
.style(Style::default().bg(theme.editor_bg));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let visible_height = inner.height as usize;
let visible: Vec<(usize, ScriptNode)> = state
.visible_scripts()
.into_iter()
.map(|(i, n)| (i, n.clone()))
.collect();
if state.scripts_cursor < state.scripts_offset {
state.scripts_offset = state.scripts_cursor;
}
if state.scripts_cursor >= state.scripts_offset + visible_height {
state.scripts_offset = state.scripts_cursor - visible_height + 1;
}
if state.scripts_tree.is_empty() && !matches!(state.scripts_mode, ScriptsMode::Insert { .. }) {
let lines = vec![
Line::from(Span::styled(
" (no scripts)",
Style::default().fg(theme.dim),
)),
Line::from(Span::styled(
" press i to create",
Style::default().fg(theme.dim),
)),
];
let content = Paragraph::new(lines);
frame.render_widget(content, inner);
return;
}
let inner_width = inner.width as usize;
let mut lines: Vec<Line> = visible
.iter()
.enumerate()
.skip(state.scripts_offset)
.take(visible_height)
.map(|(vi, (_tree_idx, node))| {
let is_selected = vi == state.scripts_cursor && is_focused;
if let ScriptsMode::ConfirmDelete { path } = &state.scripts_mode {
let node_path = match node {
ScriptNode::Collection { name, .. } => name.as_str(),
ScriptNode::Script { file_path, .. } => file_path.as_str(),
};
if path == node_path {
let display = match node {
ScriptNode::Collection { name, .. } => format!("{name}/"),
ScriptNode::Script { name, .. } => name.clone(),
};
return Line::from(vec![
Span::styled(
format!(" Delete '{display}'? "),
Style::default()
.fg(theme.error_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"y",
Style::default()
.fg(theme.conn_connected)
.add_modifier(Modifier::BOLD),
),
Span::styled("/", Style::default().fg(theme.dim)),
Span::styled(
"n",
Style::default()
.fg(theme.error_fg)
.add_modifier(Modifier::BOLD),
),
]);
}
}
if let ScriptsMode::Rename { buf, original_path } = &state.scripts_mode {
let node_path = match node {
ScriptNode::Collection { name, .. } => name.as_str(),
ScriptNode::Script { file_path, .. } => file_path.as_str(),
};
if original_path == node_path {
let indent = match node {
ScriptNode::Collection { .. } => " ",
ScriptNode::Script { collection, .. } => {
if collection.is_some() {
" "
} else {
" "
}
}
};
return Line::from(Span::styled(
format!("{indent}{buf}█"),
Style::default()
.fg(theme.conn_connecting)
.add_modifier(Modifier::BOLD),
));
}
}
match node {
ScriptNode::Collection { name, expanded } => {
let icon = if *expanded { "▼" } else { "▶" };
let text = format!(" {icon} {name}/");
let style = if is_selected {
Style::default()
.bg(theme.tree_selected_bg)
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
};
let display_w = UnicodeWidthStr::width(text.as_str());
let padded = if display_w < inner_width {
format!("{}{}", text, " ".repeat(inner_width - display_w))
} else {
text
};
Line::from(Span::styled(padded, style))
}
ScriptNode::Script {
name, collection, ..
} => {
let indent = if collection.is_some() { " " } else { " " };
let text = format!("{indent}{name}");
let style = if is_selected {
Style::default()
.bg(theme.tree_selected_bg)
.fg(theme.tree_selected_fg)
} else {
Style::default()
};
let display_w = UnicodeWidthStr::width(text.as_str());
let padded = if display_w < inner_width {
format!("{}{}", text, " ".repeat(inner_width - display_w))
} else {
text
};
Line::from(Span::styled(padded, style))
}
}
})
.collect();
if let ScriptsMode::Insert { buf } = &state.scripts_mode {
let indent = match state.current_collection() {
Some(_) => " ",
None => " ",
};
lines.push(Line::from(Span::styled(
format!("{indent}> {buf}█"),
Style::default()
.fg(theme.conn_connecting)
.add_modifier(Modifier::BOLD),
)));
}
if state.scripts_yank.is_some() {
let remaining = visible_height.saturating_sub(lines.len());
if remaining > 0 {
lines.push(Line::from(Span::styled(
" [yanked — p to paste]",
Style::default().fg(theme.dim),
)));
}
}
let content = Paragraph::new(lines);
frame.render_widget(content, inner);
}
fn render_confirm_close(frame: &mut Frame, theme: &Theme, area: Rect) {
let width = 44_u16;
let height = 5_u16;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(" Unsaved Changes ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.conn_connecting))
.style(Style::default().bg(theme.dialog_bg));
let text = vec![
Line::from(""),
Line::from(vec![
Span::raw(" Save before closing? "),
Span::styled(
"y",
Style::default()
.fg(theme.conn_connected)
.add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled(
"n",
Style::default()
.fg(theme.error_fg)
.add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled("Esc", Style::default().fg(theme.dim)),
]),
];
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(text).block(block);
frame.render_widget(content, popup);
}
fn render_confirm_quit(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
let unsaved: Vec<String> = state
.tabs
.iter()
.filter(|t| {
t.editor.as_ref().is_some_and(|e| e.modified)
|| t.body_editor.as_ref().is_some_and(|e| e.modified)
|| t.decl_editor.as_ref().is_some_and(|e| e.modified)
})
.map(|t| {
format!(
" {} {} ({})",
t.kind.icon(),
t.kind.display_name(),
t.kind.kind_label()
)
})
.collect();
let list_height = unsaved.len() as u16;
let width = 50_u16;
let height = 3 + list_height + 2 + 1;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(" Unsaved Changes ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.conn_connecting))
.style(Style::default().bg(theme.dialog_bg));
let mut lines = vec![Line::from("")];
for name in &unsaved {
lines.push(Line::from(Span::styled(
name.as_str(),
Style::default().fg(theme.error_fg),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" Quit anyway? "),
Span::styled(
"y",
Style::default()
.fg(theme.conn_connected)
.add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled(
"n",
Style::default()
.fg(theme.error_fg)
.add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled("Esc", Style::default().fg(theme.dim)),
]));
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, popup);
}
fn render_save_grid_confirm(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
use crate::ui::tabs::RowChange;
let tab = match state.active_tab() {
Some(t) => t,
None => return,
};
let modified = tab
.grid_changes
.values()
.filter(|c| matches!(c, RowChange::Modified { .. }))
.count();
let new = tab
.grid_changes
.values()
.filter(|c| matches!(c, RowChange::New { .. }))
.count();
let deleted = tab
.grid_changes
.values()
.filter(|c| matches!(c, RowChange::Deleted))
.count();
let width = 44_u16;
let height = 9_u16;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(" Save Changes ")
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.style(Style::default().bg(theme.dialog_bg));
let mut lines = vec![Line::from("")];
if modified > 0 {
lines.push(Line::from(Span::styled(
format!(" {modified} modified row(s)"),
Style::default().fg(Color::Yellow),
)));
}
if new > 0 {
lines.push(Line::from(Span::styled(
format!(" {new} new row(s)"),
Style::default().fg(Color::Green),
)));
}
if deleted > 0 {
lines.push(Line::from(Span::styled(
format!(" {deleted} deleted row(s)"),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" Save to database? "),
Span::styled(
"y",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled(
"n",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
]));
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, popup);
}
fn render_confirm_drop(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
let action = match &state.sidebar_pending_action {
Some(a) => a,
None => return,
};
let width = 48_u16;
let height = 7_u16;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(" Drop Object ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
.style(Style::default().bg(theme.dialog_bg));
let obj_label = format!(" {} {}.{}", action.obj_type, action.schema, action.name);
let lines = vec![
Line::from(""),
Line::from(Span::styled(
obj_label,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::raw(" Drop? "),
Span::styled(
"y",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled(
"n",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
];
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, popup);
}
fn render_rename_object(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
let action = match &state.sidebar_pending_action {
Some(a) => a,
None => return,
};
let width = 50_u16;
let height = 7_u16;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(format!(" Rename {} ", action.obj_type))
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.style(Style::default().bg(theme.dialog_bg));
let old_label = format!(" {}.{} \u{2192}", action.schema, action.name);
let input_text = format!(" {}\u{2588}", state.sidebar_rename_buf);
let lines = vec![
Line::from(""),
Line::from(Span::styled(old_label, Style::default().fg(theme.dim))),
Line::from(Span::styled(
input_text,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, popup);
}
#[allow(clippy::too_many_arguments)]
fn render_source_with_error(
frame: &mut Frame,
tab: &mut WorkspaceTab,
focused: bool,
theme: &Theme,
area: Rect,
_mode: &Mode,
title: &str,
is_decl: bool,
) {
use crate::ui::tabs::SubFocus;
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let editor_focused = focused && tab.sub_focus == SubFocus::Editor;
let editor = if is_decl {
tab.decl_editor.as_mut()
} else {
tab.body_editor.as_mut()
};
if let Some(editor) = editor {
vimltui::render::render(
frame,
editor,
editor_focused,
&theme.vim_theme(),
&crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme),
splits[0],
title,
);
}
let error_splits = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(splits[1]);
let vt = theme.vim_theme();
let hl = crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme);
let err_focused = focused && tab.sub_focus == SubFocus::Results;
let sql_focused = focused && tab.sub_focus == SubFocus::QueryView;
let err_bright = Color::Rgb(220, 80, 80);
let err_dim = Color::Rgb(120, 50, 50);
let sql_bright = Color::Rgb(200, 180, 60);
let sql_dim = Color::Rgb(100, 90, 30);
if let Some(ref mut err_ed) = tab.grid_error_editor {
vimltui::render::render_with_options(
frame,
err_ed,
err_focused,
&vt,
&hl,
error_splits[0],
"Error",
Some(if err_focused { err_bright } else { err_dim }),
);
}
if let Some(ref mut q_ed) = tab.grid_query_editor {
vimltui::render::render_with_options(
frame,
q_ed,
sql_focused,
&vt,
&hl,
error_splits[1],
"SQL",
Some(if sql_focused { sql_bright } else { sql_dim }),
);
}
}
fn render_confirm_compile(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
let tab = match state.active_tab() {
Some(t) => t,
None => return,
};
let obj_label = match &tab.kind {
crate::ui::tabs::TabKind::Package { schema, name, .. } => {
format!(" PACKAGE {schema}.{name}")
}
crate::ui::tabs::TabKind::Function { schema, name, .. } => {
format!(" FUNCTION {schema}.{name}")
}
crate::ui::tabs::TabKind::Procedure { schema, name, .. } => {
format!(" PROCEDURE {schema}.{name}")
}
_ => return,
};
let has_decl = tab.decl_editor.as_ref().is_some_and(|e| e.modified);
let has_body = tab.body_editor.as_ref().is_some_and(|e| e.modified);
let has_source = tab.editor.as_ref().is_some_and(|e| e.modified);
let width = 48_u16;
let height = 9_u16;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(" Compile to Database ")
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.style(Style::default().bg(theme.dialog_bg));
let mut lines = vec![Line::from("")];
lines.push(Line::from(Span::styled(
obj_label,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
if has_decl {
lines.push(Line::from(Span::styled(
" ✎ Declaration (modified)",
Style::default().fg(Color::Yellow),
)));
}
if has_body {
lines.push(Line::from(Span::styled(
" ✎ Body (modified)",
Style::default().fg(Color::Yellow),
)));
}
if has_source {
lines.push(Line::from(Span::styled(
" ✎ Source (modified)",
Style::default().fg(Color::Yellow),
)));
}
if !has_decl && !has_body && !has_source {
lines.push(Line::from(Span::styled(
" (no changes detected)",
Style::default().fg(theme.dim),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" Compile? "),
Span::styled(
"y",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("/"),
Span::styled(
"n",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
]));
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, popup);
}
fn render_save_script_name(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
let width = 44_u16;
let height = 5_u16;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width.min(area.width), height.min(area.height));
let block = Block::default()
.title(" Save Script As ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.conn_connecting))
.style(Style::default().bg(theme.dialog_bg));
let name_buf = state.scripts_save_name.as_deref().unwrap_or("");
let text = vec![
Line::from(""),
Line::from(vec![
Span::raw(" Name: "),
Span::styled(
format!("{name_buf}█"),
Style::default()
.fg(theme.conn_connecting)
.add_modifier(Modifier::BOLD),
),
]),
];
frame.render_widget(ratatui::widgets::Clear, popup);
let content = Paragraph::new(text).block(block);
frame.render_widget(content, popup);
}
fn render_topbar(frame: &mut Frame, state: &mut AppState, theme: &Theme, area: Rect) {
let (conn_icon, conn_style) = theme.connection_indicator(state.connected);
let conn_name = state.connection_name.as_deref().unwrap_or("not connected");
let db_label = state
.db_type
.as_ref()
.map(|t| t.to_string())
.unwrap_or_default();
let schema = state.current_schema.as_deref().unwrap_or("");
let status_text = if state.connected {
"CONNECTED"
} else {
"DISCONNECTED"
};
let sep = Span::styled(" \u{2502} ", Style::default().fg(theme.separator));
let line = Line::from(vec![
Span::raw(" "),
Span::styled(conn_icon, conn_style),
Span::raw(" "),
Span::styled(
conn_name,
Style::default()
.fg(theme.topbar_fg)
.add_modifier(Modifier::BOLD),
),
sep.clone(),
Span::styled(&db_label, Style::default().fg(theme.accent)),
sep.clone(),
Span::styled(
schema,
Style::default()
.fg(theme.tree_schema)
.add_modifier(Modifier::BOLD),
),
sep,
Span::styled(
status_text,
if state.connected {
Style::default().fg(theme.conn_connected)
} else {
Style::default().fg(theme.conn_disconnected)
},
),
]);
let bar = Paragraph::new(line).style(Style::default().bg(theme.topbar_bg));
frame.render_widget(bar, area);
}
fn render_center(frame: &mut Frame, state: &mut AppState, theme: &Theme, area: Rect) {
fill_bg(frame, area, Style::default().bg(theme.editor_bg));
if state.tabs.is_empty() {
render_empty_workspace(frame, theme, area);
return;
}
let has_sub_views = state
.active_tab()
.map(|t| !t.available_sub_views().is_empty())
.unwrap_or(false);
let constraints = if has_sub_views {
vec![
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(3),
]
} else {
vec![Constraint::Length(1), Constraint::Min(3)]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
render_tab_bar(frame, state, theme, chunks[0]);
let content_area = if has_sub_views {
render_sub_view_bar(frame, state, theme, chunks[1]);
render_tab_content(frame, state, theme, chunks[2]);
chunks[2]
} else {
render_tab_content(frame, state, theme, chunks[1]);
chunks[1]
};
if state.completion.is_some() {
render_completion_popup(frame, state, theme, content_area);
}
if !state.diagnostics.is_empty() {
let is_plsql = state.active_tab().is_some_and(|t| {
matches!(
t.kind,
crate::ui::tabs::TabKind::Package { .. }
| crate::ui::tabs::TabKind::Function { .. }
| crate::ui::tabs::TabKind::Procedure { .. }
| crate::ui::tabs::TabKind::DbType { .. }
| crate::ui::tabs::TabKind::Trigger { .. }
)
});
if !is_plsql {
render_diagnostic_underlines(frame, state, theme, content_area);
}
}
}
fn render_empty_workspace(frame: &mut Frame, theme: &Theme, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_unfocused))
.style(Style::default().bg(theme.editor_bg));
let text = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" No tabs open",
Style::default().fg(theme.dim),
)),
Line::from(""),
Line::from(Span::styled(
" n - New script",
Style::default().fg(theme.dim),
)),
Line::from(Span::styled(
" l - Open selected object",
Style::default().fg(theme.dim),
)),
Line::from(Span::styled(
" a - Add connection",
Style::default().fg(theme.dim),
)),
Line::from(Span::styled(" ? - Help", Style::default().fg(theme.dim))),
])
.block(block);
frame.render_widget(text, area);
}
fn render_tab_bar(frame: &mut Frame, state: &mut AppState, theme: &Theme, area: Rect) {
let mut spans: Vec<Span> = Vec::new();
for (idx, tab) in state.tabs.iter().enumerate() {
let is_active = idx == state.active_tab_idx;
let icon = tab.kind.icon();
let name = tab.kind.display_name();
let conn = tab.kind.conn_name();
let is_modified = tab.editor.as_ref().map(|e| e.modified).unwrap_or(false)
|| tab
.body_editor
.as_ref()
.map(|e| e.modified)
.unwrap_or(false)
|| tab
.decl_editor
.as_ref()
.map(|e| e.modified)
.unwrap_or(false);
let (label, style_override) = match &tab.sync_state {
Some(SyncState::Dirty) => (format!(" {icon} {name}(*) "), None),
Some(SyncState::LocalSaved) => {
(
format!(" {icon} {name}(!) "),
Some(
Style::default()
.fg(theme.conn_connecting) .add_modifier(Modifier::BOLD),
),
)
}
Some(SyncState::ValidationError(_)) => {
(
format!(" {icon} {name}(\u{2717}) "),
Some(
Style::default()
.fg(theme.error_fg) .add_modifier(Modifier::BOLD),
),
)
}
Some(SyncState::Clean) => (format!(" {icon} {name} "), None),
None => {
if is_modified {
(format!(" {icon} {name}(*) "), None)
} else {
(format!(" {icon} {name} "), None)
}
}
};
let tab_style = style_override.unwrap_or_else(|| theme.tab_style(is_active));
spans.push(Span::raw(" "));
spans.push(Span::styled(label, tab_style));
if is_active && let Some(cn) = conn {
spans.push(Span::styled(
format!("[{cn}]"),
Style::default().fg(theme.dim),
));
}
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(theme.separator),
));
}
let available_width = area.width as usize;
let mut tab_positions: Vec<(usize, usize)> = Vec::new(); let mut pos = 0;
let mut span_idx = 0;
for _ in 0..state.tabs.len() {
let start = pos;
while span_idx < spans.len() {
pos += spans[span_idx].width();
span_idx += 1;
if spans[span_idx - 1].content.contains('\u{2502}') {
break;
}
}
tab_positions.push((start, pos));
}
let mut scroll_offset: usize = 0;
if let Some(&(active_start, active_end)) = tab_positions.get(state.active_tab_idx) {
if active_end > scroll_offset + available_width {
scroll_offset = active_end.saturating_sub(available_width);
}
if active_start < scroll_offset {
scroll_offset = active_start;
}
}
let hidden_left = tab_positions
.iter()
.filter(|&&(_, end)| end <= scroll_offset)
.count();
let hidden_right = tab_positions
.iter()
.filter(|&&(start, _)| start >= scroll_offset + available_width)
.count();
let left_indicator = if hidden_left > 0 {
format!("\u{25C0} {hidden_left} ")
} else {
String::new()
};
let right_indicator = if hidden_right > 0 {
format!(" {hidden_right} \u{25B6}")
} else {
String::new()
};
let left_w = left_indicator.len() as u16;
let right_w = right_indicator.len() as u16;
if !left_indicator.is_empty() {
let left_area = Rect {
x: area.x,
y: area.y,
width: left_w.min(area.width),
height: 1,
};
let left = Paragraph::new(left_indicator).style(
Style::default()
.fg(Color::Yellow)
.bg(theme.status_bg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(left, left_area);
}
if !right_indicator.is_empty() {
let right_area = Rect {
x: area.x + area.width.saturating_sub(right_w),
y: area.y,
width: right_w.min(area.width),
height: 1,
};
let right = Paragraph::new(right_indicator).style(
Style::default()
.fg(Color::Yellow)
.bg(theme.status_bg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(right, right_area);
}
let mid_x = area.x + left_w;
let mid_w = area.width.saturating_sub(left_w + right_w);
let mid_area = Rect {
x: mid_x,
y: area.y,
width: mid_w,
height: 1,
};
let line = Line::from(spans);
let bar = Paragraph::new(line)
.style(Style::default().bg(theme.status_bg))
.scroll((0, scroll_offset as u16));
frame.render_widget(bar, mid_area);
}
fn render_sub_view_bar(frame: &mut Frame, state: &mut AppState, theme: &Theme, area: Rect) {
let mut spans: Vec<Span> = Vec::new();
if let Some(tab) = state.active_tab() {
let views = tab.available_sub_views();
for sv in &views {
let is_active = tab.active_sub_view.as_ref() == Some(sv);
let label = format!(" {} ", sv.label());
spans.push(Span::raw(" "));
if is_active {
spans.push(Span::styled(
label,
Style::default()
.fg(theme.tab_active_fg)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
label,
Style::default().fg(theme.tab_inactive_fg),
));
}
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(theme.separator),
));
}
}
let line = Line::from(spans);
let bar = Paragraph::new(line).style(Style::default().bg(theme.status_bg));
frame.render_widget(bar, area);
}
fn render_tab_content(frame: &mut Frame, state: &mut AppState, theme: &Theme, area: Rect) {
let tab_idx = state.active_tab_idx;
if tab_idx >= state.tabs.len() {
return;
}
let focused = state.focus == Focus::TabContent;
let mode = state.mode.clone();
let sub_view = state.tabs[tab_idx].active_sub_view.clone();
let loading_since = state.tabs[tab_idx].streaming_since;
match sub_view {
Some(SubView::TableData) => {
use crate::ui::tabs::SubFocus;
let tab = &mut state.tabs[tab_idx];
let has_error = tab.grid_error_editor.is_some();
if has_error {
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let grid_focused = focused && tab.sub_focus == SubFocus::Editor;
widgets::data_grid::render_for_tab(
frame,
tab,
grid_focused,
theme,
splits[0],
&mode,
);
let error_splits = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(splits[1]);
let vt = theme.vim_theme();
let hl = crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme);
let err_focused = focused && tab.sub_focus == SubFocus::Results;
let sql_focused = focused && tab.sub_focus == SubFocus::QueryView;
let err_bright = Color::Rgb(220, 80, 80);
let err_dim = Color::Rgb(120, 50, 50);
let sql_bright = Color::Rgb(200, 180, 60);
let sql_dim = Color::Rgb(100, 90, 30);
if let Some(ref mut err_ed) = tab.grid_error_editor {
vimltui::render::render_with_options(
frame,
err_ed,
err_focused,
&vt,
&hl,
error_splits[0],
"Error",
Some(if err_focused { err_bright } else { err_dim }),
);
}
if let Some(ref mut q_ed) = tab.grid_query_editor {
vimltui::render::render_with_options(
frame,
q_ed,
sql_focused,
&vt,
&hl,
error_splits[1],
"SQL",
Some(if sql_focused { sql_bright } else { sql_dim }),
);
}
} else {
widgets::data_grid::render_for_tab(frame, tab, focused, theme, area, &mode);
}
}
Some(SubView::TableProperties) => {
let tab = &state.tabs[tab_idx];
widgets::properties::render_for_tab(frame, tab, focused, theme, area, &mode);
}
Some(SubView::TableDDL) => {
let tab = &mut state.tabs[tab_idx];
if let Some(editor) = tab.ddl_editor.as_mut() {
crate::ui::loading::render_editor_or_loading(
frame,
editor,
focused,
theme,
area,
"DDL",
loading_since,
);
} else {
crate::ui::loading::render_loading(frame, theme, area, "DDL", loading_since);
}
}
Some(SubView::PackageDeclaration)
| Some(SubView::TypeDeclaration)
| Some(SubView::TriggerDeclaration) => {
let tab = &mut state.tabs[tab_idx];
let has_error = tab.grid_error_editor.is_some();
if has_error {
render_source_with_error(
frame,
tab,
focused,
theme,
area,
&mode,
"Declaration",
true,
);
} else if let Some(editor) = tab.decl_editor.as_mut() {
crate::ui::loading::render_editor_or_loading(
frame,
editor,
focused,
theme,
area,
"Declaration",
loading_since,
);
} else {
crate::ui::loading::render_loading(
frame,
theme,
area,
"Declaration",
loading_since,
);
}
}
Some(SubView::PackageBody) | Some(SubView::TypeBody) => {
let tab = &mut state.tabs[tab_idx];
let has_error = tab.grid_error_editor.is_some();
if has_error {
render_source_with_error(frame, tab, focused, theme, area, &mode, "Body", false);
} else if let Some(editor) = tab.body_editor.as_mut() {
crate::ui::loading::render_editor_or_loading(
frame,
editor,
focused,
theme,
area,
"Body",
loading_since,
);
} else {
crate::ui::loading::render_loading(frame, theme, area, "Body", loading_since);
}
}
Some(SubView::PackageFunctions) => {
render_package_list(frame, state, theme, area, focused, true);
}
Some(SubView::PackageProcedures) => {
render_package_list(frame, state, theme, area, focused, false);
}
Some(SubView::TypeAttributes)
| Some(SubView::TypeMethods)
| Some(SubView::TriggerColumns) => {
let tab = &mut state.tabs[tab_idx];
widgets::data_grid::render_for_tab(frame, tab, focused, theme, area, &mode);
}
None => {
let tab = &mut state.tabs[tab_idx];
let title = tab.kind.display_name().to_string();
let is_source = matches!(
tab.kind,
crate::ui::tabs::TabKind::Function { .. }
| crate::ui::tabs::TabKind::Procedure { .. }
);
let has_results = tab.query_result.is_some();
let has_result_tabs = !tab.result_tabs.is_empty();
if has_results || has_result_tabs {
render_script_with_results(frame, tab, focused, theme, area, &mode, &title);
} else if let Some(editor) = tab.editor.as_mut() {
if is_source {
crate::ui::loading::render_editor_or_loading(
frame,
editor,
focused,
theme,
area,
&title,
loading_since,
);
} else {
vimltui::render::render(
frame,
editor,
focused,
&theme.vim_theme(),
&crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme),
area,
&title,
);
}
} else {
crate::ui::loading::render_loading(frame, theme, area, &title, loading_since);
}
}
}
}
fn render_completion_popup(frame: &mut Frame, state: &AppState, theme: &Theme, editor_area: Rect) {
let cmp = match &state.completion {
Some(c) if !c.items.is_empty() => c,
_ => return,
};
let tab = match state.tabs.get(state.active_tab_idx) {
Some(t) => t,
None => return,
};
let editor = match tab.active_editor() {
Some(e) => e,
None => return,
};
let line_count_width = format!("{}", editor.lines.len()).len().max(3);
let num_col_width = line_count_width + 2;
let cursor_screen_row = editor.cursor_row.saturating_sub(editor.scroll_offset);
let popup_x = editor_area.x + 1 + num_col_width as u16 + cmp.origin_col as u16;
let popup_y = editor_area.y + 2 + cursor_screen_row as u16;
let max_visible = 4_u16;
let item_count = cmp.items.len() as u16;
let has_more = item_count > max_visible;
let visible_rows = item_count.min(max_visible);
let height = visible_rows + if has_more { 1 } else { 0 } + 2;
let max_label = cmp
.items
.iter()
.map(|i| i.label.len() + i.kind.tag().len() + 3) .max()
.unwrap_or(10) as u16;
let width = (max_label + 2).min(40);
let x = popup_x.min(editor_area.right().saturating_sub(width));
let available_below = editor_area.bottom().saturating_sub(popup_y);
let (y, h) = if available_below >= height {
(popup_y, height)
} else {
let above_y = (editor_area.y + 1 + cursor_screen_row as u16).saturating_sub(height);
(above_y, height)
};
let popup_rect = Rect::new(x, y, width, h);
frame.render_widget(ratatui::widgets::Clear, popup_rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_focused))
.style(Style::default().bg(theme.dialog_bg));
let inner = block.inner(popup_rect);
frame.render_widget(block, popup_rect);
let visible_count = max_visible as usize;
let scroll = if cmp.cursor >= visible_count {
cmp.cursor - visible_count + 1
} else {
0
};
for (i, item) in cmp
.items
.iter()
.enumerate()
.skip(scroll)
.take(visible_count)
{
let row_y = inner.y + (i - scroll) as u16;
let is_selected = i == cmp.cursor;
let tag = item.kind.tag();
let tag_width = tag.len();
let label_max = inner.width as usize - tag_width - 2;
let label = if item.label.len() > label_max {
&item.label[..label_max]
} else {
&item.label
};
let padding = inner.width as usize - label.len() - tag_width - 1;
let (bg, fg) = if is_selected {
(theme.border_focused, theme.dialog_bg)
} else {
(theme.dialog_bg, theme.status_fg)
};
let tag_fg = if is_selected {
theme.dialog_bg
} else {
theme.dim
};
let line = ratatui::text::Line::from(vec![
Span::styled(
format!(" {label}{:>pad$}", "", pad = padding),
Style::default().fg(fg).bg(bg),
),
Span::styled(format!("{tag} "), Style::default().fg(tag_fg).bg(bg)),
]);
let row_rect = Rect::new(inner.x, row_y, inner.width, 1);
frame.render_widget(Paragraph::new(line), row_rect);
}
if has_more {
let more_y = inner.y + visible_rows;
if more_y < inner.y + inner.height {
let remaining = cmp.items.len().saturating_sub(scroll + visible_count);
let more_text = if remaining > 0 {
format!(" ... +{remaining} more")
} else {
" ...".to_string()
};
let more_line = ratatui::text::Line::from(Span::styled(
more_text,
Style::default().fg(theme.dim).bg(theme.dialog_bg),
));
let more_rect = Rect::new(inner.x, more_y, inner.width, 1);
frame.render_widget(Paragraph::new(more_line), more_rect);
}
}
}
fn render_bind_variables(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
let bv = match &state.bind_variables {
Some(b) => b,
None => return,
};
let var_count = bv.variables.len();
let width = 50_u16.min(area.width.saturating_sub(4));
let height = (3 + var_count as u16 + 2).min(area.height.saturating_sub(2));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
let block = Block::default()
.title(" Bind Variables ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.conn_connecting))
.style(Style::default().bg(theme.dialog_bg));
frame.render_widget(ratatui::widgets::Clear, popup);
let inner = block.inner(popup);
frame.render_widget(block, popup);
for (i, (name, value)) in bv.variables.iter().enumerate() {
if i as u16 >= inner.height.saturating_sub(1) {
break;
}
let row_y = inner.y + i as u16;
let is_selected = i == bv.selected_idx;
let label = format!(":{name}");
let display_val = if is_selected {
format!("{value}\u{2588}") } else {
value.clone()
};
let label_style = if is_selected {
Style::default()
.fg(theme.conn_connecting)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.dim)
};
let val_style = if is_selected {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.status_fg)
};
let line = Line::from(vec![
Span::styled(format!(" {label:<16}"), label_style),
Span::styled(display_val, val_style),
]);
let row_rect = Rect::new(inner.x, row_y, inner.width, 1);
frame.render_widget(Paragraph::new(line), row_rect);
}
let hint_y = inner.y + inner.height.saturating_sub(1);
let hint = Line::from(vec![
Span::styled(" Tab", Style::default().fg(theme.accent)),
Span::styled(" next ", Style::default().fg(theme.dim)),
Span::styled("Enter", Style::default().fg(theme.conn_connected)),
Span::styled(" execute ", Style::default().fg(theme.dim)),
Span::styled("Esc", Style::default().fg(theme.error_fg)),
Span::styled(" cancel", Style::default().fg(theme.dim)),
]);
let hint_rect = Rect::new(inner.x, hint_y, inner.width, 1);
frame.render_widget(Paragraph::new(hint), hint_rect);
}
fn render_diagnostic_underlines(
frame: &mut Frame,
state: &AppState,
theme: &Theme,
editor_area: Rect,
) {
let tab = match state.tabs.get(state.active_tab_idx) {
Some(t) => t,
None => return,
};
let editor = match tab.active_editor() {
Some(e) => e,
None => return,
};
let has_results = !tab.result_tabs.is_empty() || tab.query_result.is_some();
let actual_editor_area = if has_results {
Rect::new(
editor_area.x,
editor_area.y,
editor_area.width,
(editor_area.height * 60) / 100,
)
} else {
editor_area
};
let line_count_width = format!("{}", editor.lines.len()).len().max(3);
let num_col_width = (line_count_width + 2) as u16;
let inner_x = actual_editor_area.x + 1 + num_col_width;
let inner_y = actual_editor_area.y + 1; let inner_height = actual_editor_area.height.saturating_sub(3) as usize;
for diag in &state.diagnostics {
if diag.row < editor.scroll_offset || diag.row >= editor.scroll_offset + inner_height {
continue;
}
let screen_row = inner_y + (diag.row - editor.scroll_offset) as u16;
let col_start = diag.col_start as u16;
let col_len = (diag.col_end - diag.col_start).max(1) as u16;
let screen_x = inner_x + col_start;
if screen_x >= actual_editor_area.right()
|| screen_row >= actual_editor_area.bottom().saturating_sub(2)
{
continue;
}
let available = actual_editor_area.right().saturating_sub(screen_x);
let width = col_len.min(available);
let underline_rect = Rect::new(screen_x, screen_row, width, 1);
let Some(line) = editor.lines.get(diag.row) else {
continue;
};
let start = diag.col_start.min(line.len());
let end = diag.col_end.min(line.len());
let text = if start < end { &line[start..end] } else { " " };
let styled = Paragraph::new(Span::styled(
text,
Style::default()
.fg(theme.error_fg)
.add_modifier(Modifier::UNDERLINED),
));
frame.render_widget(styled, underline_rect);
}
}
fn render_script_with_results(
frame: &mut Frame,
tab: &mut WorkspaceTab,
focused: bool,
theme: &Theme,
area: Rect,
mode: &Mode,
title: &str,
) {
let has_result_tabs = !tab.result_tabs.is_empty();
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let sf = tab.sub_focus;
if let Some(editor) = tab.editor.as_mut() {
let editor_focused = focused && sf == crate::ui::tabs::SubFocus::Editor;
vimltui::render::render(
frame,
editor,
editor_focused,
&theme.vim_theme(),
&crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme),
splits[0],
title,
);
}
if has_result_tabs {
let result_area = splits[1];
let result_splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(3)])
.split(result_area);
render_result_tab_bar(frame, tab, theme, result_splits[0]);
let idx = tab.active_result_idx;
let is_error = idx < tab.result_tabs.len() && tab.result_tabs[idx].error_editor.is_some();
if is_error {
use ratatui::style::Color;
let err_area = result_splits[1];
let err_focused = focused && sf == crate::ui::tabs::SubFocus::Results;
let q_focused = focused && sf == crate::ui::tabs::SubFocus::QueryView;
let red_bright = Color::Rgb(220, 80, 80);
let red_dim = Color::Rgb(120, 50, 50);
let err_border = if err_focused { red_bright } else { red_dim };
let q_border = if q_focused { red_bright } else { red_dim };
let has_query = tab.result_tabs[idx].query_editor.is_some();
if has_query {
let err_splits = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(err_area);
let vt = theme.vim_theme();
let hl = crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme);
if let Some(err_editor) = tab.result_tabs[idx].error_editor.as_mut() {
vimltui::render::render_with_options(
frame,
err_editor,
err_focused,
&vt,
&hl,
err_splits[0],
"Error",
Some(err_border),
);
}
if let Some(q_editor) = tab.result_tabs[idx].query_editor.as_mut() {
vimltui::render::render_with_options(
frame,
q_editor,
q_focused,
&vt,
&hl,
err_splits[1],
"Query",
Some(q_border),
);
}
} else if let Some(err_editor) = tab.result_tabs[idx].error_editor.as_mut() {
vimltui::render::render_with_options(
frame,
err_editor,
err_focused,
&theme.vim_theme(),
&crate::ui::sql_highlighter::SqlHighlighter::from_theme(theme),
err_area,
"Error",
Some(err_border),
);
}
} else {
if idx < tab.result_tabs.len() {
let rt = &tab.result_tabs[idx];
tab.query_result = Some(rt.result.clone());
tab.grid_scroll_row = rt.scroll_row;
tab.grid_selected_row = rt.selected_row;
tab.grid_selected_col = rt.selected_col;
tab.grid_visible_height = rt.visible_height;
tab.grid_selection_anchor = rt.selection_anchor;
}
widgets::data_grid::render_for_tab(frame, tab, focused, theme, result_splits[1], mode);
if idx < tab.result_tabs.len() {
tab.result_tabs[idx].visible_height = tab.grid_visible_height;
}
}
} else {
widgets::data_grid::render_for_tab(frame, tab, focused, theme, splits[1], mode);
}
}
fn render_theme_picker(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
use crate::ui::theme::THEME_NAMES;
let count = THEME_NAMES.len();
let height = (count as u16 + 2).min(area.height);
let width = 30_u16.min(area.width);
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width, height);
frame.render_widget(ratatui::widgets::Clear, popup);
let block = Block::default()
.title(" Theme ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.dialog_bg));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let items: Vec<ratatui::widgets::ListItem> = THEME_NAMES
.iter()
.map(|name| {
let is_current = theme.name == *name;
let icon = if is_current { "● " } else { " " };
ratatui::widgets::ListItem::new(Line::from(vec![
Span::styled(
icon,
Style::default().fg(if is_current {
theme.conn_connected
} else {
theme.dim
}),
),
Span::styled(*name, Style::default().fg(theme.topbar_fg)),
]))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(state.theme_picker.cursor));
let list = ratatui::widgets::List::new(items)
.highlight_style(
Style::default()
.bg(theme.tree_selected_bg)
.fg(theme.tree_selected_fg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▸ ");
frame.render_stateful_widget(list, inner, &mut list_state);
}
fn render_result_tab_bar(
frame: &mut Frame,
tab: &crate::ui::tabs::WorkspaceTab,
theme: &Theme,
area: Rect,
) {
let mut spans: Vec<Span> = Vec::new();
for (idx, rt) in tab.result_tabs.iter().enumerate() {
let is_active = idx == tab.active_result_idx;
let time_str = rt
.result
.elapsed
.map(|d| {
let ms = d.as_millis();
if ms < 1000 {
format!(" {ms}ms")
} else {
format!(" {:.2}s", d.as_secs_f64())
}
})
.unwrap_or_default();
let label = format!(" {} ({}){time_str} ", rt.label, rt.result.rows.len());
let style = if is_active {
Style::default()
.fg(theme.tab_active_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.tab_inactive_fg)
};
spans.push(Span::raw(" "));
spans.push(Span::styled(label, style));
spans.push(Span::styled(
"\u{2502}",
Style::default().fg(theme.separator),
));
}
let line = Line::from(spans);
let bar = Paragraph::new(line).style(Style::default().bg(theme.status_bg));
frame.render_widget(bar, area);
}
fn render_package_list(
frame: &mut Frame,
state: &mut AppState,
theme: &Theme,
area: Rect,
focused: bool,
is_functions: bool,
) {
let tab_idx = state.active_tab_idx;
if tab_idx >= state.tabs.len() {
return;
}
let tab = &state.tabs[tab_idx];
let title = if is_functions {
" Functions "
} else {
" Procedures "
};
let items = if is_functions {
&tab.package_functions
} else {
&tab.package_procedures
};
let border_style = theme.border_style(focused, &state.mode);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style)
.style(Style::default().bg(theme.editor_bg));
if items.is_empty() {
let empty_msg = if is_functions {
"(no functions)"
} else {
"(no procedures)"
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(
format!(" {empty_msg}"),
Style::default().fg(theme.dim),
)),
];
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, area);
return;
}
let visible_height = area.height.saturating_sub(2) as usize;
let offset = if tab.package_list_cursor >= visible_height {
tab.package_list_cursor - visible_height + 1
} else {
0
};
let inner_width = area.width.saturating_sub(2) as usize;
let lines: Vec<Line> = items
.iter()
.enumerate()
.skip(offset)
.take(visible_height)
.map(|(i, name)| {
let icon = if is_functions { "λ" } else { "ƒ" };
let style = if i == tab.package_list_cursor {
Style::default()
.bg(theme.tree_selected_bg)
.fg(theme.tree_selected_fg)
} else {
Style::default()
};
let text = format!(" {icon} {name}");
let display_w = UnicodeWidthStr::width(text.as_str());
let padded = if display_w < inner_width {
format!("{}{}", text, " ".repeat(inner_width - display_w))
} else {
text
};
Line::from(Span::styled(padded, style))
})
.collect();
let content = Paragraph::new(lines).block(block);
frame.render_widget(content, area);
}