pub use hjkl_layout::{Axis, LayoutRect, LayoutTree, SplitDir, WindowId};
#[derive(Debug, Clone)]
pub struct Tab {
pub layout: LayoutTree,
pub focused_window: WindowId,
pub(crate) left_dock: Option<super::dock::Dock>,
pub(crate) bottom_dock: Option<super::dock::Dock>,
pub(crate) explorer: Option<super::explorer::ExplorerPane>,
}
impl Tab {
pub fn new(layout: LayoutTree, focused_window: WindowId) -> Self {
Self {
layout,
focused_window,
left_dock: None,
bottom_dock: None,
explorer: None,
}
}
}
impl Default for Tab {
fn default() -> Self {
Self::new(LayoutTree::Leaf(0), 0)
}
}
pub use hjkl_layout::Window as AppWindow;
pub use hjkl_layout::Window;
#[inline]
pub fn rect_to_layout(r: ratatui::layout::Rect) -> LayoutRect {
LayoutRect::new(r.x, r.y, r.width, r.height)
}
#[inline]
pub fn layout_to_rect(r: LayoutRect) -> ratatui::layout::Rect {
ratatui::layout::Rect {
x: r.x,
y: r.y,
width: r.w,
height: r.h,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloseRefused {
LastRegularWindow,
}
use super::App;
impl App {
pub(crate) fn dispatch_window_action(
&mut self,
action: crate::keymap_actions::AppAction,
count: usize,
) {
use crate::keymap_actions::AppAction;
match action {
AppAction::FocusLeft => self.focus_left(),
AppAction::FocusBelow => self.focus_below(),
AppAction::FocusAbove => self.focus_above(),
AppAction::FocusRight => self.focus_right(),
AppAction::FocusNext => self.focus_next(),
AppAction::FocusPrev => self.focus_previous(),
AppAction::CloseFocusedWindow => self.close_focused_window(),
AppAction::OnlyFocusedWindow => self.only_focused_window(),
AppAction::SwapWithSibling => self.swap_with_sibling(),
AppAction::MoveWindowToNewTab => match self.move_window_to_new_tab() {
Ok(()) => {
self.bus.info("moved window to new tab");
}
Err(msg) => {
self.bus.error(msg.to_string());
}
},
AppAction::NewSplit => self.dispatch_ex("new"),
AppAction::ResizeHeight(delta) => {
if self.is_bottom_dock(self.focused_window()) {
self.resize_dock_height_by(delta * count as i32);
self.persist_dock_height();
} else {
self.resize_height(delta * count as i32);
}
}
AppAction::ResizeWidth(delta) => {
if self.is_left_dock(self.focused_window()) {
self.resize_dock_width_by(delta * count as i32);
self.persist_dock_width();
} else {
self.resize_width(delta * count as i32);
}
}
AppAction::EqualizeLayout => self.equalize_layout(),
AppAction::MaximizeHeight => self.maximize_height(),
AppAction::MaximizeWidth => self.maximize_width(),
AppAction::TmuxNavigate(dir) => self.dispatch_tmux_navigate(dir),
_ => {}
}
}
pub(crate) fn dispatch_tmux_navigate(&mut self, dir: super::NavDir) {
use super::NavDir;
let focused = self.focused_window();
let neighbour = match dir {
NavDir::Left => self.layout().neighbor_left(focused),
NavDir::Down => self.layout().neighbor_below(focused),
NavDir::Up => self.layout().neighbor_above(focused),
NavDir::Right => self.layout().neighbor_right(focused),
};
if neighbour.is_some() {
match dir {
NavDir::Left => self.focus_left(),
NavDir::Down => self.focus_below(),
NavDir::Up => self.focus_above(),
NavDir::Right => self.focus_right(),
}
} else if std::env::var("TMUX").is_ok() {
let flag = match dir {
NavDir::Left => "-L",
NavDir::Down => "-D",
NavDir::Up => "-U",
NavDir::Right => "-R",
};
let _ = std::process::Command::new("tmux")
.args(["select-pane", flag])
.status();
}
}
pub fn focus_below(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_below(fw) {
self.switch_focus(target);
}
}
pub fn focus_above(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_above(fw) {
self.switch_focus(target);
}
}
pub fn focus_left(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_left(fw) {
self.switch_focus(target);
}
}
pub fn focus_right(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_right(fw) {
self.switch_focus(target);
}
}
pub fn focus_next(&mut self) {
let fw = self.focused_window();
let order = self.layout().leaves();
if let Some(pos) = order.iter().position(|&id| id == fw) {
self.switch_focus(order[(pos + 1) % order.len()]);
}
}
pub fn focus_previous(&mut self) {
let fw = self.focused_window();
let order = self.layout().leaves();
if let Some(pos) = order.iter().position(|&id| id == fw) {
let len = order.len();
self.switch_focus(order[(pos + len - 1) % len]);
}
}
pub fn only_focused_window(&mut self) {
let focused = self.focused_window();
if self.is_dock_window(focused) {
return;
}
if !self.layout().contains(focused) {
return;
}
let pinned = self.dock_pins();
let removed = self.layout_mut().only(focused, &pinned);
for id in removed {
self.windows[id] = None;
self.window_folds.remove(&id);
}
self.bus.info("only");
}
pub fn swap_with_sibling(&mut self) {
let focused = self.focused_window();
let pinned = self.dock_pins();
if self.layout_mut().swap_with_sibling(focused, &pinned) {
self.bus.info("swap");
}
}
pub fn move_window_to_new_tab(&mut self) -> Result<(), &'static str> {
let focused = self.focused_window();
if self.is_dock_window(focused) || !self.layout().contains(focused) {
return Err("dock windows can't move to a new tab");
}
self.sync_viewport_from_editor();
let new_focus_in_old_tab = self
.detach_focused_leaf()
.map_err(|CloseRefused::LastRegularWindow| "E1: only one window in this tab")?;
self.tabs[self.active_tab].focused_window = new_focus_in_old_tab;
let new_tab = Tab::new(LayoutTree::Leaf(focused), focused);
self.tabs.push(new_tab);
self.active_tab = self.tabs.len() - 1;
self.sync_viewport_to_editor();
Ok(())
}
fn detach_focused_leaf(&mut self) -> Result<WindowId, CloseRefused> {
let focused = self.focused_window();
if !self.is_dock_window(focused) && self.regular_leaf_count() <= 1 {
return Err(CloseRefused::LastRegularWindow);
}
self.layout_mut()
.remove_leaf(focused)
.map_err(|_| CloseRefused::LastRegularWindow)
}
pub fn close_focused_window(&mut self) {
if self.close_focused_window_checked().is_err() {
self.bus.error("E444: Cannot close last window");
}
}
pub fn close_focused_window_checked(&mut self) -> Result<(), CloseRefused> {
if self.is_cmdline_win_focused() {
self.close_cmdline_window();
return Ok(());
}
let focused = self.focused_window();
if self.is_left_dock(focused) {
self.close_left_dock();
return Ok(());
}
if self.is_bottom_dock(focused) {
self.close_bottom_dock();
return Ok(());
}
let commit_ctx = self.windows[focused]
.as_ref()
.and_then(|w| self.slots.get(w.slot))
.and_then(|s| s.commit_ctx.clone());
let new_focus = self.detach_focused_leaf()?;
self.windows[focused] = None;
self.window_folds.remove(&focused);
self.set_focused_window(new_focus);
self.sync_viewport_to_editor();
self.bus.info("window closed");
if let Some(ctx) = commit_ctx {
match hjkl_app::git::commit_with_file(&ctx.root, &ctx.msg_file) {
Ok(out) => {
let first = out.lines().next().unwrap_or("committed").to_string();
self.bus.info(first);
}
Err(e) => {
let first = e.lines().next().unwrap_or("commit failed").to_string();
self.bus.warn(first);
}
}
let _ = std::fs::remove_file(&ctx.msg_file);
self.recompute_explorer_git_base();
self.refresh_git_signs_force();
self.explorer_rebuild_buffer();
}
Ok(())
}
pub fn resize_height(&mut self, delta: i32) {
let fw = self.focused_window();
if let Some((ratio, Some(rect), in_a)) = self
.layout_mut()
.enclosing_split_mut(fw, SplitDir::Horizontal)
{
let parent_h = rect.h as i32;
if parent_h < 2 {
return;
}
let current_focused_height = if in_a {
(parent_h as f32 * *ratio) as i32
} else {
(parent_h as f32 * (1.0 - *ratio)) as i32
};
let new_focused = (current_focused_height + delta).clamp(1, parent_h - 1);
let new_ratio = if in_a {
new_focused as f32 / parent_h as f32
} else {
(parent_h - new_focused) as f32 / parent_h as f32
};
*ratio = new_ratio.clamp(0.01, 0.99);
}
}
pub fn resize_width(&mut self, delta: i32) {
let fw = self.focused_window();
if let Some((ratio, Some(rect), in_a)) = self
.layout_mut()
.enclosing_split_mut(fw, SplitDir::Vertical)
{
let parent_w = rect.w as i32;
if parent_w < 2 {
return;
}
let current_focused_width = if in_a {
(parent_w as f32 * *ratio) as i32
} else {
(parent_w as f32 * (1.0 - *ratio)) as i32
};
let new_focused = (current_focused_width + delta).clamp(1, parent_w - 1);
let new_ratio = if in_a {
new_focused as f32 / parent_w as f32
} else {
(parent_w - new_focused) as f32 / parent_w as f32
};
*ratio = new_ratio.clamp(0.01, 0.99);
}
}
pub fn equalize_layout(&mut self) {
let pinned = self.dock_pins();
self.layout_mut().equalize_all(&pinned);
}
pub(crate) fn resize_split_to(
&mut self,
orientation: super::mouse::SplitOrientation,
split_origin: u16,
split_total: u16,
split_pos: u16,
) {
let min_size = match orientation {
super::mouse::SplitOrientation::Vertical => super::SPLIT_MIN_SIZE_COLS,
super::mouse::SplitOrientation::Horizontal => super::SPLIT_MIN_SIZE_ROWS,
};
if split_total < min_size * 2 + 1 {
return; }
let clamped = split_pos.clamp(min_size, split_total.saturating_sub(min_size + 1));
let new_ratio = clamped as f32 / split_total as f32;
let new_ratio = new_ratio.clamp(0.01, 0.99);
let dir = match orientation {
super::mouse::SplitOrientation::Vertical => SplitDir::Vertical,
super::mouse::SplitOrientation::Horizontal => SplitDir::Horizontal,
};
fn update_matching(
node: &mut LayoutTree,
dir: SplitDir,
origin: u16,
total: u16,
new_ratio: f32,
) {
if let LayoutTree::Split {
dir: my_dir,
ratio,
fixed,
a,
b,
last_rect,
} = node
{
if *my_dir == dir
&& let Some(r) = last_rect
{
let (rect_origin, rect_total) = match dir.axis() {
Axis::Col => (r.x, r.w),
Axis::Row => (r.y, r.h),
};
if rect_origin == origin && rect_total == total {
if fixed.is_none() {
*ratio = new_ratio;
}
return;
}
}
update_matching(a, dir, origin, total, new_ratio);
update_matching(b, dir, origin, total, new_ratio);
}
}
update_matching(self.layout_mut(), dir, split_origin, split_total, new_ratio);
}
pub(crate) fn equalize_split(&mut self) {
self.equalize_layout();
}
pub(crate) fn nvim_tab_window_ids(&self, tab_idx: usize) -> Option<Vec<u64>> {
let tab = self.tabs.get(tab_idx)?;
Some(
tab.layout
.leaves()
.into_iter()
.map(|id| id as u64)
.collect(),
)
}
pub(crate) fn nvim_window_ids(&self) -> Vec<u64> {
self.windows
.iter()
.enumerate()
.filter_map(|(i, w)| w.as_ref().map(|_| i as u64))
.collect()
}
pub(crate) fn nvim_current_window_id(&self) -> u64 {
self.focused_window() as u64
}
pub(crate) fn nvim_window_is_valid(&self, id: u64) -> bool {
self.windows.get(id as usize).is_some_and(Option::is_some)
}
pub(crate) fn nvim_window_buffer_id(&self, id: u64) -> Option<u64> {
let slot = self.windows.get(id as usize)?.as_ref()?.slot;
self.slots.get(slot).map(|s| s.buffer_id)
}
pub(crate) fn nvim_set_focused_window_checked(&mut self, id: u64) -> bool {
if !self.nvim_window_is_valid(id) {
return false;
}
self.switch_focus(id as super::window::WindowId);
true
}
pub(crate) fn nvim_set_window_buffer(&mut self, win: u64, buffer_id: u64) -> bool {
if !self.nvim_window_is_valid(win) {
return false;
}
let Some(slot) = self.nvim_slot_index_for_buffer(buffer_id) else {
return false;
};
if let Some(Some(w)) = self.windows.get_mut(win as usize) {
w.slot = slot;
} else {
return false;
}
self.reconcile_window_editors();
true
}
pub(crate) fn nvim_window_cursor(&self, id: u64) -> Option<(usize, usize)> {
let win_id = id as super::window::WindowId;
if !self.nvim_window_is_valid(id) {
return None;
}
let ed = self.window_editor(win_id);
Some(ed.cursor())
}
pub(crate) fn nvim_set_window_cursor(&mut self, id: u64, row: usize, col: usize) -> bool {
let win_id = id as super::window::WindowId;
if !self.nvim_window_is_valid(id) {
return false;
}
self.window_editors
.get_mut(&win_id)
.expect("window_editors must have an entry for every open window")
.jump_cursor(row, col);
true
}
pub fn maximize_height(&mut self) {
let focused = self.focused_window();
self.layout_mut()
.for_each_ancestor(focused, &mut |dir, ratio, in_a, rect| {
if dir != SplitDir::Horizontal {
return;
}
if let Some(r) = rect {
let h = r.h as f32;
if h < 2.0 {
return;
}
let max_branch = (h - 1.0) / h;
let min_branch = 1.0 / h;
*ratio = if in_a { max_branch } else { min_branch };
}
});
}
pub fn maximize_width(&mut self) {
let focused = self.focused_window();
self.layout_mut()
.for_each_ancestor(focused, &mut |dir, ratio, in_a, rect| {
if dir != SplitDir::Vertical {
return;
}
if let Some(r) = rect {
let w = r.w as f32;
if w < 2.0 {
return;
}
let max_branch = (w - 1.0) / w;
let min_branch = 1.0 / w;
*ratio = if in_a { max_branch } else { min_branch };
}
});
}
}