#[allow(clippy::wildcard_imports)]
use super::*;
pub fn is_blank_path(p: &Path) -> bool {
p.as_os_str().is_empty()
}
pub fn display_name(path: &Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
)
}
pub fn make_closeable_tab_label(title: &str) -> (GtkBox, Button) {
let tab_label_box = GtkBox::new(Orientation::Horizontal, 4);
tab_label_box.append(&Label::new(Some(title)));
let close_btn = Button::from_icon_name("window-close-symbolic");
close_btn.set_has_frame(false);
tab_label_box.append(&close_btn);
(tab_label_box, close_btn)
}
fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|&b| b == 0)
}
pub fn find_window(widget: &impl IsA<gtk4::Widget>) -> Option<ApplicationWindow> {
widget
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
}
pub fn read_file_content(path: &Path) -> (String, bool) {
let Ok(bytes) = fs::read(path) else {
return (String::new(), false);
};
if is_binary(&bytes) {
(String::new(), true)
} else {
(String::from_utf8_lossy(&bytes).into_owned(), false)
}
}
pub fn read_file_for_reload(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
if is_binary(&bytes) {
return None;
}
Some(String::from_utf8_lossy(&bytes).into_owned())
}
pub fn save_file(path: &Path, content: &str, save_btn: &Button) {
match fs::write(path, content) {
Ok(()) => {
mark_saving(path);
save_btn.set_sensitive(false);
}
Err(e) => {
if let Some(win) = find_window(save_btn) {
show_error_dialog(&win, &format!("Failed to save {}: {e}", path.display()));
}
}
}
}
pub fn open_externally(path: &Path) {
let path = path.to_path_buf();
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(&path).status();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(&path)
.status();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let result = std::process::Command::new("xdg-open").arg(&path).status();
match result {
Ok(status) if !status.success() => {
eprintln!(
"Failed to open {}: external opener exited with status {:?}",
path.display(),
status.code()
);
}
Err(e) => {
eprintln!("Failed to open {}: {e}", path.display());
}
_ => {}
}
});
}
pub fn show_error_dialog(parent: &ApplicationWindow, message: &str) {
let dialog = gtk4::Window::builder()
.modal(true)
.transient_for(parent)
.resizable(false)
.decorated(true)
.deletable(true)
.title("Error")
.build();
let content = GtkBox::new(Orientation::Vertical, 8);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let label = Label::new(Some(message));
label.set_wrap(true);
label.set_max_width_chars(60);
content.append(&label);
let ok_btn = Button::with_label("OK");
ok_btn.add_css_class("suggested-action");
ok_btn.set_halign(gtk4::Align::Center);
let d = dialog.clone();
ok_btn.connect_clicked(move |_| d.close());
content.append(&ok_btn);
dialog.set_child(Some(&content));
dialog.present();
}
pub fn show_confirm_dialog(
parent: &ApplicationWindow,
title: &str,
message: &str,
action_label: &str,
on_confirm: impl Fn() + 'static,
) {
let dialog = gtk4::Window::builder()
.modal(true)
.transient_for(parent)
.resizable(false)
.decorated(true)
.deletable(true)
.build();
let content = GtkBox::new(Orientation::Vertical, 8);
content.set_margin_top(18);
content.set_margin_bottom(18);
content.set_margin_start(18);
content.set_margin_end(18);
let title_label = Label::new(Some(title));
title_label.add_css_class("title-3");
content.append(&title_label);
let msg_label = Label::new(Some(message));
msg_label.set_wrap(true);
msg_label.set_max_width_chars(60);
content.append(&msg_label);
let btn_box = GtkBox::new(Orientation::Horizontal, 8);
btn_box.set_margin_top(8);
btn_box.set_halign(gtk4::Align::End);
let cancel_btn = Button::with_label("Cancel");
let action_btn = Button::with_label(action_label);
action_btn.add_css_class("destructive-action");
btn_box.append(&cancel_btn);
btn_box.append(&action_btn);
content.append(&btn_box);
dialog.set_child(Some(&content));
{
let d = dialog.clone();
cancel_btn.connect_clicked(move |_| d.close());
}
{
let d = dialog.clone();
action_btn.connect_clicked(move |_| {
on_confirm();
d.close();
});
}
{
let d = dialog.clone();
let key_ctl = EventControllerKey::new();
key_ctl.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Escape {
d.close();
return gtk4::glib::Propagation::Stop;
}
gtk4::glib::Propagation::Proceed
});
dialog.add_controller(key_ctl);
}
dialog.present();
}
pub fn filter_for_diff(
text: &str,
ignore_whitespace: bool,
ignore_blanks: bool,
) -> (String, Vec<usize>) {
let lines: Vec<&str> = text.lines().collect();
let mut filtered = Vec::with_capacity(lines.len());
let mut line_map = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
if ignore_blanks && line.trim().is_empty() {
continue;
}
if ignore_whitespace {
filtered.push(line.split_whitespace().collect::<Vec<_>>().join(" "));
} else {
filtered.push((*line).to_string());
}
line_map.push(i);
}
(filtered.join("\n"), line_map)
}
pub fn remap_chunks(
chunks: Vec<DiffChunk>,
left_map: &[usize],
left_total: usize,
right_map: &[usize],
right_total: usize,
) -> Vec<DiffChunk> {
chunks
.into_iter()
.map(|mut chunk| {
chunk.start_a = left_map.get(chunk.start_a).copied().unwrap_or(left_total);
chunk.end_a = left_map.get(chunk.end_a).copied().unwrap_or(left_total);
chunk.start_b = right_map.get(chunk.start_b).copied().unwrap_or(right_total);
chunk.end_b = right_map.get(chunk.end_b).copied().unwrap_or(right_total);
chunk
})
.collect()
}
pub fn format_size(bytes: u64) -> String {
if bytes < 1000 {
format!("{bytes} B")
} else if bytes < 1_000_000 {
format!("{:.1} kB", bytes as f64 / 1000.0)
} else if bytes < 1_000_000_000 {
format!("{:.1} MB", bytes as f64 / 1_000_000.0)
} else {
format!("{:.1} GB", bytes as f64 / 1_000_000_000.0)
}
}
pub fn format_mtime(t: SystemTime) -> String {
let dt: DateTime<Local> = t.into();
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub fn generate_unified_diff(
left_label: &str,
right_label: &str,
left_text: &str,
right_text: &str,
chunks: &[DiffChunk],
) -> String {
let left_lines: Vec<&str> = left_text.lines().collect();
let right_lines: Vec<&str> = right_text.lines().collect();
let mut out = String::new();
let _ = writeln!(out, "--- {left_label}");
let _ = writeln!(out, "+++ {right_label}");
let context = 3_usize;
let changes: Vec<&DiffChunk> = chunks.iter().filter(|c| c.tag != DiffTag::Equal).collect();
if changes.is_empty() {
return out;
}
let mut hunks: Vec<(usize, usize, usize, usize, Vec<&DiffChunk>)> = Vec::new();
for &ch in &changes {
let ctx_start_a = ch.start_a.saturating_sub(context);
let ctx_start_b = ch.start_b.saturating_sub(context);
let ctx_end_a = (ch.end_a + context).min(left_lines.len());
let ctx_end_b = (ch.end_b + context).min(right_lines.len());
if let Some(last) = hunks.last_mut() {
if ctx_start_a <= last.1 {
last.1 = ctx_end_a;
last.3 = ctx_end_b;
last.4.push(ch);
continue;
}
}
hunks.push((ctx_start_a, ctx_end_a, ctx_start_b, ctx_end_b, vec![ch]));
}
for (hunk_start_a, hunk_end_a, hunk_start_b, hunk_end_b, hunk_chunks) in &hunks {
let count_a = hunk_end_a - hunk_start_a;
let count_b = hunk_end_b - hunk_start_b;
let _ = writeln!(
out,
"@@ -{},{count_a} +{},{count_b} @@",
hunk_start_a + 1,
hunk_start_b + 1
);
let mut pos_a = *hunk_start_a;
for ch in hunk_chunks {
while pos_a < ch.start_a {
if let Some(line) = left_lines.get(pos_a) {
let _ = writeln!(out, " {line}");
}
pos_a += 1;
}
for i in ch.start_a..ch.end_a {
if let Some(line) = left_lines.get(i) {
let _ = writeln!(out, "-{line}");
}
}
for i in ch.start_b..ch.end_b {
if let Some(line) = right_lines.get(i) {
let _ = writeln!(out, "+{line}");
}
}
pos_a = ch.end_a;
}
while pos_a < *hunk_end_a {
if let Some(line) = left_lines.get(pos_a) {
let _ = writeln!(out, " {line}");
}
pos_a += 1;
}
}
out
}
pub fn column_view_row_at_y(view: &ColumnView, x: f64, y: f64, n_items: u32) -> Option<u32> {
if n_items == 0 {
return None;
}
let picked = view.pick(x, y, gtk4::PickFlags::DEFAULT)?;
let mut widget = picked;
loop {
let parent = widget.parent()?;
let grandparent = parent.parent()?;
if grandparent == *view.upcast_ref::<gtk4::Widget>() {
let mut pos = 0u32;
let mut sibling = parent.first_child();
while let Some(s) = sibling {
if s == widget {
return if pos < n_items { Some(pos) } else { None };
}
pos += 1;
sibling = s.next_sibling();
}
return None;
}
widget = parent;
}
}
pub fn make_info_bar(message: &str) -> GtkBox {
let bar = GtkBox::new(Orientation::Horizontal, 8);
bar.add_css_class("info-bar");
let icon = Image::from_icon_name("dialog-information-symbolic");
let label = Label::new(Some(message));
label.set_hexpand(true);
label.set_halign(gtk4::Align::Start);
let hide_btn = Button::with_label("Hide");
hide_btn.add_css_class("raised");
bar.append(&icon);
bar.append(&label);
bar.append(&hide_btn);
let bar_ref = bar.clone();
hide_btn.connect_clicked(move |_| bar_ref.set_visible(false));
bar
}
pub fn save_all_panes(panes: &[(TextBuffer, Rc<RefCell<PathBuf>>, Button)]) {
for (buf, save_path, save_btn) in panes {
if save_btn.is_sensitive() && !is_blank_path(&save_path.borrow()) {
let text = buf.text(&buf.start_iter(), &buf.end_iter(), false);
save_file(&save_path.borrow(), text.as_str(), save_btn);
}
}
}
pub fn refresh_panes(
anchor: &Button,
panes: Vec<(TextBuffer, Rc<RefCell<PathBuf>>, Option<Button>)>,
) {
let any_dirty = panes
.iter()
.any(|(_, _, btn)| btn.as_ref().is_some_and(Button::is_sensitive));
let do_reload = move || {
for (buf, sp, btn) in &panes {
if !is_blank_path(&sp.borrow())
&& let Some(content) = read_file_for_reload(&sp.borrow())
{
buf.set_text(&content);
if let Some(b) = btn {
b.set_sensitive(false);
}
}
}
};
if any_dirty
&& let Some(win) =
WidgetExt::root(anchor).and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_confirm_dialog(
&win,
"Discard Changes?",
"Unsaved changes will be lost. Reload from disk?",
"Reload",
do_reload,
);
return;
}
do_reload();
}
pub fn save_as_pane(
buf: TextBuffer,
save_path: Rc<RefCell<PathBuf>>,
save_btn: Button,
path_label: Label,
tab_path: Option<Rc<RefCell<String>>>,
) {
let dialog = gtk4::FileDialog::builder().title("Save As").build();
let win = find_window(&save_btn);
dialog.save(win.as_ref(), gio::Cancellable::NONE, move |result| {
if let Ok(file) = result
&& let Some(path) = file.path()
{
let text = buf.text(&buf.start_iter(), &buf.end_iter(), false);
match fs::write(&path, text.as_str()) {
Ok(()) => {
mark_saving(&path);
save_btn.set_sensitive(false);
(*save_path.borrow_mut()).clone_from(&path);
path_label.set_text(&shortened_path(&path));
path_label.set_tooltip_text(Some(&path.display().to_string()));
if let Some(tp) = &tab_path {
*tp.borrow_mut() = path.display().to_string();
}
}
Err(e) => {
if let Some(win) = find_window(&save_btn) {
show_error_dialog(&win, &format!("Failed to save {}: {e}", path.display()));
}
}
}
}
});
}
pub fn shortened_path(full: &Path) -> String {
let components: Vec<_> = full.components().collect();
if components.len() <= 2 {
return full.display().to_string();
}
let tail: std::path::PathBuf = components[components.len() - 2..].iter().collect();
format!("\u{2026}/{}", tail.display())
}
pub fn move_to_trash(path: &Path) -> Result<(), String> {
if cfg!(target_os = "macos") {
let status = std::process::Command::new("trash")
.arg(path)
.status()
.map_err(|e| format!("Failed to run trash command: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!(
"trash command failed with exit code {}",
status.code().unwrap_or(-1)
))
}
} else {
gio::File::for_path(path)
.trash(gio::Cancellable::NONE)
.map_err(|e| format!("{e}"))
}
}
pub struct KeyBindings {
pub alt_left: &'static str,
pub alt_right: &'static str,
pub alt_shift_left: &'static str,
pub alt_shift_right: &'static str,
pub extra_ctrl_shift: &'static [(&'static str, gtk4::gdk::Key, gtk4::gdk::Key)],
pub extra_ctrl: &'static [(&'static str, gtk4::gdk::Key, gtk4::gdk::Key)],
}
pub fn map_key_to_action(
key: gtk4::gdk::Key,
mods: gtk4::gdk::ModifierType,
bindings: &KeyBindings,
) -> Option<&'static str> {
use gtk4::gdk::{Key, ModifierType};
if mods.contains(ModifierType::ALT_MASK) {
if mods.contains(ModifierType::SHIFT_MASK) {
match key {
k if k == Key::Left => return Some(bindings.alt_shift_left),
k if k == Key::Right => return Some(bindings.alt_shift_right),
_ => {} }
}
return match key {
k if k == Key::Up => Some("prev-chunk"),
k if k == Key::Down => Some("next-chunk"),
k if k == Key::Left => Some(bindings.alt_left),
k if k == Key::Right => Some(bindings.alt_right),
k if k == Key::Page_Up => Some("prev-pane"),
k if k == Key::Page_Down => Some("next-pane"),
k if k == Key::Delete || k == Key::KP_Delete => Some("delete-chunk"),
_ => None,
};
}
if has_primary_modifier(mods) {
if mods.contains(ModifierType::SHIFT_MASK) {
for &(name, lo, hi) in bindings.extra_ctrl_shift {
if key == lo || key == hi {
return Some(name);
}
}
return if key == Key::o || key == Key::O {
Some("open-externally")
} else if key == Key::s || key == Key::S {
Some("save-as")
} else if key == Key::l || key == Key::L {
Some("save-all")
} else if cfg!(target_os = "macos") && (key == Key::h || key == Key::H) {
Some("find-replace")
} else {
None
};
}
for &(name, lo, hi) in bindings.extra_ctrl {
if key == lo || key == hi {
return Some(name);
}
}
return if key == Key::s || key == Key::S {
Some("save")
} else if key == Key::r || key == Key::R {
Some("refresh")
} else if key == Key::e || key == Key::E {
Some("prev-chunk")
} else if key == Key::d || key == Key::D {
Some("next-chunk")
} else if key == Key::f || key == Key::F {
Some("find")
} else if !cfg!(target_os = "macos") && (key == Key::h || key == Key::H) {
Some("find-replace")
} else if key == Key::l || key == Key::L {
Some("go-to-line")
} else {
None
};
}
if key == Key::F3 {
return if mods.contains(ModifierType::SHIFT_MASK) {
Some("find-prev")
} else {
Some("find-next")
};
}
if key == Key::F5 {
return Some("refresh");
}
None
}