use ratatui::Frame;
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use crate::components::Component;
use crate::components::activity_rail::{ActivityRail, RAIL_WIDTH};
use crate::components::backlinks_panel::QueryPanel;
use crate::components::drawer::{DrawerHost, DrawerView};
use crate::components::event_state::EventState;
use crate::components::events::{AppTx, InputEvent};
use crate::components::panel::{PanelKind, panel_block};
use crate::components::sidebar::SidebarComponent;
use crate::components::text_editor::TextEditorComponent;
use crate::settings::themes::Theme;
const DEFAULT_DRAWER_WIDTH: u16 = 34;
const MIN_DRAWER_WIDTH: u16 = 20;
const MIN_EDITOR_WIDTH: u16 = 20;
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::Rail,
visible: true,
},
Slot {
kind: PanelKind::Drawer,
visible: true,
},
Slot {
kind: PanelKind::Editor,
visible: true,
},
];
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.step(|i, n| (i + n - 1) % n)
}
pub fn next_kind(&self) -> Option<PanelKind> {
self.step(|i, n| (i + 1) % n)
}
fn step(&self, advance: impl Fn(usize, usize) -> usize) -> Option<PanelKind> {
let visible = self.visible_in_order();
let n = visible.len();
if n < 2 {
return None;
}
let i = visible.iter().position(|&k| k == self.focused())?;
Some(visible[advance(i, n)])
}
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 || kind == PanelKind::Rail {
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()
}
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, drawer_width: u16) -> Constraint {
match kind {
PanelKind::Rail => Constraint::Length(RAIL_WIDTH),
PanelKind::Drawer => Constraint::Length(drawer_width),
PanelKind::Editor => Constraint::Min(0),
}
}
fn layout_columns(visible: &[PanelKind], area: Rect, drawer_width: u16) -> Vec<(PanelKind, Rect)> {
let constraints: Vec<Constraint> = visible
.iter()
.map(|k| panel_column(*k, drawer_width))
.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,
rail: ActivityRail,
drawer: DrawerHost,
editor: TextEditorComponent,
drawer_width: u16,
dragging_divider: bool,
column_rects: Vec<(PanelKind, Rect)>,
}
impl PanelSet {
pub fn from_panels(
drawer: DrawerHost,
editor: TextEditorComponent,
icons: crate::settings::icons::Icons,
) -> Self {
Self {
order: PanelOrder::new(),
rail: ActivityRail::new(icons),
drawer,
editor,
drawer_width: DEFAULT_DRAWER_WIDTH,
dragging_divider: false,
column_rects: Vec::new(),
}
}
pub fn focused(&self) -> PanelKind {
self.order.focused()
}
pub fn focused_label(&self) -> &'static str {
match self.order.focused() {
PanelKind::Drawer => self.drawer.active_view().label(),
kind => kind.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 focus(&mut self, kind: PanelKind) {
if kind != PanelKind::Editor {
self.editor.close_autocomplete();
}
self.order.focus(kind);
}
pub fn active_drawer_view(&self) -> DrawerView {
self.drawer.active_view()
}
pub fn drawer_is_text_input(&self) -> bool {
self.drawer.is_text_input()
}
pub fn adjust_drawer_width(&mut self, delta: i16) {
let Some(right) = self.column_rects.iter().map(|(_, r)| r.right()).max() else {
return;
};
let new = self.drawer_width.saturating_add_signed(delta);
let max_width = right.saturating_sub(RAIL_WIDTH + MIN_EDITOR_WIDTH);
self.drawer_width = new.clamp(MIN_DRAWER_WIDTH, max_width.max(MIN_DRAWER_WIDTH));
}
pub fn open_drawer_view(&mut self, view: DrawerView) {
self.drawer.set_view(view);
self.rail.set_cursor(view);
self.order.show(PanelKind::Drawer);
}
pub fn sidebar(&self) -> &SidebarComponent {
self.drawer.sidebar()
}
pub fn sidebar_mut(&mut self) -> &mut SidebarComponent {
self.drawer.sidebar_mut()
}
pub fn editor(&self) -> &TextEditorComponent {
&self.editor
}
pub fn editor_mut(&mut self) -> &mut TextEditorComponent {
&mut self.editor
}
pub fn query(&self) -> &QueryPanel {
self.drawer.query()
}
pub fn query_mut(&mut self) -> &mut QueryPanel {
self.drawer.query_mut()
}
pub fn tags_mut(&mut self) -> &mut crate::components::drawer_views::TagsPanel {
self.drawer.tags_mut()
}
pub fn links_mut(&mut self) -> &mut crate::components::drawer_views::LinksPanel {
self.drawer.links_mut()
}
pub fn outline_mut(&mut self) -> &mut crate::components::drawer_views::OutlinePanel {
self.drawer.outline_mut()
}
pub fn drawer_set_config_info(&mut self, info: crate::components::drawer::ConfigInfo) {
self.drawer.set_config_info(info);
}
pub fn focused_hints(&self) -> Vec<(String, String)> {
match self.order.focused() {
PanelKind::Rail => self.rail.hint_shortcuts(),
PanelKind::Drawer => self.drawer.hint_shortcuts(),
PanelKind::Editor => self.editor.hint_shortcuts(),
}
}
pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
match self.order.focused() {
PanelKind::Rail => self.rail.handle_input(event, tx),
PanelKind::Drawer => self.drawer.handle_input(event, tx),
PanelKind::Editor => self.editor.handle_input(event, tx),
}
}
fn on_divider(&self, column: u16, row: u16) -> bool {
self.column_rects
.iter()
.find(|(kind, _)| *kind == PanelKind::Drawer)
.is_some_and(|(_, rect)| {
rect.height > 0
&& column == rect.right().saturating_sub(1)
&& row >= rect.y
&& row < rect.bottom()
})
}
fn drag_divider_to(&mut self, column: u16) {
let Some((_, drawer_rect)) = self
.column_rects
.iter()
.find(|(kind, _)| *kind == PanelKind::Drawer)
else {
return;
};
let total_right = self
.column_rects
.iter()
.map(|(_, r)| r.right())
.max()
.unwrap_or(drawer_rect.right());
let max_width = total_right
.saturating_sub(drawer_rect.x)
.saturating_sub(MIN_EDITOR_WIDTH);
let new_width = column.saturating_sub(drawer_rect.x).saturating_add(1);
self.drawer_width = new_width.clamp(MIN_DRAWER_WIDTH, max_width.max(MIN_DRAWER_WIDTH));
}
pub fn handle_mouse(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
let InputEvent::Mouse(mouse) = event else {
return EventState::NotConsumed;
};
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) if self.on_divider(mouse.column, mouse.row) => {
self.dragging_divider = true;
return EventState::Consumed;
}
MouseEventKind::Drag(MouseButton::Left) if self.dragging_divider => {
self.drag_divider_to(mouse.column);
return EventState::Consumed;
}
MouseEventKind::Up(MouseButton::Left) if self.dragging_divider => {
self.dragging_divider = false;
return EventState::Consumed;
}
_ => {}
}
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::Rail => {
self.rail.handle_input(event, tx);
}
PanelKind::Drawer => {
self.drawer.handle_mouse(event, tx);
}
PanelKind::Editor => {
self.editor.handle_input(event, 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.drawer_width);
self.column_rects = columns.clone();
let focused = self.order.focused();
let drawer_view = self
.is_visible(PanelKind::Drawer)
.then(|| self.drawer.active_view());
for (kind, rect) in &columns {
let is_focused = show_focus && *kind == focused;
let rect = *rect;
match kind {
PanelKind::Rail => self.rail.render(f, rect, theme, is_focused, drawer_view),
PanelKind::Drawer => self.drawer.render(f, rect, theme, is_focused),
PanelKind::Editor => {
let title = if self.editor.is_dirty() {
"Editor [+]"
} else {
"Editor"
};
let block = panel_block(title, theme, is_focused);
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.clone(),
settings.key_bindings.clone(),
settings.icons(),
);
let tags = crate::components::drawer_views::TagsPanel::new(vault.clone(), settings.icons());
let links =
crate::components::drawer_views::LinksPanel::new(vault.clone(), settings.icons());
let outline = crate::components::drawer_views::OutlinePanel::new(vault, settings.icons());
let drawer = DrawerHost::new(sidebar, query, tags, links, outline);
PanelSet::from_panels(drawer, editor, settings.icons())
}
fn lay_out(panels: &mut PanelSet) {
panels.column_rects = layout_columns(
&panels.order.visible_in_order(),
Rect::new(0, 0, 120, 40),
panels.drawer_width,
);
}
fn scroll_at(col: u16, row: u16) -> InputEvent {
InputEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
fn drag_at(col: u16, row: u16) -> InputEvent {
InputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
fn up_at(col: u16, row: u16) -> InputEvent {
InputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
#[tokio::test]
async fn click_focuses_panel_under_cursor() {
let mut panels = make_panel_set().await;
lay_out(&mut panels);
let (tx, _rx) = unbounded_channel();
assert_eq!(panels.focused(), PanelKind::Editor);
assert_eq!(
panels.handle_mouse(&mouse_down_at(3, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Rail);
assert_eq!(
panels.handle_mouse(&mouse_down_at(20, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Drawer);
assert_eq!(
panels.handle_mouse(&mouse_down_at(60, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Editor);
}
#[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(20, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.focused(), PanelKind::Editor);
}
#[tokio::test]
async fn divider_drag_resizes_drawer() {
let mut panels = make_panel_set().await;
lay_out(&mut panels);
let (tx, _rx) = unbounded_channel();
assert_eq!(
panels.handle_mouse(&mouse_down_at(40, 5), &tx),
EventState::Consumed
);
assert_eq!(
panels.handle_mouse(&drag_at(60, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.drawer_width, 54); assert_eq!(
panels.handle_mouse(&drag_at(0, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.drawer_width, MIN_DRAWER_WIDTH);
assert_eq!(
panels.handle_mouse(&drag_at(200, 5), &tx),
EventState::Consumed
);
assert_eq!(panels.drawer_width, 120 - 7 - MIN_EDITOR_WIDTH);
assert_eq!(
panels.handle_mouse(&up_at(80, 5), &tx),
EventState::Consumed
);
let width_before = panels.drawer_width;
panels.handle_mouse(&drag_at(50, 5), &tx);
assert_eq!(panels.drawer_width, width_before);
}
#[test]
fn default_focus_is_editor() {
let order = PanelOrder::new();
assert_eq!(order.focused(), PanelKind::Editor);
}
#[test]
fn focus_cycle_wraps_over_visible_panels() {
let mut order = PanelOrder::new();
assert_eq!(order.next_kind(), Some(PanelKind::Rail));
assert_eq!(order.prev_kind(), Some(PanelKind::Drawer));
order.focus(PanelKind::Rail);
assert_eq!(order.prev_kind(), Some(PanelKind::Editor)); assert_eq!(order.next_kind(), Some(PanelKind::Drawer));
}
#[test]
fn focus_cycle_skips_hidden_drawer() {
let mut order = PanelOrder::new();
order.hide(PanelKind::Drawer);
assert_eq!(order.next_kind(), Some(PanelKind::Rail));
order.focus(PanelKind::Rail);
assert_eq!(order.next_kind(), Some(PanelKind::Editor));
}
#[test]
fn show_hide_toggles_visibility_except_rail_and_editor() {
let mut order = PanelOrder::new();
assert!(order.is_visible(PanelKind::Drawer));
order.hide(PanelKind::Drawer);
assert!(!order.is_visible(PanelKind::Drawer));
order.show(PanelKind::Drawer);
assert!(order.is_visible(PanelKind::Drawer));
order.hide(PanelKind::Editor);
assert!(order.is_visible(PanelKind::Editor));
order.hide(PanelKind::Rail);
assert!(order.is_visible(PanelKind::Rail));
}
#[test]
fn hiding_focused_panel_moves_focus_to_visible() {
let mut order = PanelOrder::new();
order.focus(PanelKind::Drawer);
order.hide(PanelKind::Drawer);
assert!(order.is_visible(order.focused()));
}
#[test]
fn layout_columns_splits_area_in_panel_order() {
let area = Rect::new(0, 0, 120, 40);
let visible = [PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor];
let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
assert_eq!(columns.len(), 3);
assert_eq!(columns[0].0, PanelKind::Rail);
assert_eq!(columns[0].1.width, RAIL_WIDTH);
assert_eq!(columns[1].0, PanelKind::Drawer);
assert_eq!(columns[1].1.width, DEFAULT_DRAWER_WIDTH);
assert_eq!(columns[2].0, PanelKind::Editor);
assert_eq!(columns[2].1.width, 120 - RAIL_WIDTH - DEFAULT_DRAWER_WIDTH);
assert_eq!(columns[0].1.x, 0);
assert_eq!(columns[1].1.x, RAIL_WIDTH);
assert_eq!(columns[2].1.x, RAIL_WIDTH + DEFAULT_DRAWER_WIDTH);
}
#[test]
fn hidden_drawer_gives_width_to_editor() {
let area = Rect::new(0, 0, 120, 40);
let visible = [PanelKind::Rail, PanelKind::Editor];
let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
assert_eq!(columns.len(), 2);
assert_eq!(columns[1].0, PanelKind::Editor);
assert_eq!(columns[1].1.width, 120 - RAIL_WIDTH);
}
#[test]
fn kind_at_hit_tests_panel_columns() {
let area = Rect::new(0, 0, 120, 40);
let visible = [PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor];
let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
assert_eq!(kind_at(&columns, 0, 0), Some(PanelKind::Rail));
assert_eq!(kind_at(&columns, 6, 10), Some(PanelKind::Rail));
assert_eq!(kind_at(&columns, 7, 10), Some(PanelKind::Drawer));
assert_eq!(kind_at(&columns, 40, 39), Some(PanelKind::Drawer));
assert_eq!(kind_at(&columns, 41, 0), Some(PanelKind::Editor));
assert_eq!(kind_at(&columns, 119, 39), Some(PanelKind::Editor));
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 open_drawer_view_reveals_and_switches() {
let mut order = PanelOrder::new();
order.hide(PanelKind::Drawer);
assert!(!order.is_visible(PanelKind::Drawer));
order.show(PanelKind::Drawer);
assert!(order.is_visible(PanelKind::Drawer));
}
#[test]
fn visible_in_order_skips_hidden() {
let mut order = PanelOrder::new();
assert_eq!(
order.visible_in_order(),
vec![PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor]
);
order.hide(PanelKind::Drawer);
assert_eq!(
order.visible_in_order(),
vec![PanelKind::Rail, PanelKind::Editor]
);
}
}