use std::{cell::{Cell, RefCell}, collections::HashMap, 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, bookmark_file_path,
load_stylesheet, save_bookmarks_to_path,
};
use crate::fs::SortKey;
use crate::ipc;
use crate::ui::bookmarks::{self, BookmarkEvent, MoveTarget};
use crate::ui::toolbar::{PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput};
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>,
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,
nav_buttons: Option<NavButtons>,
}
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.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,
window: gtk::Window,
container: gtk::Box,
panes: Vec<Pane>,
active_pane: Option<u64>,
}
#[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 },
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,
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,
next_id: u64,
visible: bool,
widths: HashMap<PanelSide, u32>,
last_root: Option<PathBuf>,
bookmarks: Vec<Bookmark>,
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 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, 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,
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);
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 mut model = App {
config,
status,
window: root.clone(),
file_dialog: gtk::FileDialog::new(),
docks: vec![Dock {
side: primary_side,
window: root.clone(),
container: pane_container.clone(),
panes,
active_pane: None,
}],
pane_container,
primary_side,
next_id,
visible: !init.command.hidden,
widths,
last_root: session.last_root.clone(),
bookmarks,
save_generation: Rc::new(Cell::new(0)),
sender: sender.clone(),
};
init_layer_window(&model.window, &model.config, primary_side, primary_width);
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();
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(target) => {
self.set_active(id);
if let ShortcutTarget::Builtin(action) = &target
&& action.is_pane_action()
{
self.dispatch_pane_builtin(id, *action, &sender);
} else 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);
}
}
},
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::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 target = self.ensure_dock(side, false, &sender);
self.add_pane(target, Some(root), &sender);
self.set_docks_visible(self.visible);
}
}
AppMsg::ClosePane { id } => {
let Some((di, pi)) = self.dock_pane_of(id) else {
return;
};
self.docks[di].panes.remove(pi);
if self.docks[di].active_pane == Some(id) {
self.docks[di].active_pane = self.docks[di].panes.last().map(|p| p.id);
}
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() {
if di == 0 {
self.visible = false;
self.docks[di].window.set_visible(false);
} else {
let dock = self.docks.remove(di);
dock.window.close();
}
} else {
fill_pane_container(&self.docks[di].container.clone(), &self.docks[di].panes);
}
}
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> = self
.docks
.iter()
.filter(|dock| dock.window.is_visible())
.filter_map(|dock| dock.panes.last().map(|pane| pane.id))
.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::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>) {
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 di = self.ensure_dock(side, 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 di = self.ensure_dock(side, 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 existing: Vec<PanelSide> = self.docks.iter().map(|d| d.side).collect();
let shown: Vec<PanelSide> = self
.docks
.iter()
.filter(|d| d.window.is_visible())
.map(|d| d.side)
.collect();
let plan = resolve_visibility(intent, &existing, &shown);
if let Some(side) = plan.create {
self.ensure_dock(side, plan.seed, sender);
}
for side in &plan.show {
let di = self.ensure_dock(*side, plan.seed, sender);
self.docks[di].window.set_visible(true);
}
for side in &plan.hide {
if let Some(di) = self.dock_of_side(*side) {
self.docks[di].window.set_visible(false);
}
}
self.refresh_visible();
let focus: Vec<u64> = plan
.show
.iter()
.filter_map(|side| self.dock_of_side(*side))
.filter_map(|di| self.docks[di].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);
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 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 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) {
self.docks[di].window.set_visible(visible);
self.refresh_visible();
}
fn set_docks_visible(&mut self, visible: bool) {
let focus: Vec<u64> = self
.docks
.iter()
.filter_map(|dock| dock.panes.last().map(|pane| pane.id))
.collect();
for dock in &self.docks {
dock.window.set_visible(visible);
}
self.visible = visible;
if visible {
for id in focus {
self.focus_pane(id);
}
}
}
fn refresh_visible(&mut self) {
self.visible = self.docks.iter().any(|dock| dock.window.is_visible());
}
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, seed: bool, sender: &ComponentSender<Self>) -> usize {
if let Some((di, _)) = self.docks.iter().enumerate().find(|(_, d)| d.side == side) {
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);
window.set_default_size(width as i32, 520);
{
let sender = sender.clone();
window.connect_map(move |_| {
let sender = sender.clone();
glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
});
}
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,
window,
container,
panes: Vec::new(),
active_pane: None,
});
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 pane = make_pane(&self.config, parent, id, side, width, 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);
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 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(ShortcutTarget::Builtin(action)),
});
});
button
}
fn make_pane(
config: &Config,
parent: gtk::Window,
id: u64,
side: PanelSide,
width: u32,
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 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 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 keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
keys.connect_key_pressed(move |_, key, _, state| {
if let Some(action) = shortcuts.action_for(key, state) {
s.input(AppMsg::PaneShortcut { id, action });
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
widget.add_controller(keys);
}
Pane {
id,
toolbar,
tree,
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,
}
}
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]) {
for pane in panes {
remove_from_parent(pane.widget.upcast_ref());
}
while let Some(child) = container.first_child() {
container.remove(&child);
}
match panes {
[] => {}
[single] => {
let w = pane_widget(single);
w.set_vexpand(true);
container.append(&w);
}
panes => {
let n = panes.len();
let last_w = pane_widget(&panes[n - 1]);
last_w.set_vexpand(true);
let mut right: gtk::Widget = last_w.clone().upcast();
for pane in panes[..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>() {
video.set_media_stream(None::<>k::MediaStream>);
}
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) {
if !layer_shell_available() {
configure_plain_window(window, width);
return;
}
if window.is_layer_window() {
return;
}
window.init_layer_shell();
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 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();
if let Some(problem) = &stylesheet.problem {
eprintln!("tree-space: could not read stylesheet: {problem:?}");
}
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));
}
}
#[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);
}
}