use crate::primitives::resizable_grid::layout::PaneLayout;
use crate::primitives::resizable_grid::types::{ResizableGrid, SplitAxis, SplitDividerLayout};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, BorderType, Borders, Widget},
};
#[derive(Debug, Clone, Copy, Default)]
pub struct ResizableGridWidgetState {
pub hovered_divider: Option<usize>,
pub dragging_divider: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ResizableGridWidget {
layout: ResizableGrid,
state: ResizableGridWidgetState,
divider_width: u16,
hit_threshold: u16,
hover_style: Style,
drag_style: Style,
divider_style: Style,
block: Option<Block<'static>>,
show_pane_borders: bool,
}
impl ResizableGridWidget {
pub fn new(layout: ResizableGrid) -> Self {
Self {
layout,
state: ResizableGridWidgetState::default(),
divider_width: 1,
hit_threshold: 2,
hover_style: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
drag_style: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
divider_style: Style::default(),
block: None,
show_pane_borders: true,
}
}
pub fn with_state(mut self, state: ResizableGridWidgetState) -> Self {
self.state = state;
self
}
pub fn state(&self) -> ResizableGridWidgetState {
self.state
}
pub fn optimal_poll_duration(&self) -> std::time::Duration {
if self.state.dragging_divider.is_some() {
std::time::Duration::from_millis(8)
} else {
std::time::Duration::from_millis(50)
}
}
pub fn layout(&self) -> &ResizableGrid {
&self.layout
}
pub fn layout_mut(&mut self) -> &mut ResizableGrid {
&mut self.layout
}
pub fn with_divider_width(mut self, width: u16) -> Self {
self.divider_width = width.max(1);
self
}
pub fn with_hit_threshold(mut self, threshold: u16) -> Self {
self.hit_threshold = threshold.max(1);
self
}
pub fn with_hover_style(mut self, style: Style) -> Self {
self.hover_style = style;
self
}
pub fn with_drag_style(mut self, style: Style) -> Self {
self.drag_style = style;
self
}
pub fn with_divider_style(mut self, style: Style) -> Self {
self.divider_style = style;
self
}
pub fn with_block(mut self, block: Block<'static>) -> Self {
self.block = Some(block);
self
}
pub fn with_pane_borders(mut self, show: bool) -> Self {
self.show_pane_borders = show;
self
}
pub fn is_hovering(&self) -> bool {
self.state.hovered_divider.is_some()
}
pub fn is_dragging(&self) -> bool {
self.state.dragging_divider.is_some()
}
pub fn needs_fast_refresh(&self) -> bool {
self.is_dragging() || self.is_hovering()
}
pub fn hovered_divider(&self) -> Option<usize> {
self.state.hovered_divider
}
pub fn dragging_divider(&self) -> Option<usize> {
self.state.dragging_divider
}
pub fn handle_mouse(&mut self, mouse: MouseEvent, area: Rect) {
match mouse.kind {
MouseEventKind::Moved => {
if self.state.dragging_divider.is_none() {
self.state.hovered_divider =
self.find_divider_at(mouse.column, mouse.row, area);
}
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some(pane_id) = self.find_divider_at(mouse.column, mouse.row, area) {
self.state.dragging_divider = Some(pane_id);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(pane_id) = self.state.dragging_divider {
self.resize_divider(pane_id, mouse.column, mouse.row, area);
}
}
MouseEventKind::Up(MouseButton::Left) => {
self.state.dragging_divider = None;
self.state.hovered_divider = self.find_divider_at(mouse.column, mouse.row, area);
}
_ => {}
}
}
fn find_divider_at(&self, column: u16, row: u16, area: Rect) -> Option<usize> {
let layouts = self.layout.layout_dividers(area);
let threshold = self.hit_threshold;
let mut best_match: Option<(usize, u16, u32)> = None;
for divider in &layouts {
let rect = divider.area();
match divider.axis() {
SplitAxis::Vertical => {
let divider_x = rect.x.saturating_add(
((rect.width as u32 * divider.ratio() as u32) / 100) as u16,
);
let distance = divider_x.abs_diff(column);
if distance <= threshold
&& column <= divider_x.saturating_add(threshold)
&& row >= rect.y
&& row <= rect.y.saturating_add(rect.height)
{
let area_size = rect.width as u32 * rect.height as u32;
if best_match
.map(|(_, best_distance, best_area)| {
distance < best_distance
|| (distance == best_distance && area_size < best_area)
})
.unwrap_or(true)
{
best_match = Some((divider.split_index(), distance, area_size));
}
}
}
SplitAxis::Horizontal => {
let divider_y = rect.y.saturating_add(
((rect.height as u32 * divider.ratio() as u32) / 100) as u16,
);
let distance = divider_y.abs_diff(row);
if distance <= threshold
&& row <= divider_y.saturating_add(threshold)
&& column >= rect.x
&& column <= rect.x.saturating_add(rect.width)
{
let area_size = rect.width as u32 * rect.height as u32;
if best_match
.map(|(_, best_distance, best_area)| {
distance < best_distance
|| (distance == best_distance && area_size < best_area)
})
.unwrap_or(true)
{
best_match = Some((divider.split_index(), distance, area_size));
}
}
}
}
}
best_match.map(|(split_index, _, _)| split_index)
}
fn resize_divider(&mut self, split_index: usize, column: u16, row: u16, area: Rect) {
let layouts = self.layout.layout_dividers(area);
let divider_layout = layouts
.iter()
.find(|divider| divider.split_index() == split_index);
if let Some(divider) = divider_layout {
let rect = divider.area();
match divider.axis() {
SplitAxis::Vertical => {
let content_width = rect.width;
if content_width > 0 {
let relative_x = column.saturating_sub(rect.x);
let percent = ((relative_x as u32 * 100) / content_width as u32) as u16;
let _ = self.layout.resize_split(split_index, percent);
}
}
SplitAxis::Horizontal => {
let content_height = rect.height;
if content_height > 0 {
let relative_y = row.saturating_sub(rect.y);
let percent = ((relative_y as u32 * 100) / content_height as u32) as u16;
let _ = self.layout.resize_split(split_index, percent);
}
}
}
}
}
pub fn pane_layouts(&self, area: Rect) -> Vec<PaneLayout> {
self.layout.layout_panes(area)
}
}
impl Widget for ResizableGridWidget {
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
let mut render_area = area;
if let Some(ref block) = self.block {
let block = block.clone();
render_area = block.inner(area);
block.render(area, buf);
}
let pane_layouts = self.layout.layout_panes(render_area);
let divider_layouts = self.layout.layout_dividers(render_area);
for pane_layout in &pane_layouts {
let pane_id = pane_layout.pane_id();
let pane_area = pane_layout.area();
let border_style = self.divider_style;
if self.show_pane_borders {
let pane_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(Line::from(format!(" {}", pane_id)));
pane_block.render(pane_area, buf);
}
}
for divider in ÷r_layouts {
let divider_style = if self.state.dragging_divider == Some(divider.split_index()) {
self.drag_style
} else if self.state.hovered_divider == Some(divider.split_index()) {
self.hover_style
} else {
continue;
};
self.render_divider_overlay(divider, divider_style, buf);
}
}
}
impl ResizableGridWidget {
fn render_divider_overlay(
&self,
divider: &SplitDividerLayout,
style: Style,
buf: &mut ratatui::buffer::Buffer,
) {
let width = self.divider_width;
let rect = divider.area();
match divider.axis() {
SplitAxis::Vertical => {
let divider_x = rect
.x
.saturating_add(((rect.width as u32 * divider.ratio() as u32) / 100) as u16);
for y in rect.top()..rect.bottom() {
for dx in 0..width {
let x = divider_x.saturating_sub(dx);
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(style);
cell.set_char('│');
}
}
}
}
SplitAxis::Horizontal => {
let divider_y = rect
.y
.saturating_add(((rect.height as u32 * divider.ratio() as u32) / 100) as u16);
for x in rect.left()..rect.right() {
for dy in 0..width {
let y = divider_y.saturating_sub(dy);
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(style);
cell.set_char('─');
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
#[test]
fn test_widget_creation() {
let layout = ResizableGrid::new(0);
let widget = ResizableGridWidget::new(layout);
assert!(!widget.is_hovering());
assert!(!widget.is_dragging());
}
#[test]
fn test_hover_on_horizontal_divider() {
let mut layout = ResizableGrid::new(0);
let _pane_2 = layout.split_pane_vertically(0).unwrap();
let mut widget = ResizableGridWidget::new(layout);
let area = Rect::new(0, 0, 80, 24);
let mouse = MouseEvent {
kind: MouseEventKind::Moved,
column: 40,
row: 11, modifiers: KeyModifiers::empty(),
};
widget.handle_mouse(mouse, area);
assert!(widget.is_hovering());
assert_eq!(widget.hovered_divider(), Some(0));
}
#[test]
fn test_divider_hit_threshold() {
let mut layout = ResizableGrid::new(0);
let _pane_2 = layout.split_pane_horizontally(0).unwrap();
let widget = ResizableGridWidget::new(layout).with_hit_threshold(5);
assert_eq!(widget.hit_threshold, 5);
}
#[test]
fn test_with_styling_methods() {
let mut layout = ResizableGrid::new(0);
let hover_style = Style::default().fg(Color::Red);
let drag_style = Style::default().fg(Color::Blue);
let divider_style = Style::default().fg(Color::Green);
let widget = ResizableGridWidget::new(layout)
.with_hover_style(hover_style)
.with_drag_style(drag_style)
.with_divider_style(divider_style);
assert_eq!(widget.hover_style.fg, Some(Color::Red));
assert_eq!(widget.drag_style.fg, Some(Color::Blue));
assert_eq!(widget.divider_style.fg, Some(Color::Green));
}
#[test]
fn test_no_drag_on_single_pane() {
let mut layout = ResizableGrid::new(0);
let mut widget = ResizableGridWidget::new(layout);
let area = Rect::new(0, 0, 80, 24);
let mouse_down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 40,
row: 10,
modifiers: KeyModifiers::empty(),
};
widget.handle_mouse(mouse_down, area);
assert!(!widget.is_dragging());
assert!(widget.dragging_divider().is_none());
}
}