sabiql 1.11.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

use crate::app::model::app_state::AppState;
use crate::app::model::browse::jsonb_detail::JsonbDetailMode;
use crate::app::model::shared::flash_timer::FlashId;
use crate::app::model::shared::text_input::TextInputLike;
use crate::ui::primitives::atoms::{
    CursorKind, ModalTextSurface, build_modal_text_surface_lines, render_modal_text_surface,
    set_terminal_cursor, text_cursor_spans_with_kind,
};
use crate::ui::primitives::molecules::render_modal;
use crate::ui::theme::ThemePalette;

pub struct JsonbDetailRenderMetrics {
    pub editor_visible_rows: usize,
}
pub struct JsonbDetail;

impl JsonbDetail {
    pub fn render(
        frame: &mut Frame,
        state: &AppState,
        now: std::time::Instant,
        theme: &ThemePalette,
    ) -> Option<JsonbDetailRenderMetrics> {
        if !state.jsonb_detail.is_active() {
            return None;
        }

        let is_editing = matches!(state.jsonb_detail.mode(), JsonbDetailMode::Editing);
        let title = if is_editing {
            format!(
                " JSONB Edit \u{2500}\u{2500} {} (jsonb) ",
                state.jsonb_detail.column_name()
            )
        } else {
            format!(
                " JSONB Detail \u{2500}\u{2500} {}",
                state.jsonb_detail.column_name()
            )
        };
        let hint = if is_editing {
            " Esc:Normal "
        } else {
            " y:Copy  /:Search  i:Insert  Esc:Close "
        };

        let (_area, inner) = render_modal(
            frame,
            Constraint::Percentage(80),
            Constraint::Percentage(70),
            &title,
            hint,
            theme,
        );

        let (editor_area, status_area, search_area) = if state.jsonb_detail.search().active {
            let [editor_area, status_area, search_area] = Layout::vertical([
                Constraint::Min(1),
                Constraint::Length(1),
                Constraint::Length(1),
            ])
            .areas(inner);
            (editor_area, status_area, Some(search_area))
        } else {
            let [editor_area, status_area] =
                Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
            (editor_area, status_area, None)
        };

        Self::render_editor_content(frame, editor_area, state, is_editing, now, theme);
        Self::render_status(frame, status_area, state, theme);
        if let Some(search_area) = search_area {
            Self::render_search(frame, search_area, state, theme);
        }

        Some(JsonbDetailRenderMetrics {
            editor_visible_rows: editor_area.height as usize,
        })
    }

    fn render_editor_content(
        frame: &mut Frame,
        area: Rect,
        state: &AppState,
        is_editing: bool,
        now: std::time::Instant,
        theme: &ThemePalette,
    ) {
        let editor = state.jsonb_detail.editor();
        let content = editor.content();
        let (cursor_row, cursor_col) = editor.cursor_to_position();
        let cursor_kind = if is_editing {
            CursorKind::Insert
        } else {
            CursorKind::Block
        };
        let surface = ModalTextSurface {
            content,
            cursor_row,
            cursor_col,
            scroll_row: editor.scroll_row(),
            cursor_kind,
            empty_placeholder: if is_editing {
                " Enter JSON..."
            } else {
                " Press i to edit..."
            },
            base_style: Style::default().fg(theme.semantic.text.primary),
            current_line_style: Style::default().bg(theme.component.editor.current_line_bg),
        };

        let line_spans: Vec<Vec<Span<'static>>> = content
            .lines()
            .map(|line| vec![Span::raw(line.to_owned())])
            .collect();
        let mut lines = build_modal_text_surface_lines(surface, line_spans, theme);

        let flash_active = state.flash_timers.is_active(FlashId::JsonbDetail, now);
        crate::ui::primitives::atoms::apply_yank_flash(&mut lines, flash_active, theme);

        render_modal_text_surface(frame, area, surface, lines);
    }

    fn render_status(frame: &mut Frame, area: Rect, state: &AppState, theme: &ThemePalette) {
        let mut spans = Vec::new();

        if state.jsonb_detail.has_pending_changes() {
            spans.push(Span::styled(
                "\u{25cf} Modified  ",
                Style::default().fg(theme.semantic.status.pending),
            ));
        }

        if let Some(err) = state.jsonb_detail.validation_error() {
            spans.push(Span::styled(
                format!("\u{2717} {err}"),
                Style::default().fg(theme.semantic.status.error),
            ));
        } else {
            spans.push(Span::styled(
                "\u{2713} Valid JSON",
                Style::default().fg(theme.semantic.status.success),
            ));
        }

        frame.render_widget(Paragraph::new(Line::from(spans)), area);
    }

    fn render_search(frame: &mut Frame, area: Rect, state: &AppState, theme: &ThemePalette) {
        let search = state.jsonb_detail.search();
        let input = search.input.content();
        let cursor = search.input.cursor();
        let match_info = if search.matches.is_empty() {
            "0/0".to_string()
        } else {
            format!("{}/{}", search.current_match + 1, search.matches.len())
        };
        let suffix = format!("  {match_info}");
        let visible_width = area
            .width
            .saturating_sub((1 + UnicodeWidthStr::width(suffix.as_str())) as u16)
            as usize;
        let viewport_offset = search_viewport_offset(input, cursor, visible_width);
        let visible_input = slice_chars_fitting_width(input, viewport_offset, visible_width);
        let relative_cursor = cursor.saturating_sub(viewport_offset);

        let mut spans = vec![Span::styled(
            "/",
            Style::default().fg(theme.semantic.text.accent),
        )];
        spans.extend(text_cursor_spans_with_kind(
            &visible_input,
            relative_cursor,
            0,
            visible_input.chars().count(),
            CursorKind::Insert,
            theme,
        ));
        spans.push(Span::styled(
            suffix,
            Style::default().fg(theme.semantic.text.muted),
        ));

        frame.render_widget(Paragraph::new(Line::from(spans)), area);
        set_terminal_cursor(frame, area, &visible_input, 0, relative_cursor, 0, 1);
    }
}

fn search_viewport_offset(input: &str, cursor: usize, visible_width: usize) -> usize {
    if visible_width == 0 {
        return cursor;
    }

    let chars: Vec<char> = input.chars().collect();
    let mut viewport_offset = 0;
    let mut width_before_cursor = display_width(&chars[..cursor.min(chars.len())]);

    while width_before_cursor >= visible_width && viewport_offset < cursor {
        width_before_cursor =
            width_before_cursor.saturating_sub(char_width(chars[viewport_offset]));
        viewport_offset += 1;
    }

    viewport_offset
}

fn slice_chars_fitting_width(input: &str, start: usize, visible_width: usize) -> String {
    if visible_width == 0 {
        return String::new();
    }

    let mut width = 0;
    let mut visible = String::new();

    for ch in input.chars().skip(start) {
        let ch_width = char_width(ch);
        if width + ch_width > visible_width {
            break;
        }
        width += ch_width;
        visible.push(ch);
    }

    visible
}

fn display_width(chars: &[char]) -> usize {
    chars.iter().map(|&ch| char_width(ch)).sum()
}

fn char_width(ch: char) -> usize {
    UnicodeWidthChar::width(ch).unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::slice_chars_fitting_width;

    #[test]
    fn slice_chars_fitting_width_omits_first_wide_char_when_viewport_is_too_narrow() {
        assert_eq!(slice_chars_fitting_width("界a", 0, 1), "");
    }

    #[test]
    fn slice_chars_fitting_width_keeps_chars_that_fit_exactly() {
        assert_eq!(slice_chars_fitting_width("ab", 0, 2), "ab");
    }
}