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::{
BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, load_stylesheet,
};
use crate::fs::SortKey;
use crate::ipc;
use crate::ui::toolbar::{PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput};
use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct NavHistory {
entries: Vec<PathBuf>,
cursor: usize,
navigating: bool,
}
impl NavHistory {
fn record(&mut self, path: &Path) {
if self.navigating {
return;
}
if self.entries.get(self.cursor).is_some_and(|cur| cur == path) {
return;
}
if self.entries.is_empty() {
self.entries.push(path.to_path_buf());
self.cursor = 0;
return;
}
self.entries.truncate(self.cursor + 1);
self.entries.push(path.to_path_buf());
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<PathBuf> {
if !self.can_back() {
return None;
}
self.cursor -= 1;
self.navigating = true;
Some(self.entries[self.cursor].clone())
}
fn forward(&mut self) -> Option<PathBuf> {
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,
widget: gtk::Overlay,
}
impl Pane {
fn set_root(&mut self, root: PathBuf) {
self.canonical_root = std::fs::canonicalize(&root).ok();
self.root = Some(root);
}
}
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 },
}
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>,
save_generation: Rc<Cell<u64>>,
}
#[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 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() {
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() }
} else {
init.command.roots.clone()
};
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() {
panes.push(make_pane(
&config,
parent.clone(),
next_id,
primary_side,
primary_width,
sender.clone(),
));
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:?}");
}
let 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(),
save_generation: Rc::new(Cell::new(0)),
};
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 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.set_active(id);
if let Some((di, pi)) = self.dock_pane_of(id) {
self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(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::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(&root);
pane.history.finish_navigation();
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) } => {
if let Some((di, pi)) = self.dock_pane_of(id) {
self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
}
}
AppMsg::OpenFolderPicked { path: None, .. } => {}
AppMsg::LaunchRequest { command } => {
self.handle_launch(command, &sender);
}
AppMsg::SplitFromPane { id } => {
let (di, pi) = self.dock_pane_of(id).unwrap_or((0, 0));
let seed = self.docks[di].panes.get(pi).and_then(|p| p.root.clone()).or_else(home_dir);
self.add_pane(di, seed, &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) {
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.filter_bar.set_visible(false);
}
}
AppMsg::FocusPane { id } => {
if let Some((di, pi)) = self.dock_pane_of(id) {
self.docks[di].panes[pi].tree.emit(TreeMsg::Focus);
}
}
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(),
}
}
}
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 = plan
.show
.iter()
.filter_map(|side| self.dock_of_side(*side))
.filter_map(|di| self.docks[di].panes.last())
.map(|pane| pane.tree.emit(TreeMsg::Focus))
.next();
let _ = focus;
}
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 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 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 }),
_ => {}
}
}
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));
}
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.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(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.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(target));
}
}
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) {
for dock in &self.docks {
dock.window.set_visible(visible);
if visible
&& let Some(pane) = dock.panes.last()
{
pane.tree.emit(TreeMsg::Focus);
}
}
self.visible = visible;
}
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);
if layer_shell_available() {
attach_resize_controls(&window, side, sender);
}
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
container.set_vexpand(true);
window.set_child(Some(&container));
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);
}
}
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;
if let Some(root) = &root {
pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
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 }));
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 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(),
})
.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 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);
widget.append(tree_widget);
widget.set_vexpand(true);
overlay.set_child(Some(&widget));
{
let shortcuts = Rc::new(PaneShortcuts::compile(&config.pane_menu));
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,
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> {
let last = SessionState::load().last_root.filter(|p| p.is_dir());
startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
}
#[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;
use std::path::{Path, PathBuf};
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn records_visits_in_order_and_navigates_both_ways() {
let mut h = NavHistory::default();
h.record(Path::new("/a"));
h.record(Path::new("/b"));
h.record(Path::new("/c"));
assert!(h.can_back());
assert!(!h.can_forward());
assert_eq!(h.back(), Some(p("/b")));
h.finish_navigation();
assert_eq!(h.back(), Some(p("/a")));
h.finish_navigation();
assert!(!h.can_back());
assert!(h.can_forward());
assert_eq!(h.forward(), Some(p("/b")));
h.finish_navigation();
assert_eq!(h.forward(), Some(p("/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(Path::new("/a"));
h.record(Path::new("/b"));
h.record(Path::new("/c"));
assert_eq!(h.back(), Some(p("/b")));
h.finish_navigation();
h.record(Path::new("/d"));
assert!(!h.can_forward());
assert_eq!(h.back(), Some(p("/b")));
}
#[test]
fn navigating_does_not_record_and_repeats_are_ignored() {
let mut h = NavHistory::default();
h.record(Path::new("/a"));
h.record(Path::new("/b"));
let target = h.back().unwrap();
assert_eq!(target, p("/a"));
h.record(&target);
h.finish_navigation();
assert!(h.can_forward());
assert_eq!(h.forward(), Some(p("/b")));
h.finish_navigation();
h.record(Path::new("/b"));
assert_eq!(h.cursor, 1);
assert_eq!(h.entries.len(), 2);
}
#[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);
}
}