Initial commit
This commit is contained in:
commit
296b987e3b
14 files changed
+4035
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
|
||||||
|
/packaging/arch/src/
|
||||||
|
/packaging/arch/pkg/
|
||||||
|
/packaging/arch/*.pkg.tar*
|
||||||
Generated
+2298
File diff suppressed because it is too large.
Load diff
+13
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "ratbin"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.104"
|
||||||
|
backhand = "0.25.5"
|
||||||
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
|
dirs = "7.0.0"
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
toml = "1.1.8"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Ratbin
|
||||||
|
GenericName=Application Integration Manager
|
||||||
|
Comment=Manage menu entries for AppImages, binaries and scripts
|
||||||
|
Exec=kitty --class ratbin --title Ratbin ratbin
|
||||||
|
Icon=application-x-executable
|
||||||
|
Terminal=false
|
||||||
|
StartupWMClass=ratbin
|
||||||
|
Categories=System;
|
||||||
|
Keywords=appimage;desktop;launcher;integration;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
pkgname=ratbin
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Integrate AppImages, binaries and scripts into the application menu via the Thunar context menu"
|
||||||
|
arch=('x86_64')
|
||||||
|
license=('unknown')
|
||||||
|
depends=('gcc-libs' 'glibc')
|
||||||
|
makedepends=('cargo')
|
||||||
|
optdepends=(
|
||||||
|
'kitty: terminal used by the Ratbin menu entry'
|
||||||
|
'thunar: context menu integration'
|
||||||
|
'desktop-file-utils: refresh the menu cache after changes'
|
||||||
|
'libnotify: notifications from context menu actions'
|
||||||
|
)
|
||||||
|
options=('!lto')
|
||||||
|
|
||||||
|
_root="$startdir/../.."
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$_root"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$_root"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR="$srcdir/target"
|
||||||
|
cargo build --frozen --release
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -Dm755 "$srcdir/target/release/ratbin" "$pkgdir/usr/bin/ratbin"
|
||||||
|
install -Dm644 "$_root/assets/ratbin.desktop" "$pkgdir/usr/share/applications/ratbin.desktop"
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
ffi::OsStr,
|
||||||
|
fs::File,
|
||||||
|
io::{BufReader, Read},
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use backhand::{FilesystemReader, InnerNode, SquashfsFileReader};
|
||||||
|
|
||||||
|
use crate::desktop::DesktopEntry;
|
||||||
|
|
||||||
|
pub struct Icon {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub ext: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Embedded {
|
||||||
|
pub desktop: Option<DesktopEntry>,
|
||||||
|
pub icon: Option<Icon>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read(path: &Path, offset: u64) -> Result<Embedded> {
|
||||||
|
let file = BufReader::new(File::open(path)?);
|
||||||
|
let fs = FilesystemReader::from_reader_with_offset(file, offset).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot read the squashfs image embedded in {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let image = Image::new(&fs);
|
||||||
|
let desktop = image.root_desktop();
|
||||||
|
let icon_name = desktop.as_ref().and_then(|d| d.get("Icon"));
|
||||||
|
let icon = image.icon(icon_name.as_deref());
|
||||||
|
Ok(Embedded { desktop, icon })
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Image<'a, 'b> {
|
||||||
|
fs: &'a FilesystemReader<'b>,
|
||||||
|
nodes: HashMap<PathBuf, &'a InnerNode<SquashfsFileReader>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> Image<'a, 'b> {
|
||||||
|
fn new(fs: &'a FilesystemReader<'b>) -> Self {
|
||||||
|
let nodes = fs
|
||||||
|
.files()
|
||||||
|
.map(|node| (normalize(&node.fullpath), &node.inner))
|
||||||
|
.collect();
|
||||||
|
Self { fs, nodes }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, path: &Path) -> Option<Vec<u8>> {
|
||||||
|
let mut path = normalize(path);
|
||||||
|
for _ in 0..16 {
|
||||||
|
match self.nodes.get(&path)? {
|
||||||
|
InnerNode::File(file) => {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
self.fs.file(file).reader().read_to_end(&mut data).ok()?;
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
InnerNode::Symlink(symlink) => {
|
||||||
|
let parent = path.parent().unwrap_or(Path::new(""));
|
||||||
|
path = normalize(&parent.join(&symlink.link));
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_desktop(&self) -> Option<DesktopEntry> {
|
||||||
|
let mut candidates: Vec<&PathBuf> = self
|
||||||
|
.nodes
|
||||||
|
.keys()
|
||||||
|
.filter(|p| p.components().count() == 1 && p.extension() == Some(OsStr::new("desktop")))
|
||||||
|
.collect();
|
||||||
|
candidates.sort();
|
||||||
|
let data = self.read(candidates.first()?)?;
|
||||||
|
Some(DesktopEntry::parse(&String::from_utf8_lossy(&data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon(&self, name: Option<&str>) -> Option<Icon> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
if let Some(name) = name.filter(|n| !n.is_empty()) {
|
||||||
|
let name = Path::new(name)
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or(name);
|
||||||
|
candidates.push(PathBuf::from(name));
|
||||||
|
for ext in ["svg", "png", "xpm"] {
|
||||||
|
candidates.push(PathBuf::from(format!("{name}.{ext}")));
|
||||||
|
}
|
||||||
|
candidates.extend(self.hicolor(name));
|
||||||
|
}
|
||||||
|
candidates.push(PathBuf::from(".DirIcon"));
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.find_map(|path| self.read(path).and_then(icon_from))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hicolor(&self, name: &str) -> Vec<PathBuf> {
|
||||||
|
let mut found: Vec<(u32, PathBuf)> = self
|
||||||
|
.nodes
|
||||||
|
.keys()
|
||||||
|
.filter_map(|path| {
|
||||||
|
let rest = path.strip_prefix("usr/share/icons/hicolor").ok()?;
|
||||||
|
if path.file_stem()? != OsStr::new(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = rest.components().next()?.as_os_str().to_str()?;
|
||||||
|
let rank = if size == "scalable" {
|
||||||
|
u32::MAX
|
||||||
|
} else {
|
||||||
|
size.split('x').next()?.parse().ok()?
|
||||||
|
};
|
||||||
|
Some((rank, path.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
found.sort_by_key(|(rank, _)| std::cmp::Reverse(*rank));
|
||||||
|
found.into_iter().map(|(_, path)| path).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_from(data: Vec<u8>) -> Option<Icon> {
|
||||||
|
let ext = if data.starts_with(b"\x89PNG") {
|
||||||
|
"png"
|
||||||
|
} else if data.starts_with(b"/* XPM */") {
|
||||||
|
"xpm"
|
||||||
|
} else if String::from_utf8_lossy(&data[..data.len().min(4096)]).contains("<svg") {
|
||||||
|
"svg"
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(Icon { data, ext })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(path: &Path) -> PathBuf {
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(part) => out.push(part),
|
||||||
|
Component::ParentDir => {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub apps_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
apps_dir: "~/Applications".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn path() -> Result<PathBuf> {
|
||||||
|
Ok(dirs::config_dir()
|
||||||
|
.context("cannot determine the config directory")?
|
||||||
|
.join("ratbin/config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
let config = Self::default();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(&path, toml::to_string_pretty(&config)?)
|
||||||
|
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
let text =
|
||||||
|
fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
toml::from_str(&text).with_context(|| format!("invalid config in {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apps_dir(&self) -> Result<PathBuf> {
|
||||||
|
expand_home(&self.apps_dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn applications_dir() -> Result<PathBuf> {
|
||||||
|
Ok(data_dir()?.join("applications"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icons_dir() -> Result<PathBuf> {
|
||||||
|
Ok(data_dir()?.join("ratbin/icons"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_dir() -> Result<PathBuf> {
|
||||||
|
dirs::data_dir().context("cannot determine the data directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_home(path: &str) -> Result<PathBuf> {
|
||||||
|
let home = || dirs::home_dir().context("cannot determine the home directory");
|
||||||
|
if path == "~" {
|
||||||
|
return home();
|
||||||
|
}
|
||||||
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
return Ok(home()?.join(rest));
|
||||||
|
}
|
||||||
|
Ok(PathBuf::from(path))
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
use std::{fmt, path::Path};
|
||||||
|
|
||||||
|
const MAIN: &str = "Desktop Entry";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DesktopEntry {
|
||||||
|
groups: Vec<Group>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Group {
|
||||||
|
name: Option<String>,
|
||||||
|
lines: Vec<Line>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum Line {
|
||||||
|
Pair(String, String),
|
||||||
|
Raw(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopEntry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
groups: vec![Group {
|
||||||
|
name: Some(MAIN.into()),
|
||||||
|
lines: Vec::new(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(text: &str) -> Self {
|
||||||
|
let mut groups = vec![Group {
|
||||||
|
name: None,
|
||||||
|
lines: Vec::new(),
|
||||||
|
}];
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let current = groups.last_mut().expect("groups is never empty");
|
||||||
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
groups.push(Group {
|
||||||
|
name: Some(trimmed[1..trimmed.len() - 1].to_string()),
|
||||||
|
lines: Vec::new(),
|
||||||
|
});
|
||||||
|
} else if !trimmed.starts_with('#')
|
||||||
|
&& let Some((key, value)) = line.split_once('=')
|
||||||
|
{
|
||||||
|
current
|
||||||
|
.lines
|
||||||
|
.push(Line::Pair(key.trim().into(), value.trim_start().into()));
|
||||||
|
} else {
|
||||||
|
current.lines.push(Line::Raw(line.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if groups[0].lines.is_empty() {
|
||||||
|
groups.remove(0);
|
||||||
|
}
|
||||||
|
Self { groups }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &str) -> Option<String> {
|
||||||
|
self.group(MAIN)?.lines.iter().find_map(|line| match line {
|
||||||
|
Line::Pair(k, v) if k == key => Some(unescape(v)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, key: &str, value: &str) {
|
||||||
|
let value = escape(value);
|
||||||
|
let group = self.main_mut();
|
||||||
|
for line in &mut group.lines {
|
||||||
|
if let Line::Pair(k, v) = line
|
||||||
|
&& k == key
|
||||||
|
{
|
||||||
|
*v = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.lines.push(Line::Pair(key.into(), value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, key: &str) {
|
||||||
|
self.main_mut()
|
||||||
|
.lines
|
||||||
|
.retain(|line| !matches!(line, Line::Pair(k, _) if k == key));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_localized(&mut self, key: &str) {
|
||||||
|
self.main_mut().lines.retain(|line| match line {
|
||||||
|
Line::Pair(k, _) => !(k.starts_with(key) && k[key.len()..].starts_with('[')),
|
||||||
|
Line::Raw(_) => true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_values(&mut self, key: &str, f: impl Fn(&str) -> String) {
|
||||||
|
for group in &mut self.groups {
|
||||||
|
for line in &mut group.lines {
|
||||||
|
if let Line::Pair(k, v) = line
|
||||||
|
&& k == key
|
||||||
|
{
|
||||||
|
*v = escape(&f(&unescape(v)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group(&self, name: &str) -> Option<&Group> {
|
||||||
|
self.groups.iter().find(|g| g.name.as_deref() == Some(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main_mut(&mut self) -> &mut Group {
|
||||||
|
let index = match self
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.position(|g| g.name.as_deref() == Some(MAIN))
|
||||||
|
{
|
||||||
|
Some(index) => index,
|
||||||
|
None => {
|
||||||
|
let at = usize::from(self.groups.first().is_some_and(|g| g.name.is_none()));
|
||||||
|
self.groups.insert(
|
||||||
|
at,
|
||||||
|
Group {
|
||||||
|
name: Some(MAIN.into()),
|
||||||
|
lines: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
at
|
||||||
|
}
|
||||||
|
};
|
||||||
|
&mut self.groups[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DesktopEntry {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
for group in &self.groups {
|
||||||
|
if let Some(name) = &group.name {
|
||||||
|
writeln!(f, "[{name}]")?;
|
||||||
|
}
|
||||||
|
for line in &group.lines {
|
||||||
|
match line {
|
||||||
|
Line::Pair(k, v) => writeln!(f, "{k}={v}")?,
|
||||||
|
Line::Raw(raw) => writeln!(f, "{raw}")?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
let mut chars = value.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c != '\\' {
|
||||||
|
out.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match chars.next() {
|
||||||
|
Some('s') => out.push(' '),
|
||||||
|
Some('n') => out.push('\n'),
|
||||||
|
Some('t') => out.push('\t'),
|
||||||
|
Some('r') => out.push('\r'),
|
||||||
|
Some('\\') => out.push('\\'),
|
||||||
|
Some(other) => {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(other);
|
||||||
|
}
|
||||||
|
None => out.push('\\'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\t', "\\t")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quote_exec_arg(arg: &str) -> String {
|
||||||
|
const RESERVED: &[char] = &[
|
||||||
|
' ', '\t', '\n', '"', '\'', '\\', '>', '<', '~', '|', '&', ';', '$', '*', '?', '#', '(',
|
||||||
|
')', '`',
|
||||||
|
];
|
||||||
|
let arg = arg.replace('%', "%%");
|
||||||
|
if !arg.is_empty() && !arg.contains(RESERVED) {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
let mut out = String::from('"');
|
||||||
|
for c in arg.chars() {
|
||||||
|
if matches!(c, '"' | '`' | '$' | '\\') {
|
||||||
|
out.push('\\');
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exec_args(exec: &str) -> &str {
|
||||||
|
let exec = exec.trim_start();
|
||||||
|
let end = match exec.strip_prefix('"') {
|
||||||
|
Some(rest) => {
|
||||||
|
let mut escaped = false;
|
||||||
|
rest.char_indices()
|
||||||
|
.find_map(|(i, c)| {
|
||||||
|
match c {
|
||||||
|
_ if escaped => escaped = false,
|
||||||
|
'\\' => escaped = true,
|
||||||
|
'"' => return Some(i + 2),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.unwrap_or(exec.len())
|
||||||
|
}
|
||||||
|
None => exec.find(char::is_whitespace).unwrap_or(exec.len()),
|
||||||
|
};
|
||||||
|
exec[end..].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_exec(program: &Path, args: &str) -> String {
|
||||||
|
let program = quote_exec_arg(&program.to_string_lossy());
|
||||||
|
if args.is_empty() {
|
||||||
|
program
|
||||||
|
} else {
|
||||||
|
format!("{program} {args}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use std::{fs::File, io::Read, path::Path};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
pub enum Kind {
|
||||||
|
AppImage(Option<u64>),
|
||||||
|
Elf,
|
||||||
|
Script,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::AppImage(_) => "AppImage",
|
||||||
|
Kind::Elf => "ELF",
|
||||||
|
Kind::Script => "Script",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fallback_icon(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::AppImage(_) | Kind::Elf => "application-x-executable",
|
||||||
|
Kind::Script => "text-x-script",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect(path: &Path) -> Result<Kind> {
|
||||||
|
let mut header = Vec::with_capacity(64);
|
||||||
|
File::open(path)
|
||||||
|
.with_context(|| format!("cannot open {}", path.display()))?
|
||||||
|
.take(64)
|
||||||
|
.read_to_end(&mut header)?;
|
||||||
|
|
||||||
|
if header.starts_with(b"#!") {
|
||||||
|
return Ok(Kind::Script);
|
||||||
|
}
|
||||||
|
if !header.starts_with(b"\x7fELF") {
|
||||||
|
bail!(
|
||||||
|
"{} is not an AppImage, ELF binary or script",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let named_appimage = path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("appimage"));
|
||||||
|
Ok(match header.get(8..11) {
|
||||||
|
Some(b"AI\x02") => Kind::AppImage(elf_end(&header)),
|
||||||
|
Some(b"AI\x01") => Kind::AppImage(None),
|
||||||
|
_ if named_appimage => Kind::AppImage(elf_end(&header)),
|
||||||
|
_ => Kind::Elf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elf_end(header: &[u8]) -> Option<u64> {
|
||||||
|
let little_endian = match header.get(5)? {
|
||||||
|
1 => true,
|
||||||
|
2 => false,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let read = |offset: usize, len: usize| -> Option<u64> {
|
||||||
|
let bytes = header.get(offset..offset + len)?;
|
||||||
|
let fold = |acc: u64, &b: &u8| (acc << 8) | u64::from(b);
|
||||||
|
Some(if little_endian {
|
||||||
|
bytes.iter().rev().fold(0, fold)
|
||||||
|
} else {
|
||||||
|
bytes.iter().fold(0, fold)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let (shoff, shentsize, shnum) = match header.get(4)? {
|
||||||
|
1 => (read(0x20, 4)?, read(0x2E, 2)?, read(0x30, 2)?),
|
||||||
|
2 => (read(0x28, 8)?, read(0x3A, 2)?, read(0x3C, 2)?),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(shoff + shentsize * shnum)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use std::{path::Path, process::Command};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::store;
|
||||||
|
|
||||||
|
const SCANNED_DIRS: [&str; 2] = ["AppImages", "Applications"];
|
||||||
|
|
||||||
|
pub fn packages_line() -> Result<String> {
|
||||||
|
let integrations = store::list()?;
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
let counted_twice = integrations
|
||||||
|
.iter()
|
||||||
|
.filter(|i| counted_by_fastfetch(&i.source(), &home))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let mut parts: Vec<String> = fastfetch_packages()
|
||||||
|
.split(", ")
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.filter_map(|part| match part.strip_suffix(" (appimage)") {
|
||||||
|
Some(count) => {
|
||||||
|
let count = count.parse::<usize>().ok()?.saturating_sub(counted_twice);
|
||||||
|
(count > 0).then(|| format!("{count} (appimage)"))
|
||||||
|
}
|
||||||
|
None => Some(part.to_string()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
parts.push(format!("{} (ratbin)", integrations.len()));
|
||||||
|
Ok(parts.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fastfetch_packages() -> String {
|
||||||
|
let Ok(output) = Command::new("fastfetch")
|
||||||
|
.args(["--config", "none", "--logo", "none", "--pipe", "true"])
|
||||||
|
.args(["--structure", "packages"])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let line = text.lines().next().unwrap_or_default();
|
||||||
|
line.split_once(": ")
|
||||||
|
.map_or(line, |(_, counts)| counts)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn counted_by_fastfetch(source: &Path, home: &Path) -> bool {
|
||||||
|
source.is_file()
|
||||||
|
&& source
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("appimage"))
|
||||||
|
&& source
|
||||||
|
.parent()
|
||||||
|
.is_some_and(|dir| SCANNED_DIRS.iter().any(|d| dir == home.join(d)))
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
mod appimage;
|
||||||
|
mod config;
|
||||||
|
mod desktop;
|
||||||
|
mod detect;
|
||||||
|
mod fastfetch;
|
||||||
|
mod store;
|
||||||
|
mod thunar;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command as Process, ExitCode},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
version,
|
||||||
|
about = "Integrate AppImages, binaries and scripts into the application menu"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Move files into the applications folder and create menu entries
|
||||||
|
Integrate {
|
||||||
|
/// Report the result as a desktop notification
|
||||||
|
#[arg(long)]
|
||||||
|
notify: bool,
|
||||||
|
#[arg(required = true)]
|
||||||
|
files: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Remove the menu entries of integrated files
|
||||||
|
Remove {
|
||||||
|
#[arg(long)]
|
||||||
|
notify: bool,
|
||||||
|
/// Also delete the files themselves
|
||||||
|
#[arg(long)]
|
||||||
|
delete: bool,
|
||||||
|
#[arg(required = true)]
|
||||||
|
files: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
/// List integrated files
|
||||||
|
List,
|
||||||
|
/// Open the interactive manager (default)
|
||||||
|
Tui,
|
||||||
|
/// Print fastfetch's package counts with ratbin integrations added
|
||||||
|
Fastfetch,
|
||||||
|
/// Manage the Thunar context menu actions
|
||||||
|
Thunar {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ThunarAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ThunarAction {
|
||||||
|
/// Add the ratbin actions to Thunar's context menu
|
||||||
|
Install,
|
||||||
|
/// Remove the ratbin actions from Thunar's context menu
|
||||||
|
Uninstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run(Cli::parse()) {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: {e:#}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(cli: Cli) -> Result<ExitCode> {
|
||||||
|
match cli.command.unwrap_or(Command::Tui) {
|
||||||
|
Command::Integrate { notify, files } => Ok(batch("Integrated", notify, &files, |file| {
|
||||||
|
let apps_dir = Config::load()?.apps_dir()?;
|
||||||
|
store::integrate(&apps_dir, file).map(|i| i.name())
|
||||||
|
})),
|
||||||
|
Command::Remove {
|
||||||
|
notify,
|
||||||
|
delete,
|
||||||
|
files,
|
||||||
|
} => Ok(batch("Removed", notify, &files, |file| {
|
||||||
|
store::remove_path(file, delete)
|
||||||
|
})),
|
||||||
|
Command::List => {
|
||||||
|
for integration in store::list()? {
|
||||||
|
println!(
|
||||||
|
"{}\t{}\t{}",
|
||||||
|
integration.name(),
|
||||||
|
integration.kind(),
|
||||||
|
integration.source().display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
Command::Fastfetch => {
|
||||||
|
println!("{}", fastfetch::packages_line()?);
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
Command::Tui => {
|
||||||
|
tui::run()?;
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
Command::Thunar { action } => {
|
||||||
|
match action {
|
||||||
|
ThunarAction::Install => {
|
||||||
|
let path = thunar::install()?;
|
||||||
|
println!("Added the ratbin actions to {}", path.display());
|
||||||
|
}
|
||||||
|
ThunarAction::Uninstall => {
|
||||||
|
let path = thunar::uninstall()?;
|
||||||
|
println!("Removed the ratbin actions from {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Restart Thunar (thunar -q) to apply.");
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch(
|
||||||
|
verb: &str,
|
||||||
|
notify: bool,
|
||||||
|
files: &[PathBuf],
|
||||||
|
op: impl Fn(&Path) -> Result<String>,
|
||||||
|
) -> ExitCode {
|
||||||
|
let mut done = Vec::new();
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
for file in files {
|
||||||
|
match op(file) {
|
||||||
|
Ok(name) => {
|
||||||
|
println!("{verb}: {name}");
|
||||||
|
done.push(name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: {e:#}");
|
||||||
|
failed.push(format!("{e:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notify {
|
||||||
|
if !done.is_empty() {
|
||||||
|
send_notification(&format!("{verb} {}", done.join(", ")), "", false);
|
||||||
|
}
|
||||||
|
if !failed.is_empty() {
|
||||||
|
send_notification("ratbin", &failed.join("\n"), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed.is_empty() {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_notification(summary: &str, body: &str, error: bool) {
|
||||||
|
let icon = if error {
|
||||||
|
"dialog-error"
|
||||||
|
} else {
|
||||||
|
"application-x-executable"
|
||||||
|
};
|
||||||
|
let _ = Process::new("notify-send")
|
||||||
|
.args(["--app-name=ratbin", "--icon", icon, summary, body])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
+322
@@ -0,0 +1,322 @@
|
|||||||
|
use std::{
|
||||||
|
fs, io,
|
||||||
|
os::unix::fs::PermissionsExt,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
appimage, config,
|
||||||
|
desktop::{self, DesktopEntry},
|
||||||
|
detect::{self, Kind},
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_SOURCE: &str = "X-Ratbin-Source";
|
||||||
|
const KEY_ARGS: &str = "X-Ratbin-Args";
|
||||||
|
const KEY_KIND: &str = "X-Ratbin-Kind";
|
||||||
|
const PREFIX: &str = "ratbin-";
|
||||||
|
|
||||||
|
pub struct Integration {
|
||||||
|
pub desktop_path: PathBuf,
|
||||||
|
pub entry: DesktopEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Fields {
|
||||||
|
pub name: String,
|
||||||
|
pub comment: String,
|
||||||
|
pub categories: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub args: String,
|
||||||
|
pub terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Integration {
|
||||||
|
pub fn source(&self) -> PathBuf {
|
||||||
|
self.entry
|
||||||
|
.get(KEY_SOURCE)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> String {
|
||||||
|
self.entry
|
||||||
|
.get("Name")
|
||||||
|
.unwrap_or_else(|| file_stem(&self.source()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> String {
|
||||||
|
self.entry.get(KEY_KIND).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields(&self) -> Fields {
|
||||||
|
let get = |key: &str| self.entry.get(key).unwrap_or_default();
|
||||||
|
Fields {
|
||||||
|
name: self.name(),
|
||||||
|
comment: get("Comment"),
|
||||||
|
categories: get("Categories"),
|
||||||
|
icon: get("Icon"),
|
||||||
|
args: get(KEY_ARGS),
|
||||||
|
terminal: get("Terminal") == "true",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&mut self, fields: &Fields) {
|
||||||
|
let source = self.source();
|
||||||
|
let entry = &mut self.entry;
|
||||||
|
|
||||||
|
let name = fields.name.trim();
|
||||||
|
entry.remove_localized("Name");
|
||||||
|
if name.is_empty() {
|
||||||
|
entry.set("Name", &file_stem(&source));
|
||||||
|
} else {
|
||||||
|
entry.set("Name", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.remove_localized("Comment");
|
||||||
|
set_or_remove(entry, "Comment", fields.comment.trim());
|
||||||
|
|
||||||
|
let categories = fields.categories.trim();
|
||||||
|
if categories.is_empty() || categories.ends_with(';') {
|
||||||
|
set_or_remove(entry, "Categories", categories);
|
||||||
|
} else {
|
||||||
|
entry.set("Categories", &format!("{categories};"));
|
||||||
|
}
|
||||||
|
|
||||||
|
set_or_remove(entry, "Icon", fields.icon.trim());
|
||||||
|
|
||||||
|
let args = fields.args.trim();
|
||||||
|
entry.set(KEY_ARGS, args);
|
||||||
|
entry.set("Exec", &desktop::build_exec(&source, args));
|
||||||
|
entry.set("Terminal", if fields.terminal { "true" } else { "false" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Result<Vec<Integration>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = fs::read_dir(config::applications_dir()?) else {
|
||||||
|
return Ok(out);
|
||||||
|
};
|
||||||
|
for dir_entry in entries.flatten() {
|
||||||
|
let file_name = dir_entry.file_name();
|
||||||
|
let file_name = file_name.to_string_lossy();
|
||||||
|
if !file_name.starts_with(PREFIX) || !file_name.ends_with(".desktop") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(text) = fs::read_to_string(dir_entry.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = DesktopEntry::parse(&text);
|
||||||
|
if entry.get(KEY_SOURCE).is_some() {
|
||||||
|
out.push(Integration {
|
||||||
|
desktop_path: dir_entry.path(),
|
||||||
|
entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_cached_key(|i| i.name().to_lowercase());
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(source: &Path) -> Result<Option<Integration>> {
|
||||||
|
Ok(list()?.into_iter().find(|i| i.source() == source))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn integrate(apps_dir: &Path, path: &Path) -> Result<Integration> {
|
||||||
|
let src = path
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("cannot access {}", path.display()))?;
|
||||||
|
if !src.is_file() {
|
||||||
|
bail!("{} is not a file", src.display());
|
||||||
|
}
|
||||||
|
if let Some(existing) = find(&src)? {
|
||||||
|
bail!(
|
||||||
|
"{} is already integrated as \"{}\"",
|
||||||
|
src.display(),
|
||||||
|
existing.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let kind = detect::detect(&src)?;
|
||||||
|
let target = place(apps_dir, &src)?;
|
||||||
|
make_executable(&target)?;
|
||||||
|
let id = unique_id(&target)?;
|
||||||
|
let entry = build_entry(&kind, &target, &id)?;
|
||||||
|
let integration = Integration {
|
||||||
|
desktop_path: config::applications_dir()?.join(format!("{id}.desktop")),
|
||||||
|
entry,
|
||||||
|
};
|
||||||
|
save(&integration)?;
|
||||||
|
Ok(integration)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_path(path: &Path, delete_file: bool) -> Result<String> {
|
||||||
|
let src = path
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("cannot access {}", path.display()))?;
|
||||||
|
let integration =
|
||||||
|
find(&src)?.with_context(|| format!("{} is not integrated", src.display()))?;
|
||||||
|
remove(&integration, delete_file)?;
|
||||||
|
Ok(integration.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(integration: &Integration, delete_file: bool) -> Result<()> {
|
||||||
|
remove_if_exists(&integration.desktop_path)?;
|
||||||
|
if let Some(icon) = integration.entry.get("Icon").map(PathBuf::from)
|
||||||
|
&& icon.starts_with(config::icons_dir()?)
|
||||||
|
{
|
||||||
|
remove_if_exists(&icon)?;
|
||||||
|
}
|
||||||
|
if delete_file {
|
||||||
|
remove_if_exists(&integration.source())?;
|
||||||
|
}
|
||||||
|
refresh_menu();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(integration: &Integration) -> Result<()> {
|
||||||
|
let path = &integration.desktop_path;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("desktop.tmp");
|
||||||
|
fs::write(&tmp, integration.entry.to_string())
|
||||||
|
.with_context(|| format!("cannot write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, path).with_context(|| format!("cannot write {}", path.display()))?;
|
||||||
|
refresh_menu();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_entry(kind: &Kind, target: &Path, id: &str) -> Result<DesktopEntry> {
|
||||||
|
let embedded = match kind {
|
||||||
|
Kind::AppImage(Some(offset)) => appimage::read(target, *offset)
|
||||||
|
.inspect_err(|e| eprintln!("warning: {e:#}"))
|
||||||
|
.ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let (desktop, icon) = embedded.map(|e| (e.desktop, e.icon)).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut entry = desktop.unwrap_or_else(DesktopEntry::new);
|
||||||
|
let args = entry
|
||||||
|
.get("Exec")
|
||||||
|
.map(|exec| desktop::exec_args(&exec).to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
entry.map_values("Exec", |exec| {
|
||||||
|
desktop::build_exec(target, desktop::exec_args(exec))
|
||||||
|
});
|
||||||
|
entry.remove("TryExec");
|
||||||
|
entry.remove("DBusActivatable");
|
||||||
|
|
||||||
|
entry.set("Type", "Application");
|
||||||
|
if entry.get("Name").is_none() {
|
||||||
|
entry.set("Name", &file_stem(target));
|
||||||
|
}
|
||||||
|
if entry.get("Terminal").is_none() {
|
||||||
|
let terminal = matches!(kind, Kind::Script);
|
||||||
|
entry.set("Terminal", if terminal { "true" } else { "false" });
|
||||||
|
}
|
||||||
|
let icon = match icon {
|
||||||
|
Some(icon) => store_icon(&icon, id)?.to_string_lossy().into_owned(),
|
||||||
|
None => kind.fallback_icon().into(),
|
||||||
|
};
|
||||||
|
entry.set("Icon", &icon);
|
||||||
|
entry.set(KEY_SOURCE, &target.to_string_lossy());
|
||||||
|
entry.set(KEY_KIND, kind.label());
|
||||||
|
entry.set(KEY_ARGS, &args);
|
||||||
|
entry.set("Exec", &desktop::build_exec(target, &args));
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn place(apps_dir: &Path, src: &Path) -> Result<PathBuf> {
|
||||||
|
fs::create_dir_all(apps_dir)
|
||||||
|
.with_context(|| format!("cannot create {}", apps_dir.display()))?;
|
||||||
|
let apps_dir = apps_dir.canonicalize()?;
|
||||||
|
if src.starts_with(&apps_dir) {
|
||||||
|
return Ok(src.to_path_buf());
|
||||||
|
}
|
||||||
|
let dest = apps_dir.join(src.file_name().context("path has no file name")?);
|
||||||
|
if dest.exists() {
|
||||||
|
bail!("{} already exists", dest.display());
|
||||||
|
}
|
||||||
|
if fs::rename(src, &dest).is_err() {
|
||||||
|
fs::copy(src, &dest).with_context(|| format!("cannot copy to {}", dest.display()))?;
|
||||||
|
fs::remove_file(src).with_context(|| format!("cannot remove {}", src.display()))?;
|
||||||
|
}
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_executable(path: &Path) -> Result<()> {
|
||||||
|
let mut permissions = fs::metadata(path)?.permissions();
|
||||||
|
let mode = permissions.mode();
|
||||||
|
permissions.set_mode(mode | 0o100 | ((mode & 0o044) >> 2));
|
||||||
|
fs::set_permissions(path, permissions)
|
||||||
|
.with_context(|| format!("cannot make {} executable", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_id(target: &Path) -> Result<String> {
|
||||||
|
let slug: String = file_stem(target)
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() {
|
||||||
|
c.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let slug = slug
|
||||||
|
.split('-')
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-");
|
||||||
|
let slug = if slug.is_empty() { "app".into() } else { slug };
|
||||||
|
let apps = config::applications_dir()?;
|
||||||
|
let mut id = format!("{PREFIX}{slug}");
|
||||||
|
let mut n = 2;
|
||||||
|
while apps.join(format!("{id}.desktop")).exists() {
|
||||||
|
id = format!("{PREFIX}{slug}-{n}");
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_icon(icon: &appimage::Icon, id: &str) -> Result<PathBuf> {
|
||||||
|
let dir = config::icons_dir()?;
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
let path = dir.join(format!("{id}.{}", icon.ext));
|
||||||
|
fs::write(&path, &icon.data).with_context(|| format!("cannot write {}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_or_remove(entry: &mut DesktopEntry, key: &str, value: &str) {
|
||||||
|
if value.is_empty() {
|
||||||
|
entry.remove(key);
|
||||||
|
} else {
|
||||||
|
entry.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_stem(path: &Path) -> String {
|
||||||
|
path.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_if_exists(path: &Path) -> Result<()> {
|
||||||
|
match fs::remove_file(path) {
|
||||||
|
Err(e) if e.kind() != io::ErrorKind::NotFound => {
|
||||||
|
Err(e).with_context(|| format!("cannot remove {}", path.display()))
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_menu() {
|
||||||
|
if let Ok(dir) = config::applications_dir() {
|
||||||
|
let _ = Command::new("update-desktop-database")
|
||||||
|
.arg(dir)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
const SKELETON: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n</actions>\n";
|
||||||
|
const IDS: [&str; 2] = ["ratbin-integrate", "ratbin-remove"];
|
||||||
|
|
||||||
|
pub fn install() -> Result<PathBuf> {
|
||||||
|
let exe = std::env::current_exe()?.canonicalize()?;
|
||||||
|
let exe = shell_quote(&exe.to_string_lossy());
|
||||||
|
let actions = [
|
||||||
|
action(
|
||||||
|
IDS[0],
|
||||||
|
"Integrate into menu",
|
||||||
|
"application-x-executable",
|
||||||
|
&format!("{exe} integrate --notify %F"),
|
||||||
|
"Move to the applications folder and add a menu entry",
|
||||||
|
),
|
||||||
|
action(
|
||||||
|
IDS[1],
|
||||||
|
"Remove menu integration",
|
||||||
|
"edit-delete",
|
||||||
|
&format!("{exe} remove --notify %F"),
|
||||||
|
"Remove the menu entry created by ratbin",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let mut doc = stripped()?;
|
||||||
|
let pos = doc
|
||||||
|
.rfind("</actions>")
|
||||||
|
.context("uca.xml has no closing </actions> tag")?;
|
||||||
|
doc.insert_str(pos, &actions.concat());
|
||||||
|
write(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uninstall() -> Result<PathBuf> {
|
||||||
|
write(stripped()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uca_path() -> Result<PathBuf> {
|
||||||
|
Ok(dirs::config_dir()
|
||||||
|
.context("cannot determine the config directory")?
|
||||||
|
.join("Thunar/uca.xml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stripped() -> Result<String> {
|
||||||
|
let path = uca_path()?;
|
||||||
|
let doc = if path.exists() {
|
||||||
|
fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?
|
||||||
|
} else {
|
||||||
|
SKELETON.into()
|
||||||
|
};
|
||||||
|
Ok(strip(&doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(doc: String) -> Result<PathBuf> {
|
||||||
|
let path = uca_path()?;
|
||||||
|
if path.exists() {
|
||||||
|
fs::copy(&path, path.with_extension("xml.bak"))?;
|
||||||
|
} else if let Some(dir) = path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
fs::write(&path, doc).with_context(|| format!("cannot write {}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip(doc: &str) -> String {
|
||||||
|
const CLOSE: &str = "</action>";
|
||||||
|
let mut out = String::with_capacity(doc.len());
|
||||||
|
let mut rest = doc;
|
||||||
|
while let Some(start) = rest.find("<action>") {
|
||||||
|
let Some(len) = rest[start..].find(CLOSE) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let mut end = start + len + CLOSE.len();
|
||||||
|
let block = &rest[start..end];
|
||||||
|
out.push_str(&rest[..start]);
|
||||||
|
if IDS
|
||||||
|
.iter()
|
||||||
|
.any(|id| block.contains(&format!("<unique-id>{id}</unique-id>")))
|
||||||
|
{
|
||||||
|
if rest[end..].starts_with('\n') {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push_str(block);
|
||||||
|
}
|
||||||
|
rest = &rest[end..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(id: &str, name: &str, icon: &str, command: &str, description: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"<action>\n\
|
||||||
|
\t<icon>{icon}</icon>\n\
|
||||||
|
\t<name>{name}</name>\n\
|
||||||
|
\t<submenu></submenu>\n\
|
||||||
|
\t<unique-id>{id}</unique-id>\n\
|
||||||
|
\t<command>{command}</command>\n\
|
||||||
|
\t<description>{description}</description>\n\
|
||||||
|
\t<range></range>\n\
|
||||||
|
\t<patterns>*</patterns>\n\
|
||||||
|
\t<other-files/>\n\
|
||||||
|
\t<text-files/>\n\
|
||||||
|
</action>\n",
|
||||||
|
icon = xml_escape(icon),
|
||||||
|
name = xml_escape(name),
|
||||||
|
id = xml_escape(id),
|
||||||
|
command = xml_escape(command),
|
||||||
|
description = xml_escape(description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
if s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c))
|
||||||
|
{
|
||||||
|
s.into()
|
||||||
|
} else {
|
||||||
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+463
@@ -0,0 +1,463 @@
|
|||||||
|
use std::{io, path::PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use ratatui::{
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::SetTitle,
|
||||||
|
},
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::store::{self, Fields, Integration};
|
||||||
|
|
||||||
|
const LABELS: [&str; 6] = [
|
||||||
|
"Name",
|
||||||
|
"Comment",
|
||||||
|
"Categories",
|
||||||
|
"Icon",
|
||||||
|
"Arguments",
|
||||||
|
"Terminal",
|
||||||
|
];
|
||||||
|
const TERMINAL_FIELD: usize = 5;
|
||||||
|
const LABEL_WIDTH: u16 = 12;
|
||||||
|
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
let mut app = App::new()?;
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
let _ = execute!(io::stdout(), SetTitle("Ratbin"));
|
||||||
|
let result = app.run(&mut terminal);
|
||||||
|
ratatui::restore();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
Browse,
|
||||||
|
Edit(Form),
|
||||||
|
Confirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
items: Vec<Integration>,
|
||||||
|
list: ListState,
|
||||||
|
mode: Mode,
|
||||||
|
status: Option<(String, bool)>,
|
||||||
|
quit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new() -> Result<Self> {
|
||||||
|
let mut app = Self {
|
||||||
|
items: Vec::new(),
|
||||||
|
list: ListState::default(),
|
||||||
|
mode: Mode::Browse,
|
||||||
|
status: None,
|
||||||
|
quit: false,
|
||||||
|
};
|
||||||
|
app.reload(None)?;
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload(&mut self, keep: Option<PathBuf>) -> Result<()> {
|
||||||
|
self.items = store::list()?;
|
||||||
|
let index = keep
|
||||||
|
.and_then(|path| self.items.iter().position(|i| i.desktop_path == path))
|
||||||
|
.or(self.list.selected())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(self.items.len().saturating_sub(1));
|
||||||
|
self.list.select((!self.items.is_empty()).then_some(index));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(&self) -> Option<&Integration> {
|
||||||
|
self.items.get(self.list.selected()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||||
|
while !self.quit {
|
||||||
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
if let Event::Key(key) = event::read()?
|
||||||
|
&& key.kind == KeyEventKind::Press
|
||||||
|
{
|
||||||
|
self.status = None;
|
||||||
|
self.on_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key(&mut self, key: KeyEvent) {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
match &mut self.mode {
|
||||||
|
Mode::Browse => self.on_browse(key),
|
||||||
|
Mode::Confirm => self.on_confirm(key),
|
||||||
|
Mode::Edit(form) => match key.code {
|
||||||
|
KeyCode::Esc => self.mode = Mode::Browse,
|
||||||
|
KeyCode::Enter => self.save(),
|
||||||
|
KeyCode::Char('s') if ctrl => self.save(),
|
||||||
|
_ => form.on_key(key),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_browse(&mut self, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => self.list.select_next(),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => self.list.select_previous(),
|
||||||
|
KeyCode::Home | KeyCode::Char('g') => self.list.select_first(),
|
||||||
|
KeyCode::End | KeyCode::Char('G') => self.list.select_last(),
|
||||||
|
KeyCode::Enter | KeyCode::Char('e') => {
|
||||||
|
if let Some(item) = self.selected() {
|
||||||
|
self.mode = Mode::Edit(Form::new(item.fields()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete | KeyCode::Char('d') => {
|
||||||
|
if self.selected().is_some() {
|
||||||
|
self.mode = Mode::Confirm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
let result = self.reload(None);
|
||||||
|
self.report(result.map(|()| "Reloaded".into()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_confirm(&mut self, key: KeyEvent) {
|
||||||
|
self.mode = Mode::Browse;
|
||||||
|
let delete_file = match key.code {
|
||||||
|
KeyCode::Char('e') => false,
|
||||||
|
KeyCode::Char('d') => true,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let Some(item) = self.selected() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let name = item.name();
|
||||||
|
let result = store::remove(item, delete_file).and_then(|()| self.reload(None));
|
||||||
|
self.report(result.map(|()| format!("Removed {name}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self) {
|
||||||
|
let Mode::Edit(form) = std::mem::replace(&mut self.mode, Mode::Browse) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(item) = self
|
||||||
|
.list
|
||||||
|
.selected()
|
||||||
|
.and_then(|index| self.items.get_mut(index))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
item.apply(&form.fields());
|
||||||
|
let path = item.desktop_path.clone();
|
||||||
|
let name = item.name();
|
||||||
|
match store::save(item) {
|
||||||
|
Ok(()) => {
|
||||||
|
let result = self.reload(Some(path));
|
||||||
|
self.report(result.map(|()| format!("Saved {name}")));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status = Some((format!("{e:#}"), true));
|
||||||
|
self.mode = Mode::Edit(form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(&mut self, result: Result<String>) {
|
||||||
|
self.status = Some(match result {
|
||||||
|
Ok(message) => (message, false),
|
||||||
|
Err(e) => (format!("{e:#}"), true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
|
let [main, footer] =
|
||||||
|
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area());
|
||||||
|
let [left, right] =
|
||||||
|
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||||
|
.areas(main);
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = self
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let name_style = if item.source().exists() {
|
||||||
|
Style::default()
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(item.name(), name_style),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {}", item.kind()),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::bordered().title(" ratbin "))
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
|
||||||
|
.highlight_symbol("› ");
|
||||||
|
frame.render_stateful_widget(list, left, &mut self.list);
|
||||||
|
frame.render_widget(self.details(), right);
|
||||||
|
frame.render_widget(self.footer(), footer);
|
||||||
|
|
||||||
|
match &self.mode {
|
||||||
|
Mode::Browse => {}
|
||||||
|
Mode::Edit(form) => form.draw(frame, main),
|
||||||
|
Mode::Confirm => self.draw_confirm(frame, main),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn details(&self) -> Paragraph<'static> {
|
||||||
|
let block = Block::bordered().title(" Details ");
|
||||||
|
let Some(item) = self.selected() else {
|
||||||
|
return Paragraph::new(
|
||||||
|
"Nothing integrated yet.\n\nRight-click an AppImage, binary or script in Thunar \
|
||||||
|
and choose \"Integrate into menu\".",
|
||||||
|
)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
};
|
||||||
|
let source = item.source();
|
||||||
|
let get = |key: &str| item.entry.get(key).unwrap_or_default();
|
||||||
|
let row = |label: &str, value: String| {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{label:<width$}", width = LABEL_WIDTH as usize),
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
Span::raw(value),
|
||||||
|
])
|
||||||
|
};
|
||||||
|
let mut lines = vec![
|
||||||
|
row("Name", item.name()),
|
||||||
|
row("Kind", item.kind()),
|
||||||
|
row("Comment", get("Comment")),
|
||||||
|
row("Categories", get("Categories")),
|
||||||
|
row("Icon", get("Icon")),
|
||||||
|
row("Terminal", get("Terminal")),
|
||||||
|
row("Exec", get("Exec")),
|
||||||
|
row("File", source.display().to_string()),
|
||||||
|
row("Entry", item.desktop_path.display().to_string()),
|
||||||
|
];
|
||||||
|
if !source.exists() {
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::styled(
|
||||||
|
"The file no longer exists, so this menu entry is broken.",
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn footer(&self) -> Line<'static> {
|
||||||
|
if let Some((message, error)) = &self.status {
|
||||||
|
let color = if *error { Color::Red } else { Color::Green };
|
||||||
|
return Line::styled(message.clone(), Style::default().fg(color));
|
||||||
|
}
|
||||||
|
let hints = match self.mode {
|
||||||
|
Mode::Browse => "↑↓ select enter edit d remove r reload q quit",
|
||||||
|
Mode::Edit(_) => "tab/↑↓ field space toggle ctrl+u clear enter save esc cancel",
|
||||||
|
Mode::Confirm => "e entry only d entry + file esc cancel",
|
||||||
|
};
|
||||||
|
Line::styled(hints, Style::default().fg(Color::DarkGray))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let Some(item) = self.selected() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let file_name = item
|
||||||
|
.source()
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let rect = popup(area, 64, 6);
|
||||||
|
frame.render_widget(Clear, rect);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::from(format!("Remove \"{}\"?", item.name())),
|
||||||
|
Line::default(),
|
||||||
|
Line::from("e remove the menu entry only"),
|
||||||
|
Line::from(format!("d also delete {file_name}")),
|
||||||
|
])
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title(" Remove ")
|
||||||
|
.border_style(Style::default().fg(Color::Red)),
|
||||||
|
),
|
||||||
|
rect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Form {
|
||||||
|
inputs: [Input; 5],
|
||||||
|
terminal: bool,
|
||||||
|
focus: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Form {
|
||||||
|
fn new(fields: Fields) -> Self {
|
||||||
|
Self {
|
||||||
|
inputs: [
|
||||||
|
fields.name,
|
||||||
|
fields.comment,
|
||||||
|
fields.categories,
|
||||||
|
fields.icon,
|
||||||
|
fields.args,
|
||||||
|
]
|
||||||
|
.map(Input::new),
|
||||||
|
terminal: fields.terminal,
|
||||||
|
focus: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields(&self) -> Fields {
|
||||||
|
let [name, comment, categories, icon, args] =
|
||||||
|
self.inputs.each_ref().map(|input| input.value.clone());
|
||||||
|
Fields {
|
||||||
|
name,
|
||||||
|
comment,
|
||||||
|
categories,
|
||||||
|
icon,
|
||||||
|
args,
|
||||||
|
terminal: self.terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key(&mut self, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab | KeyCode::Down => self.focus = (self.focus + 1) % LABELS.len(),
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
self.focus = (self.focus + LABELS.len() - 1) % LABELS.len();
|
||||||
|
}
|
||||||
|
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right if self.focus == TERMINAL_FIELD => {
|
||||||
|
self.terminal = !self.terminal;
|
||||||
|
}
|
||||||
|
_ if self.focus == TERMINAL_FIELD => {}
|
||||||
|
_ => self.inputs[self.focus].on_key(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let rect = popup(area, 80, LABELS.len() as u16 + 2);
|
||||||
|
frame.render_widget(Clear, rect);
|
||||||
|
let block = Block::bordered().title(" Edit entry ");
|
||||||
|
let inner = block.inner(rect);
|
||||||
|
frame.render_widget(block, rect);
|
||||||
|
|
||||||
|
let value_width = inner.width.saturating_sub(LABEL_WIDTH) as usize;
|
||||||
|
for (row, label) in LABELS.iter().enumerate() {
|
||||||
|
let y = inner.y + row as u16;
|
||||||
|
if y >= inner.bottom() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let focused = row == self.focus;
|
||||||
|
let label_style = if focused {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
let (text, cursor) = if row == TERMINAL_FIELD {
|
||||||
|
let mark = if self.terminal { "[x]" } else { "[ ]" };
|
||||||
|
(format!("{mark} run in a terminal"), None)
|
||||||
|
} else {
|
||||||
|
let (text, cursor) = self.inputs[row].view(value_width);
|
||||||
|
(text, Some(cursor))
|
||||||
|
};
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{label:<width$}", width = LABEL_WIDTH as usize),
|
||||||
|
label_style,
|
||||||
|
),
|
||||||
|
Span::raw(text),
|
||||||
|
]);
|
||||||
|
frame.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
|
||||||
|
if focused && let Some(cursor) = cursor {
|
||||||
|
frame.set_cursor_position((inner.x + LABEL_WIDTH + cursor as u16, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Input {
|
||||||
|
value: String,
|
||||||
|
cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
fn new(value: String) -> Self {
|
||||||
|
let cursor = value.chars().count();
|
||||||
|
Self { value, cursor }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_index(&self, char_index: usize) -> usize {
|
||||||
|
self.value
|
||||||
|
.char_indices()
|
||||||
|
.nth(char_index)
|
||||||
|
.map_or(self.value.len(), |(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key(&mut self, key: KeyEvent) {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let len = self.value.chars().count();
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('u') if ctrl => {
|
||||||
|
self.value.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if !ctrl => {
|
||||||
|
let at = self.byte_index(self.cursor);
|
||||||
|
self.value.insert(at, c);
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace if self.cursor > 0 => {
|
||||||
|
self.cursor -= 1;
|
||||||
|
let at = self.byte_index(self.cursor);
|
||||||
|
self.value.remove(at);
|
||||||
|
}
|
||||||
|
KeyCode::Delete if self.cursor < len => {
|
||||||
|
let at = self.byte_index(self.cursor);
|
||||||
|
self.value.remove(at);
|
||||||
|
}
|
||||||
|
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
|
||||||
|
KeyCode::Right => self.cursor = (self.cursor + 1).min(len),
|
||||||
|
KeyCode::Home => self.cursor = 0,
|
||||||
|
KeyCode::End => self.cursor = len,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, width: usize) -> (String, usize) {
|
||||||
|
let width = width.max(1);
|
||||||
|
let start = (self.cursor + 1).saturating_sub(width);
|
||||||
|
let text = self.value.chars().skip(start).take(width).collect();
|
||||||
|
(text, self.cursor - start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn popup(area: Rect, width: u16, height: u16) -> Rect {
|
||||||
|
let width = width.min(area.width);
|
||||||
|
let height = height.min(area.height);
|
||||||
|
Rect::new(
|
||||||
|
area.x + (area.width - width) / 2,
|
||||||
|
area.y + (area.height - height) / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user