use ratatui::Frame;
use ratatui::crossterm::event::MouseEventKind;
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use ratatui::widgets::{Block, Borders};
use crate::components::Component;
use crate::components::backlinks_panel::QueryPanel;
use crate::components::event_state::EventState;
use crate::components::events::{AppTx, InputEvent};
use crate::components::panel::PanelKind;
use crate::components::sidebar::SidebarComponent;
use crate::components::text_editor::TextEditorComponent;
use crate::settings::themes::Theme;
struct Slot {
kind: PanelKind,
visible: bool,
}
pub struct PanelOrder {
slots: Vec<Slot>,
focus: usize,
}
impl PanelOrder {
pub fn new() -> Self {
let slots = vec![
Slot {
kind: PanelKind::Sidebar,
visible: true,
},
Slot {
kind: PanelKind::Editor,
visible: true,
},
Slot {
kind: PanelKind::Query,
visible: false,
},
];
let focus = slots
.iter()
.position(|s| s.kind == PanelKind::Editor)
.expect("editor slot present");
Self { slots, focus }
}
pub fn focused(&self) -> PanelKind {
self.slots[self.focus].kind
}
pub fn prev_kind(&self) -> Option<PanelKind> {
self.focus.checked_sub(1).map(|i| self.slots[i].kind)
}
pub fn next_kind(&self) -> Option<PanelKind> {
self.slots.get(self.focus + 1).map(|s| s.kind)
}
pub fn focus(&mut self, kind: PanelKind) {
if let Some(i) = self.slots.iter().position(|s| s.kind == kind) {
self.focus = i;
}
}
pub fn is_visible(&self, kind: PanelKind) -> bool {
self.slots
.iter()
.find(|s| s.kind == kind)
.is_some_and(|s| s.visible)
}
pub fn show(&mut self, kind: PanelKind) {
if let Some(s) = self.slots.iter_mut().find(|s| s.kind == kind) {
s.visible = true;
}
}
pub fn hide(&mut self, kind: PanelKind) {
if kind == PanelKind::Editor {
return;
}
if let Some(s) = self.slots.iter_mut().find(|s| s.kind == kind) {
s.visible = false;
}
if !self.slots[self.focus].visible {
self.focus = self.nearest_visible(self.focus);
}
}
pub fn visible_in_order(&self) -> Vec<PanelKind> {
self.slots
.iter()
.filter(|s| s.visible)
.map(|s| s.kind)
.collect()
}
pub fn set_order(&mut self, order: &[PanelKind]) {
let focused_kind = self.focused();
let mut new: Vec<Slot> = Vec::with_capacity(self.slots.len());
for &k in order {
if let Some(pos) = self.slots.iter().position(|s| s.kind == k) {
new.push(self.slots.remove(pos));
}
}
new.append(&mut self.slots);
self.slots = new;
self.focus = self
.slots
.iter()
.position(|s| s.kind == focused_kind)
.unwrap_or(0);
}
fn nearest_visible(&self, from: usize) -> usize {
let n = self.slots.len();
(1..n)
.flat_map(|d| [from.checked_sub(d), Some(from + d).filter(|&i| i < n)])
.flatten()
.find(|&i| self.slots[i].visible)
.unwrap_or(from)
}
}
impl Default for PanelOrder {
fn default() -> Self {
Self::new()
}
}
fn panel_column(kind: PanelKind) -> Constraint {
match kind {
PanelKind::Sidebar => Constraint::Length(30),
PanelKind::Editor => Constraint::Min(0),
PanelKind::Query => Constraint::Length(40),
}
}
fn layout_columns(visible: &[PanelKind], area: Rect) -> Vec<(PanelKind, Rect)> {
let constraints: Vec<Constraint> = visible.iter().map(|k| panel_column(*k)).collect();
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
visible
.iter()
.copied()
.zip(columns.iter().copied())
.collect()
}
fn kind_at(columns: &[(PanelKind, Rect)], column: u16, row: u16) -> Option<PanelKind> {
columns
.iter()
.find(|(_, rect)| rect.contains(Position::new(column, row)))
.map(|(kind, _)| *kind)
}
pub struct PanelSet {
order: PanelOrder,
sidebar: SidebarComponent,
editor: TextEditorComponent,
query: QueryPanel,
column_rects: Vec<(PanelKind, Rect)>,
}
impl PanelSet {
pub fn from_panels(
sidebar: SidebarComponent,
editor: TextEditorComponent,
query: QueryPanel,
) -> Self {
Self {
order: PanelOrder::new(),
sidebar,
editor,
query,
column_rects: Vec::new(),
}
}
pub fn focused(&self) -> PanelKind {
self.order.focused()
}
pub fn focused_label(&self) -> &'static str {
self.order.focused().label()
}
pub fn prev_kind(&self) -> Option<PanelKind> {
self.order.prev_kind()
}
pub fn next_kind(&self) -> Option<PanelKind> {
self.order.next_kind()
}
pub fn is_visible(&self, kind: PanelKind) -> bool {
self.order.is_visible(kind)
}
pub fn show(&mut self, kind: PanelKind) {
self.order.show(kind);
}
pub fn hide(&mut self, kind: PanelKind) {
self.order.hide(kind);
}
pub fn set_order(&mut self, order: &[PanelKind]) {
self.order.set_order(order);
}
pub fn focus(&mut self, kind: PanelKind) {
if kind != PanelKind::Editor {
self.editor.close_autocomplete();
}
self.order.focus(kind);
}
pub fn sidebar(&self) -> &SidebarComponent {
&self.sidebar
}
pub fn sidebar_mut(&mut self) -> &mut SidebarComponent {
&mut self.sidebar
}
pub fn editor(&self) -> &TextEditorComponent {
&self.editor
}
pub fn editor_mut(&mut self) -> &mut TextEditorComponent {
&mut self.editor
}
pub fn query(&self) -> &QueryPanel {
&self.query
}
pub fn query_mut(&mut self) -> &mut QueryPanel {
&mut self.query
}
pub fn focused_hints(&self) -> Vec<(String, String)> {
match self.order.focused() {
PanelKind::Sidebar => self.sidebar.hint_shortcuts(),
PanelKind::Editor => self.editor.hint_shortcuts(),
PanelKind::Query => self.query.hint_shortcuts(),
}
}
pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
match self.order.focused() {
PanelKind::Sidebar => self.sidebar.handle_input(event, tx),
PanelKind::Editor => self.editor.handle_input(event, tx),
PanelKind::Query => {
if let InputEvent::Key(key) = event {
self.query.handle_key(key, tx)
} else {
EventState::NotConsumed
}
}
}
}
pub fn handle_mouse(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
let InputEvent::Mouse(mouse) = event else {
return EventState::NotConsumed;
};
let Some(kind) = kind_at(&self.column_rects, mouse.column, mouse.row) else {
return EventState::NotConsumed;
};
if matches!(mouse.kind, MouseEventKind::Down(_)) {
self.focus(kind);
}
match kind {
PanelKind::Sidebar => {
self.sidebar.handle_input(event, tx);
}
PanelKind::Editor => {
self.editor.handle_input(event, tx);
}
PanelKind::Query => {
self.query.handle_mouse(mouse, tx);
}
}
EventState::Consumed
}
pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, show_focus: bool) {
let visible = self.order.visible_in_order();
if visible.is_empty() {
return;
}
let columns = layout_columns(&visible, area);
self.column_rects = columns.clone();
let focused = self.order.focused();
for (kind, rect) in &columns {
let is_focused = show_focus && *kind == focused;
let rect = *rect;
match kind {
PanelKind::Sidebar => self.sidebar.render(f, rect, theme, is_focused),
PanelKind::Query => self.query.render(f, rect, theme, is_focused),
PanelKind::Editor => {
let title = if self.editor.is_dirty() {
"Editor [+]"
} else {
"Editor"
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(theme.border_style(is_focused))
.style(theme.base_style());
let inner = block.inner(rect);
f.render_widget(block, rect);
self.editor.render(f, inner, theme, is_focused);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::AppSettings;
use crate::test_support::{mouse_down_at, temp_vault};
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
use tokio::sync::mpsc::unbounded_channel;
async fn make_panel_set() -> PanelSet {
let vault = temp_vault("panelset").await;
vault.validate_and_init().await.unwrap();
let settings = AppSettings::default();
let sidebar = SidebarComponent::new(
settings.key_bindings.clone(),
vault.clone(),
settings.icons(),
&settings,
);
let editor = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
let query = QueryPanel::new(vault, settings.key_bindings.clone());
PanelSet::from_panels(sidebar, editor, query)
}
fn lay_out(panels: &mut PanelSet) {
panels.column_rects =
layout_columns(&panels.order.visible_in_order(), Rect::new(0, 0, 120, 40));
}
fn scroll_at(col: u16, row: u16) -> InputEvent {
InputEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
#[tokio::test]
async fn click_focuses_panel_under_cursor() {
let mut panels = make_panel_set().await;
panels.show(PanelKind::Query);
lay_out(&mut panels);
let (tx, _rx) = unbounded_channel();
assert_eq!(panels.focused(), PanelKind::Editor);
assert_eq!(
panels.handle_mouse(&mouse_down_at(90, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Query);
assert_eq!(
panels.handle_mouse(&mouse_down_at(50, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Editor);
assert_eq!(
panels.handle_mouse(&mouse_down_at(5, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Sidebar);
}
#[tokio::test]
async fn click_outside_panels_changes_nothing() {
let mut panels = make_panel_set().await;
let (tx, _rx) = unbounded_channel();
assert_eq!(
panels.handle_mouse(&mouse_down_at(10, 10), &tx),
EventState::NotConsumed
);
assert_eq!(panels.focused(), PanelKind::Editor);
lay_out(&mut panels);
assert_eq!(
panels.handle_mouse(&mouse_down_at(10, 50), &tx),
EventState::NotConsumed
);
assert_eq!(panels.focused(), PanelKind::Editor);
}
#[tokio::test]
async fn scroll_does_not_change_focus() {
let mut panels = make_panel_set().await;
lay_out(&mut panels);
let (tx, _rx) = unbounded_channel();
assert_eq!(
panels.handle_mouse(&scroll_at(5, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Editor);
}
#[test]
fn default_focus_is_editor() {
let order = PanelOrder::new();
assert_eq!(order.focused(), PanelKind::Editor);
}
#[test]
fn adjacent_kinds_follow_order_and_clamp_at_ends() {
let order = PanelOrder::new();
assert_eq!(order.prev_kind(), Some(PanelKind::Sidebar));
assert_eq!(order.next_kind(), Some(PanelKind::Query));
}
#[test]
fn focus_moves_and_clamps_at_ends() {
let mut order = PanelOrder::new();
order.focus(PanelKind::Sidebar);
assert_eq!(order.focused(), PanelKind::Sidebar);
assert_eq!(order.prev_kind(), None);
assert_eq!(order.next_kind(), Some(PanelKind::Editor));
order.focus(PanelKind::Query);
assert_eq!(order.prev_kind(), Some(PanelKind::Editor));
assert_eq!(order.next_kind(), None);
}
#[test]
fn show_hide_toggles_visibility_except_editor() {
let mut order = PanelOrder::new();
assert!(order.is_visible(PanelKind::Sidebar));
assert!(!order.is_visible(PanelKind::Query));
order.show(PanelKind::Query);
assert!(order.is_visible(PanelKind::Query));
order.hide(PanelKind::Sidebar);
assert!(!order.is_visible(PanelKind::Sidebar));
order.hide(PanelKind::Editor);
assert!(order.is_visible(PanelKind::Editor));
}
#[test]
fn hiding_focused_panel_moves_focus_to_visible() {
let mut order = PanelOrder::new();
order.focus(PanelKind::Sidebar);
order.hide(PanelKind::Sidebar);
assert!(order.is_visible(order.focused()));
assert_eq!(order.focused(), PanelKind::Editor);
}
#[test]
fn set_order_permutes_keeping_focus_and_visibility() {
let mut order = PanelOrder::new();
order.set_order(&[PanelKind::Query, PanelKind::Editor, PanelKind::Sidebar]);
assert_eq!(order.focused(), PanelKind::Editor);
assert_eq!(order.prev_kind(), Some(PanelKind::Query));
assert_eq!(order.next_kind(), Some(PanelKind::Sidebar));
assert!(order.is_visible(PanelKind::Sidebar));
assert!(!order.is_visible(PanelKind::Query));
}
#[test]
fn layout_columns_splits_area_in_panel_order() {
let area = Rect::new(0, 0, 120, 40);
let visible = [PanelKind::Sidebar, PanelKind::Editor, PanelKind::Query];
let columns = layout_columns(&visible, area);
assert_eq!(columns.len(), 3);
assert_eq!(columns[0].0, PanelKind::Sidebar);
assert_eq!(columns[0].1.width, 30);
assert_eq!(columns[1].0, PanelKind::Editor);
assert_eq!(columns[1].1.width, 50);
assert_eq!(columns[2].0, PanelKind::Query);
assert_eq!(columns[2].1.width, 40);
assert_eq!(columns[0].1.x, 0);
assert_eq!(columns[1].1.x, 30);
assert_eq!(columns[2].1.x, 80);
}
#[test]
fn kind_at_hit_tests_panel_columns() {
let area = Rect::new(0, 0, 120, 40);
let visible = [PanelKind::Sidebar, PanelKind::Editor, PanelKind::Query];
let columns = layout_columns(&visible, area);
assert_eq!(kind_at(&columns, 0, 0), Some(PanelKind::Sidebar));
assert_eq!(kind_at(&columns, 29, 10), Some(PanelKind::Sidebar));
assert_eq!(kind_at(&columns, 30, 10), Some(PanelKind::Editor));
assert_eq!(kind_at(&columns, 79, 39), Some(PanelKind::Editor));
assert_eq!(kind_at(&columns, 80, 0), Some(PanelKind::Query));
assert_eq!(kind_at(&columns, 119, 39), Some(PanelKind::Query));
assert_eq!(kind_at(&columns, 120, 10), None);
assert_eq!(kind_at(&columns, 10, 40), None);
assert_eq!(kind_at(&[], 10, 10), None);
}
#[test]
fn visible_in_order_skips_hidden_and_follows_order() {
let mut order = PanelOrder::new();
assert_eq!(
order.visible_in_order(),
vec![PanelKind::Sidebar, PanelKind::Editor]
);
order.show(PanelKind::Query);
order.set_order(&[PanelKind::Query, PanelKind::Editor, PanelKind::Sidebar]);
assert_eq!(
order.visible_in_order(),
vec![PanelKind::Query, PanelKind::Editor, PanelKind::Sidebar]
);
}
}