use std::{
cell::{Cell, RefCell},
collections::{HashMap, HashSet},
path::{Path, PathBuf},
rc::Rc,
};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use relm4::gtk::{gdk, gio, glib, prelude::*};
use relm4::prelude::*;
use crate::cmd::{Command, WidthArg};
use crate::config::{
Bookmark, BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, WorkspaceMove,
bookmark_file_path, load_stylesheet, save_bookmarks_to_path,
};
use crate::workspace::Hyprland;
use crate::fs::SortKey;
use crate::ipc;
use crate::ui::bookmarks::{self, BookmarkEvent, MoveTarget};
use crate::ui::toolbar::{
PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput, parse_accelerator,
};
use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
#[derive(Debug, Clone, PartialEq, Eq)]
enum ViewEntry {
Dir(PathBuf),
Bookmarks,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct NavHistory {
entries: Vec<ViewEntry>,
cursor: usize,
navigating: bool,
}
impl NavHistory {
fn record(&mut self, entry: ViewEntry) {
if self.navigating {
return;
}
if self.entries.get(self.cursor).is_some_and(|cur| cur == &entry) {
return;
}
if self.entries.is_empty() {
self.entries.push(entry);
self.cursor = 0;
return;
}
self.entries.truncate(self.cursor + 1);
self.entries.push(entry);
self.cursor = self.entries.len() - 1;
}
fn can_back(&self) -> bool {
self.cursor > 0 && self.cursor < self.entries.len()
}
fn can_forward(&self) -> bool {
!self.entries.is_empty() && self.cursor + 1 < self.entries.len()
}
fn back(&mut self) -> Option<ViewEntry> {
if !self.can_back() {
return None;
}
self.cursor -= 1;
self.navigating = true;
Some(self.entries[self.cursor].clone())
}
fn forward(&mut self) -> Option<ViewEntry> {
if !self.can_forward() {
return None;
}
self.cursor += 1;
self.navigating = true;
Some(self.entries[self.cursor].clone())
}
fn finish_navigation(&mut self) {
self.navigating = false;
}
}
pub struct Pane {
id: u64,
toolbar: Controller<Toolbar>,
tree: Controller<Tree>,
workspace: String,
root: Option<PathBuf>,
canonical_root: Option<PathBuf>,
history: NavHistory,
filter_bar: gtk::Box,
filter_entry: gtk::SearchEntry,
body: gtk::Stack,
bookmarks_list: gtk::Box,
bookmark_filter: String,
bookmark_drag: Rc<bookmarks::BookmarkDrag>,
bookmark_nav: Rc<bookmarks::BookmarkNav>,
widget: gtk::Overlay,
grip: gtk::Image,
nav_buttons: Option<NavButtons>,
shortcuts: Rc<PaneShortcuts>,
}
struct NavButtons {
up: gtk::Button,
back: gtk::Button,
forward: gtk::Button,
}
impl Pane {
fn set_root(&mut self, root: PathBuf) {
self.canonical_root = std::fs::canonicalize(&root).ok();
self.root = Some(root);
self.refresh_nav();
}
fn show_bookmarks(&self) {
self.tree.emit(TreeMsg::Suspend);
self.body.set_visible_child_name("bookmarks");
self.toolbar.emit(ToolbarMsg::SetBookmarks(true));
self.refresh_nav();
}
fn show_tree(&self) {
self.body.set_visible_child_name("tree");
self.toolbar.emit(ToolbarMsg::SetBookmarks(false));
self.refresh_nav();
}
fn on_bookmarks(&self) -> bool {
self.body.visible_child_name().as_deref() == Some("bookmarks")
}
fn refresh_nav(&self) {
let Some(nav) = &self.nav_buttons else {
return;
};
let has_parent = !self.on_bookmarks()
&& self.root.as_deref().and_then(Path::parent).is_some();
nav.up.set_sensitive(has_parent);
nav.back.set_sensitive(self.history.can_back());
nav.forward.set_sensitive(self.history.can_forward());
}
}
struct Dock {
side: PanelSide,
monitor: Option<gdk::Monitor>,
window: gtk::Window,
container: gtk::Box,
panes: Vec<Pane>,
active_pane: Option<u64>,
}
struct PaneDrag {
id: u64,
base: (f64, f64),
highlighted: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisibilityIntent {
ShowAll,
HideAll,
ToggleAll,
ShowSide(PanelSide),
HideSide(PanelSide),
ToggleSide(PanelSide),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibilityPlan {
pub show: Vec<PanelSide>,
pub hide: Vec<PanelSide>,
pub create: Option<PanelSide>,
pub seed: bool,
}
pub fn resolve_visibility(
intent: VisibilityIntent,
existing: &[PanelSide],
shown: &[PanelSide],
) -> VisibilityPlan {
match intent {
VisibilityIntent::ShowAll => VisibilityPlan {
show: existing.to_vec(),
hide: Vec::new(),
create: None,
seed: true,
},
VisibilityIntent::HideAll => VisibilityPlan {
show: Vec::new(),
hide: PanelSide::ALL.to_vec(),
create: None,
seed: false,
},
VisibilityIntent::ToggleAll => {
if shown.is_empty() {
VisibilityPlan {
show: existing.to_vec(),
hide: Vec::new(),
create: None,
seed: true,
}
} else {
VisibilityPlan {
show: Vec::new(),
hide: PanelSide::ALL.to_vec(),
create: None,
seed: false,
}
}
}
VisibilityIntent::ShowSide(side) => VisibilityPlan {
show: vec![side],
hide: Vec::new(),
create: (!existing.contains(&side)).then_some(side),
seed: true,
},
VisibilityIntent::HideSide(side) => VisibilityPlan {
show: Vec::new(),
hide: vec![side],
create: None,
seed: false,
},
VisibilityIntent::ToggleSide(side) => {
if !existing.contains(&side) {
VisibilityPlan {
show: vec![side],
hide: Vec::new(),
create: Some(side),
seed: true,
}
} else if shown.contains(&side) {
VisibilityPlan {
show: Vec::new(),
hide: vec![side],
create: None,
seed: false,
}
} else {
VisibilityPlan {
show: vec![side],
hide: Vec::new(),
create: None,
seed: true,
}
}
}
}
}
#[derive(Debug)]
pub enum AppMsg {
PaneToolbar { id: u64, out: ToolbarOutput },
PaneTree { id: u64, out: TreeOutput },
PaneShortcut { id: u64, action: ContextAction },
OpenFolderPicked { id: u64, path: Option<PathBuf> },
LaunchRequest { command: Command },
ActiveWorkspace(String),
SplitFromPane { id: u64 },
OpenSplitFrom { id: u64, root: PathBuf },
OpenOppositeFrom { id: u64, root: PathBuf },
ClosePane { id: u64 },
FilterChanged { id: u64, filter: String },
FilterClosed { id: u64 },
Shutdown,
ResizeBy { side: PanelSide, delta: i32 },
ResizeCommit,
PaneDragBegin { id: u64, start: (f64, f64) },
PaneDragUpdate { id: u64, offset: (f64, f64) },
PaneDragEnd { id: u64, offset: (f64, f64) },
ResolveDockMonitor { di: usize },
FocusPane { id: u64 },
FocusVisible,
BookmarkEvent { id: u64, event: BookmarkEvent },
BookmarkEditSaved { index_path: Vec<usize>, name: String, path: Option<PathBuf> },
BookmarkAdded { name: String, path: PathBuf },
BookmarkFolderAdded { name: String },
}
pub struct AppInit {
pub command: Command,
pub listener: Option<std::os::unix::net::UnixListener>,
}
pub struct App {
config: Config,
status: String,
window: gtk::Window,
file_dialog: gtk::FileDialog,
docks: Vec<Dock>,
pane_container: gtk::Box,
primary_side: PanelSide,
hypr: Option<Hyprland>,
active_workspace: String,
next_id: u64,
hidden_workspaces: HashSet<String>,
widths: HashMap<PanelSide, u32>,
last_root: Option<PathBuf>,
bookmarks: Vec<Bookmark>,
pane_drag: Option<PaneDrag>,
save_generation: Rc<Cell<u64>>,
sender: ComponentSender<App>,
}
#[relm4::component(pub)]
impl SimpleComponent for App {
type Init = AppInit;
type Input = AppMsg;
type Output = ();
view! {
gtk::Window {
set_default_size: (model.config.panel.width as i32, 520),
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 0,
add_css_class: "panel",
append: &model.pane_container,
append: status = >k::Label {
#[watch]
set_label: &model.status,
set_halign: gtk::Align::Start,
set_ellipsize: gtk::pango::EllipsizeMode::End,
add_css_class: "status-bar",
}
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let loaded = Config::load();
let mut config = loaded.config;
let parent = root.clone();
let (bookmarks, bookmark_problem) = crate::config::load_bookmarks(&config.bookmarks);
if let Err(problem) = crate::config::ensure_bookmarks_file(&config.bookmarks, &bookmarks) {
eprintln!("tree-space: could not create bookmarks file: {problem:?}");
}
let session = SessionState::load();
let default_width = config.panel.width;
let widths: HashMap<PanelSide, u32> = [
(
PanelSide::Left,
session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
),
(
PanelSide::Right,
session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
),
]
.into_iter()
.collect();
let primary_side = init.command.side.unwrap_or(config.panel.side);
config.panel.width = widths[&primary_side];
let primary_width = config.panel.width;
let hypr = Hyprland::connect();
let active_workspace =
hypr.as_ref().and_then(Hyprland::active_workspace).unwrap_or_default();
let roots = if !init.command.roots.is_empty() {
init.command.roots.clone()
} else if config.startup.is_bookmarks() {
Vec::new()
} else {
let last = session.last_root.clone().filter(|p| p.is_dir());
let fallback = config
.startup
.resolve(last)
.filter(|p| p.is_dir())
.or_else(home_dir);
if let Some(p) = fallback { vec![p] } else { Vec::new() }
};
let mut panes: Vec<Pane> = Vec::new();
let mut next_id = 0u64;
for root in roots.iter() {
let pane = make_pane(
&config,
parent.clone(),
next_id,
primary_side,
primary_width,
active_workspace.clone(),
sender.clone(),
);
pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
panes.push(pane);
panes.last_mut().unwrap().set_root(root.clone());
next_id += 1;
}
if panes.is_empty() {
let mut pane = make_pane(
&config,
parent.clone(),
next_id,
primary_side,
primary_width,
active_workspace.clone(),
sender.clone(),
);
pane.show_bookmarks();
pane.history.record(ViewEntry::Bookmarks);
pane.refresh_nav();
panes.push(pane);
next_id += 1;
}
let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
pane_container.set_vexpand(true);
fill_pane_container(&pane_container, &panes, &active_workspace);
let mut status = String::new();
if let Some(problem) = loaded.problem {
status = format!("config: {problem:?}");
} else if let Some(problem) = bookmark_problem {
status = format!("bookmarks: {problem:?}");
}
let hidden_workspaces = if init.command.hidden {
HashSet::from([active_workspace.clone()])
} else {
HashSet::new()
};
let mut model = App {
config,
status,
window: root.clone(),
file_dialog: gtk::FileDialog::new(),
docks: vec![Dock {
side: primary_side,
monitor: None,
window: root.clone(),
container: pane_container.clone(),
panes,
active_pane: None,
}],
pane_container,
primary_side,
hypr: hypr.clone(),
active_workspace,
next_id,
hidden_workspaces,
widths,
last_root: session.last_root.clone(),
bookmarks,
pane_drag: None,
save_generation: Rc::new(Cell::new(0)),
sender: sender.clone(),
};
if let Some(hypr) = &hypr {
let workspace_sender = sender.clone();
hypr.spawn_watcher(move |name| {
let sender = workspace_sender.clone();
glib::MainContext::default().invoke(move || {
sender.input(AppMsg::ActiveWorkspace(name));
});
});
}
init_layer_window(&model.window, &model.config, primary_side, primary_width, None);
install_css(&model.config);
if layer_shell_available() {
attach_resize_controls(&model.window, primary_side, &sender);
}
if let Some(listener) = init.listener {
let ipc_sender = sender.input_sender().clone();
ipc::spawn_listener(listener, move |command| {
let sender = ipc_sender.clone();
glib::MainContext::default().invoke(move || {
let _ = sender.send(AppMsg::LaunchRequest { command });
});
});
}
let widgets = view_output!();
{
let sender = sender.clone();
model.window.connect_map(move |_| {
let sender = sender.clone();
sender.input(AppMsg::ResolveDockMonitor { di: 0 });
glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
});
}
model.refresh_all_bookmarks(&sender);
let shutting_down = Rc::new(Cell::new(false));
let flag = shutting_down.clone();
let shutdown_sender = sender.clone();
model.window.connect_close_request(move |_| {
if flag.get() {
return glib::Propagation::Proceed;
}
flag.set(true);
shutdown_sender.input(AppMsg::Shutdown);
glib::Propagation::Stop
});
ComponentParts { model, widgets }
}
fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
match msg {
AppMsg::PaneToolbar { id, out } => match out {
ToolbarOutput::OpenFolder => {
self.set_active(id);
self.show_open_folder(id, &sender);
}
ToolbarOutput::NavigateTo(path) => {
self.open_root_in_pane(id, path);
}
ToolbarOutput::FilterRequested => {
self.set_active(id);
self.open_filter(id);
}
ToolbarOutput::SplitView => {
sender.input(AppMsg::SplitFromPane { id });
}
ToolbarOutput::Collapse => {
self.collapse_dock(id);
}
ToolbarOutput::ClosePane => {
sender.input(AppMsg::ClosePane { id });
}
ToolbarOutput::PaneItem(action) => self.run_pane_shortcut(id, action, &sender),
},
AppMsg::PaneTree { id, out } => match out {
TreeOutput::Status(message) => {
self.set_active(id);
self.status = message;
}
TreeOutput::OpenFolderRequested => {
self.set_active(id);
self.show_open_folder(id, &sender);
}
TreeOutput::OpenSplit(root) => {
self.set_active(id);
sender.input(AppMsg::OpenSplitFrom { id, root });
}
TreeOutput::OpenOpposite(root) => {
self.set_active(id);
sender.input(AppMsg::OpenOppositeFrom { id, root });
}
TreeOutput::PaneAction(action) => {
self.dispatch_pane_builtin(id, action, &sender);
}
TreeOutput::AddBookmark(path) => {
self.set_active(id);
self.add_bookmark(path);
self.refresh_all_bookmarks(&sender);
}
TreeOutput::RootChanged(root) => {
self.set_active(id);
if let Some((di, pi)) = self.dock_pane_of(id) {
let pane = &mut self.docks[di].panes[pi];
pane.history.record(ViewEntry::Dir(root.clone()));
pane.history.finish_navigation();
pane.show_tree();
pane.set_root(root.clone());
pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
}
self.status = String::new();
self.last_root = Some(root);
self.persist_session();
}
},
AppMsg::PaneShortcut { id, action } => {
self.run_pane_shortcut(id, action, &sender);
}
AppMsg::OpenFolderPicked { id, path: Some(path) } => {
self.open_root_in_pane(id, path);
}
AppMsg::OpenFolderPicked { path: None, .. } => {}
AppMsg::LaunchRequest { command } => {
self.handle_launch(command, &sender);
}
AppMsg::ActiveWorkspace(name) => self.on_active_workspace(name),
AppMsg::SplitFromPane { id } => {
let di = self.dock_pane_of(id).map(|(di, _)| di).unwrap_or(0);
self.add_pane(di, None, &sender);
}
AppMsg::OpenSplitFrom { id, root } => {
if let Some((di, _pi)) = self.dock_pane_of(id) {
self.add_pane(di, Some(root), &sender);
}
}
AppMsg::OpenOppositeFrom { id, root } => {
if let Some((di, _pi)) = self.dock_pane_of(id) {
let side = self.docks[di].side.opposite();
let monitor = self.docks[di].monitor.clone();
let target = self.ensure_dock(side, monitor, false, &sender);
self.add_pane(target, Some(root), &sender);
let visible = self.visible();
self.set_docks_visible(visible);
}
}
AppMsg::ClosePane { id } => {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
self.docks[di].panes[pi].tree.emit(TreeMsg::Suspend);
self.docks[di].panes.remove(pi);
if self.docks[di].active_pane == Some(id) {
self.docks[di].active_pane = self.last_active_pane_id(di);
}
let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
if panes_left == 0 {
self.window.close();
return;
}
if self.docks[di].panes.is_empty() && di != 0 {
let dock = self.docks.remove(di);
dock.window.close();
} else {
fill_pane_container(
&self.docks[di].container.clone(),
&self.docks[di].panes,
&self.active_workspace,
);
let shown = self.visible() && self.dock_has_active_pane(di);
self.docks[di].window.set_visible(shown);
}
}
AppMsg::FilterChanged { id, filter } => {
if let Some((di, pi)) = self.dock_pane_of(id) {
if self.docks[di].panes[pi].on_bookmarks() {
self.docks[di].panes[pi].bookmark_filter = filter;
self.refresh_all_bookmarks(&sender);
} else {
self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
}
}
}
AppMsg::FilterClosed { id } => {
if let Some((di, pi)) = self.dock_pane_of(id) {
let pane = &mut self.docks[di].panes[pi];
pane.filter_entry.set_text("");
pane.tree.emit(TreeMsg::SetFilter(String::new()));
pane.bookmark_filter.clear();
pane.filter_bar.set_visible(false);
}
self.refresh_all_bookmarks(&sender);
}
AppMsg::FocusPane { id } => {
self.focus_pane(id);
}
AppMsg::FocusVisible => {
let ids: Vec<u64> = (0..self.docks.len())
.filter(|&di| self.docks[di].window.is_visible())
.filter_map(|di| self.last_active_pane_id(di))
.collect();
for id in ids {
self.focus_pane(id);
}
}
AppMsg::Shutdown => {
for dock in &self.docks {
stop_video_widgets(dock.window.upcast_ref());
}
for dock in &self.docks {
for pane in &dock.panes {
pane.tree.emit(TreeMsg::Shutdown);
}
}
let window = self.window.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
window.close();
});
}
AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
AppMsg::ResizeCommit => self.persist_session(),
AppMsg::ResolveDockMonitor { di } => {
let Some(dock) = self.docks.get(di) else { return };
if dock.monitor.is_some() {
return;
}
let (Some(surface), Some(display)) =
(dock.window.surface(), gdk::Display::default())
else {
return;
};
if let Some(monitor) = display.monitor_at_surface(&surface) {
self.docks[di].monitor = Some(monitor);
}
}
AppMsg::PaneDragBegin { id, start } => self.begin_pane_drag(id, start),
AppMsg::PaneDragUpdate { id, offset } => self.update_pane_drag(id, offset),
AppMsg::PaneDragEnd { id, offset } => self.end_pane_drag(id, offset, &sender),
AppMsg::BookmarkEvent { id, event } => match event {
BookmarkEvent::Open(path) => self.open_bookmark(id, path, &sender),
BookmarkEvent::Toggle(index_path) => {
if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
entry.expanded = !entry.expanded;
}
self.refresh_all_bookmarks(&sender);
}
BookmarkEvent::Edit(index_path) => self.edit_bookmark(id, index_path, &sender),
BookmarkEvent::NewBookmark => self.new_bookmark(id, &sender),
BookmarkEvent::NewBookmarkFolder => self.new_bookmark_folder(id, &sender),
BookmarkEvent::Action { path, target } => {
self.run_bookmark_action(id, path, target);
}
BookmarkEvent::Move { from, to } => self.move_bookmark(from, to, &sender),
BookmarkEvent::Delete(index_path) => {
if Bookmark::remove(&mut self.bookmarks, &index_path) {
self.save_bookmarks();
self.refresh_all_bookmarks(&sender);
}
}
},
AppMsg::BookmarkEditSaved { index_path, name, path } => {
if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
entry.name = name;
if path.is_some() {
entry.path = path;
}
self.save_bookmarks();
self.refresh_all_bookmarks(&sender);
}
}
AppMsg::BookmarkAdded { name, path } => {
if Bookmark::contains_path(&self.bookmarks, &path) {
self.status = format!("{} is already bookmarked", path.display());
return;
}
self.bookmarks.push(Bookmark::leaf(name, path));
self.save_bookmarks();
self.refresh_all_bookmarks(&sender);
}
AppMsg::BookmarkFolderAdded { name } => {
self.bookmarks.push(Bookmark {
name,
path: None,
items: Vec::new(),
expanded: false,
});
self.save_bookmarks();
self.refresh_all_bookmarks(&sender);
}
}
}
}
impl App {
fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
if let Some(accel) = command.key.clone() {
match self.active_pane_id(command.side) {
Some(id) => {
self.run_accelerator(id, &accel, sender);
return;
}
None if !self.docks.is_empty() => return,
None => {}
}
}
let hidden = command.hidden;
if let Some(width) = command.width {
let side = command.side.unwrap_or(self.primary_side);
match width {
WidthArg::To(px) => self.set_width(side, px),
WidthArg::By(delta) => self.resize_by(side, delta),
}
self.persist_session();
if command.roots.is_empty() && command.reveal.is_empty() {
return;
}
}
if command.roots.is_empty() && command.reveal.is_empty() {
let intent = match (command.side, hidden) {
(Some(side), true) => VisibilityIntent::HideSide(side),
(Some(side), false) => VisibilityIntent::ToggleSide(side),
(None, true) => VisibilityIntent::HideAll,
(None, false) => VisibilityIntent::ToggleAll,
};
self.apply_visibility(intent, sender);
return;
}
for root in &command.roots {
if self.find_pane_with_dir(root).is_some() {
continue;
}
let side = command.side.unwrap_or(self.primary_side);
let monitor = self.default_monitor();
let di = self.ensure_dock(side, monitor, false, sender);
self.add_pane(di, Some(root.clone()), sender);
}
for (root, select) in command.reveal_targets() {
if !root.is_dir() {
continue;
}
let side = command.side.unwrap_or(self.primary_side);
let monitor = self.default_monitor();
let di = self.ensure_dock(side, monitor, false, sender);
let id = self.add_pane(di, Some(root), sender);
if let Some(select) = select
&& let Some((di, pi)) = self.dock_pane_of(id)
{
self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
}
}
self.set_docks_visible(!hidden);
}
fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
let mut existing: Vec<PanelSide> = Vec::new();
let mut shown: Vec<PanelSide> = Vec::new();
for dock in &self.docks {
if !existing.contains(&dock.side) {
existing.push(dock.side);
}
if dock.window.is_visible() && !shown.contains(&dock.side) {
shown.push(dock.side);
}
}
let plan = resolve_visibility(intent, &existing, &shown);
let whole_panel =
matches!(intent, VisibilityIntent::ShowAll | VisibilityIntent::ToggleAll);
let panes_exist = self
.docks
.iter()
.flat_map(|dock| &dock.panes)
.any(|pane| pane.workspace == self.active_workspace);
let seed_empty = should_seed_empty(panes_exist, plan.seed, whole_panel);
if let Some(side) = plan.create {
let monitor = self.default_monitor();
self.ensure_dock(side, monitor, plan.seed, sender);
}
for side in &plan.show {
if self.dock_of_side(*side).is_none() {
let monitor = self.default_monitor();
self.ensure_dock(*side, monitor, seed_empty || !whole_panel, sender);
}
let indices: Vec<usize> = self
.docks
.iter()
.enumerate()
.filter(|(_, dock)| dock.side == *side)
.map(|(di, _)| di)
.collect();
for di in indices {
if seed_empty && !self.dock_has_active_pane(di) {
let startup = self.config.startup.clone();
match default_root(&startup) {
Some(root) => self.add_pane(di, Some(root), sender),
None => self.add_pane(di, None, sender),
};
}
let show = self.dock_has_active_pane(di);
self.docks[di].window.set_visible(show);
}
}
for side in &plan.hide {
let indices: Vec<usize> = self
.docks
.iter()
.enumerate()
.filter(|(_, dock)| dock.side == *side)
.map(|(di, _)| di)
.collect();
for di in indices {
self.docks[di].window.set_visible(false);
self.suspend_dock_panes(di);
}
}
self.refresh_visible();
let focus: Vec<u64> = plan
.show
.iter()
.flat_map(|side| {
self.docks
.iter()
.filter(move |dock| dock.side == *side)
.filter_map(|dock| dock.panes.last())
.map(|pane| pane.id)
})
.collect();
for id in focus {
self.focus_pane(id);
}
}
fn set_active(&mut self, id: u64) {
if let Some((di, _pi)) = self.dock_pane_of(id) {
self.docks[di].active_pane = Some(id);
}
}
fn focus_pane(&mut self, id: u64) {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
let pane = &self.docks[di].panes[pi];
if pane.on_bookmarks() {
pane.bookmark_nav.focus_start();
} else {
pane.tree.emit(TreeMsg::Focus);
}
}
fn focus_pane_later(&self, id: u64) {
let sender = self.sender.clone();
glib::idle_add_local_once(move || sender.input(AppMsg::FocusPane { id }));
}
fn open_filter(&mut self, id: u64) {
if let Some((di, pi)) = self.dock_pane_of(id) {
let pane = &mut self.docks[di].panes[pi];
pane.filter_bar.set_visible(true);
let entry = pane.filter_entry.clone();
glib::idle_add_local_once(move || {
entry.grab_focus();
});
}
}
fn add_bookmark(&mut self, path: PathBuf) {
if Bookmark::contains_path(&self.bookmarks, &path) {
self.status = format!("{} is already bookmarked", path.display());
return;
}
self.bookmarks.push(Bookmark::leaf(Bookmark::default_name(&path), path.clone()));
self.save_bookmarks();
self.status = format!("Bookmarked {}", path.display());
}
fn open_root_in_pane(&mut self, id: u64, path: PathBuf) {
if let Some((di, pi)) = self.dock_pane_of(id) {
self.docks[di].panes[pi].show_tree();
self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
self.set_active(id);
}
self.focus_pane_later(id);
}
fn open_bookmark(&mut self, id: u64, path: PathBuf, _sender: &ComponentSender<Self>) {
let path = crate::config::expand_bookmark_path(&path);
if !path.is_dir() {
self.status = format!("{} is not a directory", path.display());
return;
}
self.open_root_in_pane(id, path);
}
fn run_bookmark_action(&mut self, id: u64, path: PathBuf, target: ShortcutTarget) {
let path = crate::config::expand_bookmark_path(&path);
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
match target {
ShortcutTarget::Builtin(BuiltinAction::Open) => {
if path.is_dir() {
self.open_root_in_pane(id, path);
} else {
self.docks[di].panes[pi].tree.emit(TreeMsg::OpenWithDefault(path));
}
}
ShortcutTarget::Builtin(action) => {
if let Some(msg) = crate::ui::tree::path_action_message(action, &path) {
self.docks[di].panes[pi].tree.emit(msg);
}
}
ShortcutTarget::Command(cmd) => {
self.docks[di].panes[pi]
.tree
.emit(TreeMsg::RunCommand { command: cmd.command, path });
}
}
}
fn show_bookmarks_view(&mut self, id: u64) {
if let Some((di, pi)) = self.dock_pane_of(id) {
self.docks[di].panes[pi].show_bookmarks();
self.docks[di].panes[pi].history.record(ViewEntry::Bookmarks);
self.docks[di].panes[pi].refresh_nav();
self.set_active(id);
}
self.focus_pane_later(id);
}
fn edit_bookmark(&mut self, id: u64, index_path: Vec<usize>, sender: &ComponentSender<Self>) {
let Some(bookmark) = Bookmark::get(&self.bookmarks, &index_path).cloned() else {
return;
};
let Some((di, _pi)) = self.dock_pane_of(id) else { return };
let parent = self.docks[di].window.clone();
let sender = sender.clone();
let path = bookmark.path.as_deref().map(crate::config::expand_bookmark_path);
let title = if bookmark.is_folder() { "Edit Folder" } else { "Edit Bookmark" };
bookmarks::show_bookmark_dialog(
&parent,
title,
&bookmark.name,
path.as_deref(),
move |name, path| {
sender.input(AppMsg::BookmarkEditSaved { index_path: index_path.clone(), name, path });
},
);
}
fn new_bookmark(&mut self, id: u64, sender: &ComponentSender<Self>) {
let Some((di, _pi)) = self.dock_pane_of(id) else { return };
let parent = self.docks[di].window.clone();
let sender = sender.clone();
bookmarks::show_bookmark_dialog(
&parent,
"New Bookmark",
"",
Some(Path::new("")),
move |name, path| {
if let Some(path) = path {
sender.input(AppMsg::BookmarkAdded { name, path });
}
},
);
}
fn new_bookmark_folder(&mut self, id: u64, sender: &ComponentSender<Self>) {
let Some((di, _pi)) = self.dock_pane_of(id) else { return };
let parent = self.docks[di].window.clone();
let sender = sender.clone();
bookmarks::show_bookmark_dialog(&parent, "New Folder", "", None, move |name, _path| {
sender.input(AppMsg::BookmarkFolderAdded { name });
});
}
fn move_bookmark(&mut self, from: Vec<usize>, to: MoveTarget, sender: &ComponentSender<Self>) {
if from.is_empty() {
return;
}
let (parent, index) = match &to {
MoveTarget::Root => (Vec::new(), usize::MAX),
MoveTarget::Into(folder) => {
if folder.starts_with(&from) {
return;
}
(adjust_path_after_removal(&from, folder), usize::MAX)
}
MoveTarget::Before(leaf) => {
if leaf.is_empty() || leaf.starts_with(&from) {
return;
}
let parent_orig = leaf[..leaf.len() - 1].to_vec();
let parent = adjust_path_after_removal(&from, &parent_orig);
let mut index = leaf[leaf.len() - 1];
if from.len() == leaf.len()
&& from[..from.len() - 1] == parent_orig[..]
&& from[from.len() - 1] < index
{
index -= 1;
}
(parent, index)
}
};
let Some(entry) = Bookmark::take(&mut self.bookmarks, &from) else {
return;
};
if !Bookmark::insert(&mut self.bookmarks, &parent, index, entry) {
return;
}
self.save_bookmarks();
self.refresh_all_bookmarks(sender);
}
fn save_bookmarks(&mut self) {
let path = bookmark_file_path(&self.config.bookmarks.file);
if let Err(err) = save_bookmarks_to_path(&path, &self.bookmarks) {
self.status = format!("Could not save bookmarks: {err:?}");
}
}
fn refresh_all_bookmarks(&mut self, sender: &ComponentSender<Self>) {
let context = self.config.context_menu.clone();
let extras = self.config.bookmarks.context.clone();
let bookmarks = self.bookmarks.clone();
for di in 0..self.docks.len() {
for pi in 0..self.docks[di].panes.len() {
let pane = &self.docks[di].panes[pi];
let menu = bookmarks::BookmarkMenuConfig {
context: &context,
extras: &extras,
side: self.docks[di].side,
};
let id = pane.id;
let filter = pane.bookmark_filter.clone();
let sender = sender.clone();
let on_event: Rc<dyn Fn(BookmarkEvent)> =
Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
bookmarks::fill_bookmarks(
&pane.bookmarks_list,
&bookmarks,
&filter,
&menu,
&pane.bookmark_drag,
&pane.bookmark_nav,
on_event,
);
}
}
}
fn dispatch_pane_builtin(
&mut self,
id: u64,
action: BuiltinAction,
sender: &ComponentSender<Self>,
) {
self.set_active(id);
match action {
BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
BuiltinAction::Filter => self.open_filter(id),
BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
BuiltinAction::Up => self.go_up(id),
BuiltinAction::Back => self.go_back(id),
BuiltinAction::Forward => self.go_forward(id),
BuiltinAction::Collapse => self.collapse_dock(id),
BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
BuiltinAction::ToggleBookmarks => self.show_bookmarks_view(id),
BuiltinAction::NewBookmark => self.new_bookmark(id, sender),
BuiltinAction::NewBookmarkFolder => self.new_bookmark_folder(id, sender),
_ => {}
}
}
fn go_up(&mut self, id: u64) {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
let Some(parent) = self.docks[di].panes[pi]
.root
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)
else {
return;
};
self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
self.focus_pane_later(id);
}
fn go_back(&mut self, id: u64) {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
if let Some(target) = self.docks[di].panes[pi].history.back() {
self.show_view_entry(id, target);
}
}
fn go_forward(&mut self, id: u64) {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
if let Some(target) = self.docks[di].panes[pi].history.forward() {
self.show_view_entry(id, target);
}
}
fn show_view_entry(&mut self, id: u64, entry: ViewEntry) {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
match entry {
ViewEntry::Dir(path) => {
self.docks[di].panes[pi].show_tree();
self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
}
ViewEntry::Bookmarks => {
self.docks[di].panes[pi].show_bookmarks();
self.docks[di].panes[pi].history.finish_navigation();
self.docks[di].panes[pi].refresh_nav();
}
}
self.set_active(id);
self.focus_pane_later(id);
}
fn run_pane_shortcut(
&mut self,
id: u64,
action: ContextAction,
sender: &ComponentSender<Self>,
) {
self.set_active(id);
if let ContextAction::Entry(entry) = &action
&& entry.action == BuiltinAction::MoveToWorkspace
&& entry.workspace.is_none()
{
self.status = "Move to Workspace needs a workspace = \"...\" target".to_owned();
return;
}
if let Some(target) = action.workspace_move() {
self.move_active_pane_to_workspace(target);
return;
}
let Some(target) = ShortcutTarget::from_action(&action) else {
return;
};
if let ShortcutTarget::Builtin(builtin) = &target
&& builtin.is_pane_action()
{
self.dispatch_pane_builtin(id, *builtin, sender);
return;
}
if let Some((di, pi)) = self.dock_pane_of(id)
&& let Some(msg) = pane_item_message(&target)
{
self.docks[di].panes[pi].tree.emit(msg);
}
}
fn active_pane_id(&self, side: Option<PanelSide>) -> Option<u64> {
let di = match side {
Some(side) => self.dock_of_side(side)?,
None => self
.dock_of_side(self.primary_side)
.or_else(|| self.docks.iter().position(|dock| dock.window.is_visible()))
.or_else(|| (!self.docks.is_empty()).then_some(0))?,
};
let dock = &self.docks[di];
let active = &self.active_workspace;
dock.active_pane
.filter(|id| {
dock.panes.iter().any(|pane| pane.id == *id && &pane.workspace == active)
})
.or_else(|| {
dock.panes
.iter()
.rev()
.find(|pane| &pane.workspace == active)
.map(|pane| pane.id)
})
}
fn run_accelerator(&mut self, id: u64, accel: &str, sender: &ComponentSender<Self>) {
let Some((key, mods)) = parse_accelerator(accel) else {
return;
};
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
if let Some(action) = self.docks[di].panes[pi].shortcuts.action_for(key, mods) {
self.run_pane_shortcut(id, action, sender);
return;
}
if self.docks[di].panes[pi].on_bookmarks() {
if bookmarks::BookmarkNav::is_nav_key(key) {
if let Some(node) = self.docks[di].panes[pi].bookmark_nav.navigate(key) {
sender.input(AppMsg::BookmarkEvent {
id,
event: BookmarkEvent::Toggle(node.index_path),
});
}
return;
}
if matches!(key, gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::space) {
if let Some(event) = self.docks[di].panes[pi].bookmark_nav.activate() {
sender.input(AppMsg::BookmarkEvent { id, event });
}
return;
}
}
self.docks[di].panes[pi]
.tree
.emit(TreeMsg::RunAccelerator(accel.to_owned()));
}
fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
self.docks.iter().enumerate().find_map(|(di, dock)| {
dock.panes
.iter()
.position(|pane| pane.id == id)
.map(|pi| (di, pi))
})
}
fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
self.docks.iter().enumerate().find_map(|(di, dock)| {
dock.panes.iter().enumerate().find_map(|(pi, pane)| {
if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
Some((di, pi))
} else {
None
}
})
})
}
fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
self.docks.iter().position(|dock| dock.side == side)
}
fn dock_index_of(&self, side: PanelSide, monitor: Option<&gdk::Monitor>) -> Option<usize> {
self.docks.iter().position(|dock| {
dock.side == side
&& match (&dock.monitor, monitor) {
(Some(a), Some(b)) => monitor_key(a) == monitor_key(b),
(None, None) => true,
_ => false,
}
})
}
fn default_monitor(&self) -> Option<gdk::Monitor> {
self.docks
.first()
.and_then(|dock| dock.monitor.clone())
.or_else(|| {
gdk::Display::default()?
.monitors()
.item(0)
.and_downcast::<gdk::Monitor>()
})
}
fn collapse_dock(&mut self, id: u64) {
if let Some((di, _)) = self.dock_pane_of(id) {
self.set_dock_visible(di, false);
}
}
fn set_dock_visible(&mut self, di: usize, visible: bool) {
let shown = visible && self.dock_has_active_pane(di);
self.docks[di].window.set_visible(shown);
if !shown {
self.suspend_dock_panes(di);
}
self.refresh_visible();
}
fn suspend_dock_panes(&self, di: usize) {
for pane in &self.docks[di].panes {
pane.tree.emit(TreeMsg::Suspend);
}
}
fn dock_has_active_pane(&self, di: usize) -> bool {
let active = &self.active_workspace;
self.docks[di].panes.iter().any(|pane| &pane.workspace == active)
}
fn last_active_pane_id(&self, di: usize) -> Option<u64> {
let active = &self.active_workspace;
self.docks[di]
.panes
.iter()
.rev()
.find(|pane| &pane.workspace == active)
.map(|pane| pane.id)
}
fn on_active_workspace(&mut self, name: String) {
if self.active_workspace == name {
return;
}
self.active_workspace = name;
self.refill_docks();
self.sync_dock_visibility();
let active = (0..self.docks.len())
.find(|&di| self.docks[di].window.is_visible())
.and_then(|di| self.last_active_pane_id(di));
if let Some(id) = active {
self.focus_pane(id);
}
}
fn refill_docks(&mut self) {
for dock in &self.docks {
fill_pane_container(&dock.container, &dock.panes, &self.active_workspace);
}
}
fn visible(&self) -> bool {
!self.hidden_workspaces.contains(&self.active_workspace)
}
fn set_visible(&mut self, visible: bool) {
if visible {
self.hidden_workspaces.remove(&self.active_workspace);
} else {
self.hidden_workspaces.insert(self.active_workspace.clone());
}
}
fn sync_dock_visibility(&mut self) {
let show = self.visible();
for di in 0..self.docks.len() {
let shown = show && self.dock_has_active_pane(di);
self.docks[di].window.set_visible(shown);
if !shown {
self.suspend_dock_panes(di);
}
}
}
fn set_docks_visible(&mut self, visible: bool) {
self.set_visible(visible);
self.sync_dock_visibility();
if visible {
let focus: Vec<u64> = (0..self.docks.len())
.filter(|&di| self.docks[di].window.is_visible())
.filter_map(|di| self.last_active_pane_id(di))
.collect();
for id in focus {
self.focus_pane(id);
}
}
}
fn move_active_pane_to_workspace(&mut self, target: WorkspaceMove) {
if self.hypr.is_none() {
self.status = "Workspace moves need a Hyprland session".to_owned();
return;
}
let Some(id) = self.active_pane_id(None) else { return };
let Some((di, pi)) = self.dock_pane_of(id) else { return };
let from = self.docks[di].panes[pi].workspace.clone();
let name = match target {
WorkspaceMove::Named(name) => name,
WorkspaceMove::Previous => self.neighbor_workspace(&from, -1),
WorkspaceMove::Next => self.neighbor_workspace(&from, 1),
};
if name.is_empty() || name == from {
return;
}
self.docks[di].panes[pi].workspace = name.clone();
let container = self.docks[di].container.clone();
fill_pane_container(&container, &self.docks[di].panes, &self.active_workspace);
self.sync_dock_visibility();
self.status = format!("Pane moved to workspace {name}");
}
fn neighbor_workspace(&self, from: &str, delta: i32) -> String {
if let Ok(id) = from.parse::<i64>() {
return (id + delta as i64).max(1).to_string();
}
let Some(hypr) = &self.hypr else { return String::new() };
let names = hypr.workspace_names();
if names.is_empty() {
return from.to_owned();
}
match names.iter().position(|name| name == from) {
Some(i) => {
let j = (i as i32 + delta).rem_euclid(names.len() as i32) as usize;
names[j].clone()
}
None => names[0].clone(),
}
}
fn refresh_visible(&mut self) {
let any = self.docks.iter().any(|dock| dock.window.is_visible());
self.set_visible(any);
}
fn width_for(&self, side: PanelSide) -> u32 {
self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
}
fn set_width(&mut self, side: PanelSide, width: u32) {
let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
if width == self.width_for(side) {
return;
}
self.widths.insert(side, width);
let panel = PanelConfig { width, ..self.config.panel };
for dock in self.docks.iter().filter(|dock| dock.side == side) {
apply_window_width(&dock.window, width);
for pane in &dock.panes {
pane.tree.emit(TreeMsg::SetPanel(panel));
}
}
if side == self.primary_side {
self.config.panel.width = width;
}
self.schedule_save();
}
fn resize_by(&mut self, side: PanelSide, delta: i32) {
let target = (self.width_for(side) as i64 + delta as i64)
.clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
self.set_width(side, target);
}
fn session_state(&self) -> SessionState {
SessionState {
last_root: self.last_root.clone(),
left_width: Some(self.width_for(PanelSide::Left)),
right_width: Some(self.width_for(PanelSide::Right)),
}
}
fn persist_session(&mut self) {
self.save_generation.set(self.save_generation.get().wrapping_add(1));
if let Err(err) = self.session_state().save() {
self.status = format!("Could not save session: {err}");
}
}
fn schedule_save(&mut self) {
let generation = self.save_generation.get().wrapping_add(1);
self.save_generation.set(generation);
let current = self.save_generation.clone();
let state = self.session_state();
glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
if current.get() == generation {
let _ = state.save();
}
});
}
fn ensure_dock(
&mut self,
side: PanelSide,
monitor: Option<gdk::Monitor>,
seed: bool,
sender: &ComponentSender<Self>,
) -> usize {
if let Some(di) = self.dock_index_of(side, monitor.as_ref()) {
if seed && self.docks[di].panes.is_empty() {
let startup = self.config.startup.clone();
if let Some(root) = default_root(&startup) {
self.add_pane(di, Some(root), sender);
}
}
return di;
}
let config = &self.config;
let width = self.width_for(side);
let window = gtk::Window::new();
init_layer_window(&window, config, side, width, monitor.as_ref());
window.set_default_size(width as i32, 520);
if layer_shell_available() {
attach_resize_controls(&window, side, sender);
}
let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);
outer.set_vexpand(true);
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
container.set_vexpand(true);
outer.append(&container);
window.set_child(Some(&outer));
let di = self.docks.len();
self.docks.push(Dock {
side,
monitor,
window,
container,
panes: Vec::new(),
active_pane: None,
});
{
let sender = sender.clone();
let window = self.docks[di].window.clone();
window.connect_map(move |_| {
let sender = sender.clone();
sender.input(AppMsg::ResolveDockMonitor { di });
glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
});
}
if seed {
let startup = self.config.startup.clone();
if let Some(root) = default_root(&startup) {
self.add_pane(di, Some(root), sender);
} else {
self.add_pane(di, None, sender);
}
}
di
}
fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
let id = self.next_id;
self.next_id += 1;
let parent = self.docks[di].window.clone();
let side = self.docks[di].side;
let width = self.width_for(side);
let workspace = self.active_workspace.clone();
let pane = make_pane(&self.config, parent, id, side, width, workspace, sender.clone());
let mut pane = pane;
match &root {
Some(root) => pane.tree.emit(TreeMsg::OpenRoot(root.clone())),
None => {
pane.show_bookmarks();
pane.history.record(ViewEntry::Bookmarks);
pane.refresh_nav();
}
}
if let Some(root) = &root {
pane.set_root(root.clone());
}
let dock = &mut self.docks[di];
dock.panes.push(pane);
dock.active_pane = Some(id);
let container = dock.container.clone();
fill_pane_container(&container, &dock.panes, &self.active_workspace);
let focus_sender = sender.clone();
glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
self.refresh_all_bookmarks(sender);
id
}
fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
let sender = sender.clone();
self.file_dialog.select_folder(
None::<>k::Window>,
None::<&gio::Cancellable>,
move |result| {
let path = result.ok().and_then(|file| file.path());
sender.input(AppMsg::OpenFolderPicked { id, path });
},
);
}
fn dock_origin(&self, di: usize) -> (f64, f64) {
let dock = &self.docks[di];
let margin = self.config.panel.margin as f64;
let width = self.width_for(dock.side) as f64;
let (mx, my, mw) = match dock.monitor.as_ref().map(gdk::Monitor::geometry) {
Some(g) => (g.x() as f64, g.y() as f64, g.width() as f64),
None => (0.0, 0.0, 0.0),
};
let x = match dock.side {
PanelSide::Left => mx + margin,
PanelSide::Right => mx + mw - width - margin,
};
(x, my + margin)
}
fn point_target(&self, point: (f64, f64)) -> Option<(PanelSide, gdk::Monitor)> {
let (x, y) = point;
let monitors = gdk::Display::default()?.monitors();
for i in 0..monitors.n_items() {
let Some(monitor) = monitors.item(i).and_downcast::<gdk::Monitor>() else {
continue;
};
let g = monitor.geometry();
let (x0, y0) = (g.x() as f64, g.y() as f64);
let (w, h) = (g.width() as f64, g.height() as f64);
if x >= x0 && x < x0 + w && y >= y0 && y < y0 + h {
let side = if x < x0 + w / 2.0 { PanelSide::Left } else { PanelSide::Right };
return Some((side, monitor));
}
}
None
}
fn begin_pane_drag(&mut self, id: u64, start: (f64, f64)) {
let Some((di, pi)) = self.dock_pane_of(id) else { return };
let grip_origin = {
let grip = &self.docks[di].panes[pi].grip;
let window = &self.docks[di].window;
grip.compute_point(window, &relm4::gtk::graphene::Point::new(0.0, 0.0))
.map(|p| (p.x() as f64, p.y() as f64))
.unwrap_or((0.0, 0.0))
};
let origin = self.dock_origin(di);
let base = (origin.0 + grip_origin.0 + start.0, origin.1 + grip_origin.1 + start.1);
self.docks[di].panes[pi].widget.add_css_class("pane-moving");
self.pane_drag = Some(PaneDrag { id, base, highlighted: None });
self.status = "Drag the pane to the edge of a screen to move it".to_owned();
}
fn update_pane_drag(&mut self, id: u64, offset: (f64, f64)) {
let Some(drag) = self.pane_drag.as_ref() else { return };
if drag.id != id {
return;
}
let point = (drag.base.0 + offset.0, drag.base.1 + offset.1);
let target = self.point_target(point);
let highlight = target
.as_ref()
.and_then(|(side, monitor)| self.dock_index_of(*side, Some(monitor)));
if let Some(drag) = self.pane_drag.as_mut()
&& drag.highlighted != highlight
{
if let Some(di) = drag.highlighted
&& let Some(dock) = self.docks.get(di)
{
dock.window.remove_css_class("pane-drop-target");
}
if let Some(di) = highlight
&& let Some(dock) = self.docks.get(di)
{
dock.window.add_css_class("pane-drop-target");
}
drag.highlighted = highlight;
}
self.status = match target {
Some((side, monitor)) => format!(
"Drop to move the pane to the {} panel on {}",
side.name(),
monitor_label(&monitor)
),
None => "Drag the pane to the edge of a screen to move it".to_owned(),
};
}
fn end_pane_drag(&mut self, id: u64, offset: (f64, f64), sender: &ComponentSender<Self>) {
let Some(drag) = self.pane_drag.take() else { return };
if drag.id != id {
self.pane_drag = Some(drag);
return;
}
if let Some((di, pi)) = self.dock_pane_of(id) {
self.docks[di].panes[pi].widget.remove_css_class("pane-moving");
}
if let Some(di) = drag.highlighted
&& let Some(dock) = self.docks.get(di)
{
dock.window.remove_css_class("pane-drop-target");
}
let point = (drag.base.0 + offset.0, drag.base.1 + offset.1);
match self.point_target(point) {
Some((side, monitor)) => self.move_pane_to(id, side, monitor, sender),
None => self.status.clear(),
}
}
fn move_pane_to(
&mut self,
id: u64,
side: PanelSide,
monitor: gdk::Monitor,
sender: &ComponentSender<Self>,
) {
let Some((sdi, spi)) = self.dock_pane_of(id) else { return };
if self.dock_index_of(side, Some(&monitor)) == Some(sdi) {
self.status.clear();
return;
}
let pane = self.docks[sdi].panes.remove(spi);
remove_from_parent(pane.widget.upcast_ref());
if self.docks[sdi].active_pane == Some(id) {
self.docks[sdi].active_pane = self.docks[sdi].panes.last().map(|p| p.id);
}
if self.docks[sdi].panes.is_empty() {
if sdi == 0 {
self.docks[sdi].window.set_visible(false);
} else {
let dock = self.docks.remove(sdi);
dock.window.close();
}
} else {
let container = self.docks[sdi].container.clone();
fill_pane_container(&container, &self.docks[sdi].panes, &self.active_workspace);
}
let tdi = self.ensure_dock(side, Some(monitor), false, sender);
pane.tree.emit(TreeMsg::SetSide(side));
self.docks[tdi].panes.push(pane);
self.docks[tdi].active_pane = Some(id);
let container = self.docks[tdi].container.clone();
fill_pane_container(&container, &self.docks[tdi].panes, &self.active_workspace);
self.sync_dock_visibility();
self.refresh_all_bookmarks(sender);
self.focus_pane_later(id);
self.status = format!("Moved pane to the {} panel", side.name());
}
}
fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
match target {
ShortcutTarget::Builtin(action) => match action {
BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
other => Some(TreeMsg::RunShortcut {
target: ShortcutTarget::Builtin(*other),
}),
},
ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
}
}
fn build_nav_bar(id: u64, sender: &ComponentSender<App>) -> (gtk::Box, NavButtons) {
let bar = gtk::Box::new(gtk::Orientation::Horizontal, 2);
bar.add_css_class("nav-toolbar");
let up = nav_button("pan-up-symbolic", "Up One Level", BuiltinAction::Up, id, sender);
let back = nav_button("pan-start-symbolic", "Back", BuiltinAction::Back, id, sender);
let forward = nav_button("pan-end-symbolic", "Forward", BuiltinAction::Forward, id, sender);
bar.append(&up);
bar.append(&back);
bar.append(&forward);
(bar, NavButtons { up, back, forward })
}
fn nav_button(
icon: &str,
tooltip: &str,
action: BuiltinAction,
id: u64,
sender: &ComponentSender<App>,
) -> gtk::Button {
let button = gtk::Button::from_icon_name(icon);
button.add_css_class("nav-button");
button.add_css_class("flat");
button.set_tooltip_text(Some(tooltip));
button.set_focusable(false);
button.set_can_focus(false);
button.set_valign(gtk::Align::Center);
let sender = sender.clone();
button.connect_clicked(move |_| {
sender.input(AppMsg::PaneToolbar {
id,
out: ToolbarOutput::PaneItem(ContextAction::Builtin(action)),
});
});
button
}
fn make_pane(
config: &Config,
parent: gtk::Window,
id: u64,
side: PanelSide,
width: u32,
workspace: String,
sender: ComponentSender<App>,
) -> Pane {
let overlay = gtk::Overlay::new();
let toolbar = Toolbar::builder()
.launch(ToolbarInit {
overlay: overlay.clone(),
pane_menu: config.pane_menu.clone(),
bookmarks_menu: config.bookmarks.menu.clone(),
})
.forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
let tree = Tree::builder()
.launch(TreeInit {
config: config.tree.clone(),
parent,
menu: config.context_menu.clone(),
side,
panel: PanelConfig { width, ..config.panel },
})
.forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
let grip = toolbar.model().grip().clone();
{
let drag = gtk::GestureDrag::new();
drag.set_button(gdk::BUTTON_PRIMARY);
drag.set_propagation_phase(gtk::PropagationPhase::Capture);
let s = sender.clone();
drag.connect_drag_begin(move |_, start_x, start_y| {
s.input(AppMsg::PaneDragBegin { id, start: (start_x, start_y) });
});
let s = sender.clone();
drag.connect_drag_update(move |_, offset_x, offset_y| {
s.input(AppMsg::PaneDragUpdate { id, offset: (offset_x, offset_y) });
});
let s = sender.clone();
drag.connect_drag_end(move |_, offset_x, offset_y| {
s.input(AppMsg::PaneDragEnd { id, offset: (offset_x, offset_y) });
});
grip.add_controller(drag);
}
let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
widget.append(toolbar.widget());
let nav_buttons = if config.panel.nav_toolbar {
let (bar, buttons) = build_nav_bar(id, &sender);
widget.append(&bar);
Some(buttons)
} else {
None
};
let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
filter_bar.add_css_class("filter-bar");
filter_bar.set_visible(false);
let filter_entry = gtk::SearchEntry::new();
filter_entry.set_placeholder_text(Some("Filter..."));
filter_entry.set_hexpand(true);
filter_entry.set_valign(gtk::Align::Center);
filter_entry.add_css_class("filter-entry");
{
let s = sender.clone();
filter_entry.connect_search_changed(move |entry| {
s.input(AppMsg::FilterChanged {
id,
filter: entry.text().to_string(),
});
});
}
filter_bar.append(&filter_entry);
let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
close_btn.set_tooltip_text(Some("Clear filter"));
close_btn.set_valign(gtk::Align::Center);
close_btn.add_css_class("flat");
{
let s = sender.clone();
close_btn.connect_clicked(move |_| {
s.input(AppMsg::FilterClosed { id });
});
}
filter_bar.append(&close_btn);
widget.append(&filter_bar);
let tree_widget = tree.widget();
tree_widget.set_vexpand(true);
let bookmarks_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
bookmarks_list.add_css_class("bookmarks-list");
let bookmarks_scroll = gtk::ScrolledWindow::new();
bookmarks_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
bookmarks_scroll.set_vexpand(true);
bookmarks_scroll.add_css_class("bookmarks-view");
bookmarks_scroll.set_child(Some(&bookmarks_list));
let bookmark_drag = bookmarks::BookmarkDrag::new();
let bookmark_nav = bookmarks::BookmarkNav::new(&bookmarks_list);
{
let items = config.bookmarks.blank.clone();
let sender = sender.clone();
let on_event: Rc<dyn Fn(BookmarkEvent)> =
Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
bookmarks::attach_bookmarks_scroller(&bookmarks_scroll, &items, side, &bookmark_drag, &bookmark_nav, on_event);
}
let body = gtk::Stack::new();
body.set_vexpand(true);
body.add_named(tree_widget, Some("tree"));
body.add_named(&bookmarks_scroll, Some("bookmarks"));
body.set_visible_child_name("tree");
widget.append(&body);
widget.set_vexpand(true);
overlay.set_child(Some(&widget));
let shortcuts = {
let mut items = config.pane_menu.items.clone();
items.extend(config.bookmarks.menu.iter().cloned());
let shortcuts = Rc::new(PaneShortcuts::compile(&items));
let s = sender.clone();
let bound = shortcuts.clone();
let keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
keys.connect_key_pressed(move |_, key, _, state| {
if let Some(action) = bound.action_for(key, state) {
s.input(AppMsg::PaneShortcut { id, action });
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
widget.add_controller(keys);
shortcuts
};
Pane {
id,
toolbar,
tree,
workspace,
root: None,
canonical_root: None,
history: NavHistory::default(),
filter_bar,
filter_entry,
body,
bookmarks_list,
bookmark_filter: String::new(),
bookmark_drag,
bookmark_nav,
nav_buttons,
widget: overlay,
grip,
shortcuts,
}
}
fn remove_from_parent(w: >k::Widget) {
let Some(parent) = w.parent() else { return };
if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
let none: Option<>k::Widget> = None;
if paned.start_child().is_some_and(|c| c == *w) {
paned.set_start_child(none);
}
let none: Option<>k::Widget> = None;
if paned.end_child().is_some_and(|c| c == *w) {
paned.set_end_child(none);
}
} else if let Ok(b) = parent.downcast::<gtk::Box>() {
b.remove(w);
} else {
w.unparent();
}
}
fn pane_widget(pane: &Pane) -> gtk::Widget {
pane.widget.clone().upcast()
}
fn fill_pane_container(container: >k::Box, panes: &[Pane], active: &str) {
for pane in panes {
remove_from_parent(pane.widget.upcast_ref());
}
while let Some(child) = container.first_child() {
container.remove(&child);
}
let visible: Vec<&Pane> = panes.iter().filter(|pane| pane.workspace == active).collect();
match visible.as_slice() {
[] => {}
[single] => {
let w = pane_widget(single);
w.set_vexpand(true);
container.append(&w);
}
visible => {
let n = visible.len();
let last_w = pane_widget(visible[n - 1]);
last_w.set_vexpand(true);
let mut right: gtk::Widget = last_w.clone().upcast();
for pane in visible[..n - 1].iter().rev() {
let left_w = pane_widget(pane);
left_w.set_vexpand(true);
let split = gtk::Paned::new(gtk::Orientation::Vertical);
split.set_vexpand(true);
split.set_wide_handle(true);
split.set_shrink_start_child(true);
split.set_shrink_end_child(true);
split.set_resize_start_child(true);
split.set_resize_end_child(true);
split.set_start_child(Some(&left_w));
split.set_end_child(Some(&right));
{
let split2 = split.clone();
relm4::gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
move || {
let alloc = split2.height();
if alloc > 0 {
split2.set_position(alloc / 2);
}
},
);
}
right = split.upcast();
}
container.append(&right);
}
}
}
fn stop_video_widgets(widget: >k::Widget) {
if let Some(video) = widget.downcast_ref::<gtk::Video>() {
crate::ui::stop_video_stream(video);
}
let mut child = widget.first_child();
while let Some(current) = child {
stop_video_widgets(¤t);
child = current.next_sibling();
}
}
const WIDTH_STEP: i32 = 24;
const SAVE_DEBOUNCE_MS: u64 = 300;
const PANED_CENTER_DELAY_MS: u64 = 100;
const SHUTDOWN_GRACE_MS: u64 = 150;
fn apply_window_width(window: >k::Window, width: u32) {
let (_, height) = window.default_size();
if window.is_layer_window() {
window.set_exclusive_zone(width as i32);
}
window.set_size_request(width as i32, -1);
window.set_default_size(width as i32, if height > 0 { height } else { -1 });
}
fn attach_resize_controls(window: >k::Window, side: PanelSide, sender: &ComponentSender<App>) {
attach_resize_drag(window, side, sender.input_sender().clone());
attach_resize_keys(window, side, sender.input_sender().clone());
}
fn attach_resize_drag(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
let drag = gtk::GestureDrag::new();
drag.set_button(gdk::BUTTON_SECONDARY);
drag.set_propagation_phase(gtk::PropagationPhase::Capture);
let state = Rc::new(RefCell::new((false, 0.0f64)));
let begin_state = state.clone();
drag.connect_drag_begin(move |gesture, _, _| {
let super_held = gesture
.current_event_state()
.contains(gdk::ModifierType::SUPER_MASK);
*begin_state.borrow_mut() = (super_held, 0.0);
});
let update_sender = sender.clone();
let update_state = state.clone();
drag.connect_drag_update(move |_, offset_x, _| {
let mut state = update_state.borrow_mut();
if !state.0 {
return;
}
let step = offset_x - state.1;
state.1 = offset_x;
let widen = match side {
PanelSide::Left => step,
PanelSide::Right => -step,
};
let delta = widen.round() as i32;
if delta != 0 {
let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
}
});
let end_sender = sender;
let end_state = state.clone();
drag.connect_drag_end(move |_, _, _| {
if end_state.borrow().0 {
let _ = end_sender.send(AppMsg::ResizeCommit);
}
});
window.add_controller(drag);
}
fn attach_resize_keys(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
let keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
keys.connect_key_pressed(move |_, key, _, state| {
if !state.contains(gdk::ModifierType::SUPER_MASK) {
return glib::Propagation::Proceed;
}
match key {
gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
glib::Propagation::Stop
}
gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
window.add_controller(keys);
}
fn layer_shell_available() -> bool {
static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
}
fn init_layer_window(
window: >k::Window,
config: &Config,
side: PanelSide,
width: u32,
monitor: Option<&gdk::Monitor>,
) {
if !layer_shell_available() {
configure_plain_window(window, width);
return;
}
if window.is_layer_window() {
return;
}
window.init_layer_shell();
window.set_namespace(Some(crate::ui::LAYER_NAMESPACE));
window.set_monitor(monitor);
window.set_layer(layer_of(config.panel.layer));
let edge = match side {
PanelSide::Left => Edge::Left,
PanelSide::Right => Edge::Right,
};
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Bottom, true);
window.set_anchor(edge, true);
window.set_exclusive_zone(width as i32);
window.set_size_request(width as i32, -1);
let margin = config.panel.margin as i32;
window.set_margin(Edge::Top, margin);
window.set_margin(Edge::Bottom, margin);
window.set_margin(Edge::Left, margin);
window.set_margin(Edge::Right, margin);
window.set_keyboard_mode(KeyboardMode::OnDemand);
let motion = gtk::EventControllerMotion::new();
{
let kb_window = window.clone();
motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
}
{
let kb_window = window.clone();
motion.connect_leave(move |_| {
if has_visible_popover(kb_window.upcast_ref()) {
return;
}
kb_window.set_keyboard_mode(KeyboardMode::None);
});
}
window.add_controller(motion);
}
fn configure_plain_window(window: >k::Window, width: u32) {
static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
ANNOUNCED.get_or_init(|| {
eprintln!(
"tree-space: this compositor does not support wlr-layer-shell, so the panel \
cannot dock; opening it as a normal window instead."
);
});
window.set_title(Some("tree-space"));
window.set_default_size(width as i32, 720);
window.set_size_request(width as i32, -1);
}
fn has_visible_popover(widget: >k::Widget) -> bool {
if widget.is::<gtk::Popover>() && widget.is_visible() {
return true;
}
let mut child = widget.first_child();
while let Some(current) = child {
if has_visible_popover(¤t) {
return true;
}
child = current.next_sibling();
}
false
}
fn monitor_key(monitor: &gdk::Monitor) -> String {
if let Some(connector) = monitor.connector().filter(|c| !c.is_empty()) {
return connector.to_string();
}
let g = monitor.geometry();
let model = monitor.model().map(|m| m.to_string()).unwrap_or_default();
format!("{model}:{}x{}+{}+{}", g.width(), g.height(), g.x(), g.y())
}
fn should_seed_empty(panes_exist: bool, seed: bool, whole_panel: bool) -> bool {
seed && (!whole_panel || !panes_exist)
}
fn monitor_label(monitor: &gdk::Monitor) -> String {
monitor
.connector()
.filter(|c| !c.is_empty())
.or_else(|| monitor.model())
.map(|name| name.to_string())
.unwrap_or_else(|| "this monitor".to_owned())
}
fn layer_of(layer: PanelLayer) -> Layer {
use PanelLayer::*;
match layer {
Background => Layer::Background,
Bottom => Layer::Bottom,
Top => Layer::Top,
Overlay => Layer::Overlay,
}
}
fn install_css(config: &Config) {
let stylesheet = load_stylesheet(&config.theme);
if let Some(problem) = &stylesheet.problem {
eprintln!("tree-space: could not load stylesheet: {problem:?}");
}
crate::highlight::set_palette(crate::theme::syntax_palette(&config.theme));
let css = format!(
"{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
stylesheet.css, config.tree.font_size
);
let provider = gtk::CssProvider::new();
provider.load_from_string(&css);
if let Some(display) = gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
if startup.is_bookmarks() {
return None;
}
let last = SessionState::load().last_root.filter(|p| p.is_dir());
startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
}
fn adjust_path_after_removal(from: &[usize], path: &[usize]) -> Vec<usize> {
let mut out = path.to_vec();
for level in 0..path.len().min(from.len()) {
if from[level] < path[level] {
out[level] -= 1;
return out;
} else if from[level] > path[level] {
return out;
}
}
out
}
#[cfg(test)]
mod adjust_path_tests {
use super::adjust_path_after_removal;
#[test]
fn adjusts_sibling_indices_after_a_removal() {
assert_eq!(adjust_path_after_removal(&[0], &[2]), vec![1]);
assert_eq!(adjust_path_after_removal(&[2], &[0]), vec![0]);
assert_eq!(adjust_path_after_removal(&[0], &[1, 3]), vec![0, 3]);
assert_eq!(adjust_path_after_removal(&[1, 2], &[1]), vec![1]);
assert_eq!(adjust_path_after_removal(&[1, 0], &[1, 2]), vec![1, 1]);
}
}
#[cfg(test)]
mod visibility_tests {
use super::*;
const L: PanelSide = PanelSide::Left;
const R: PanelSide = PanelSide::Right;
fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
resolve_visibility(intent, existing, shown)
}
#[test]
fn show_all_shows_both() {
let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
assert_eq!(p.show, vec![L]);
assert!(p.seed);
let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
assert_eq!(both.show, vec![L, R]);
}
#[test]
fn hide_all_hides_both() {
let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
assert_eq!(p.hide, vec![L, R]);
assert!(p.show.is_empty());
}
#[test]
fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
assert_eq!(shown.show, vec![L]);
let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
assert_eq!(hidden.hide, vec![L, R]);
}
#[test]
fn show_side_creates_a_missing_dock() {
let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
assert_eq!(p.create, Some(R));
assert_eq!(p.show, vec![R]);
}
#[test]
fn hide_side_never_creates() {
let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
assert_eq!(p.create, None);
assert_eq!(p.hide, vec![R]);
}
#[test]
fn toggle_side_is_granular() {
let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
assert_eq!(create.create, Some(R));
assert_eq!(create.show, vec![R]);
let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
assert_eq!(hide.hide, vec![L]);
let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
assert_eq!(show.show, vec![L]);
}
#[test]
fn toggling_one_side_leaves_the_other_alone() {
let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
assert_eq!(p.show, vec![R]);
assert!(!p.hide.contains(&L));
}
#[test]
fn whole_panel_show_does_not_seed_while_a_pane_is_open() {
assert!(!should_seed_empty(true, true, true));
assert!(should_seed_empty(false, true, true));
assert!(should_seed_empty(true, true, false));
assert!(!should_seed_empty(false, false, false));
}
}
#[cfg(test)]
mod nav_history_tests {
use super::{NavHistory, ViewEntry};
use std::path::PathBuf;
fn dir(s: &str) -> ViewEntry {
ViewEntry::Dir(PathBuf::from(s))
}
#[test]
fn records_visits_in_order_and_navigates_both_ways() {
let mut h = NavHistory::default();
h.record(dir("/a"));
h.record(dir("/b"));
h.record(dir("/c"));
assert!(h.can_back());
assert!(!h.can_forward());
assert_eq!(h.back(), Some(dir("/b")));
h.finish_navigation();
assert_eq!(h.back(), Some(dir("/a")));
h.finish_navigation();
assert!(!h.can_back());
assert!(h.can_forward());
assert_eq!(h.forward(), Some(dir("/b")));
h.finish_navigation();
assert_eq!(h.forward(), Some(dir("/c")));
h.finish_navigation();
assert!(h.can_back());
assert!(!h.can_forward());
}
#[test]
fn a_new_visit_after_going_back_truncates_the_forward_tail() {
let mut h = NavHistory::default();
h.record(dir("/a"));
h.record(dir("/b"));
h.record(dir("/c"));
assert_eq!(h.back(), Some(dir("/b")));
h.finish_navigation();
h.record(dir("/d"));
assert!(!h.can_forward());
assert_eq!(h.back(), Some(dir("/b")));
}
#[test]
fn navigating_does_not_record_and_repeats_are_ignored() {
let mut h = NavHistory::default();
h.record(dir("/a"));
h.record(dir("/b"));
let target = h.back().unwrap();
assert_eq!(target, dir("/a"));
h.record(target.clone());
h.finish_navigation();
assert!(h.can_forward());
assert_eq!(h.forward(), Some(dir("/b")));
h.finish_navigation();
h.record(dir("/b"));
assert_eq!(h.cursor, 1);
assert_eq!(h.entries.len(), 2);
}
#[test]
fn bookmarks_view_is_a_history_entry() {
let mut h = NavHistory::default();
h.record(ViewEntry::Bookmarks);
h.record(dir("/a"));
assert_eq!(h.back(), Some(ViewEntry::Bookmarks));
h.finish_navigation();
h.record(ViewEntry::Bookmarks);
assert_eq!(h.entries.len(), 2);
assert_eq!(h.forward(), Some(dir("/a")));
h.finish_navigation();
}
#[test]
fn cannot_navigate_an_empty_history() {
let mut h = NavHistory::default();
assert!(!h.can_back());
assert!(!h.can_forward());
assert_eq!(h.back(), None);
assert_eq!(h.forward(), None);
}
}