This commit is contained in:
Puechberty Arthur
2026-07-18 22:53:56 +02:00
commit ef165dd804
9 changed files with 3003 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2194
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "runapp"
version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = "0.26.0"
crossterm = "0.27.0"
ratatui-image = "1.0.5"
image = { version = "0.25.0", features = ["webp", "avif", "rayon", "tiff", "pnm", "tga", "dds", "qoi", "exr"] }
resvg = "0.42.0"
usvg = "0.42.0"
tiny-skia = "0.11.4"
toml = "0.8.12"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
evalexpr = "11.0.0"
dirs = "5.0.1"
fuzzy-matcher = "0.3.7"
anyhow = "1.0"
indexmap = { version = "2.2", features = ["serde"] }
+278
View File
@@ -0,0 +1,278 @@
use crate::config::{Config, get_history_path};
use crate::desktop::{Application, SystemCommand, get_system_commands, scan_apps};
use crate::image_handler::{find_icon_path, load_image};
use image::DynamicImage;
use ratatui_image::picker::Picker;
use ratatui_image::picker::ProtocolType;
use std::collections::{HashMap, HashSet};
use std::sync::mpsc;
use std::thread;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use serde_json;
use std::fs;
use std::io::BufRead;
pub struct App {
pub config: Config,
pub history: HashMap<String, u32>,
pub apps: Vec<Application>,
pub sys_commands: Vec<SystemCommand>,
pub picker: Option<Picker>,
pub cache_images: HashMap<String, Box<dyn ratatui_image::protocol::StatefulProtocol>>,
pub is_dmenu: bool,
tx_request: mpsc::Sender<String>,
rx_response: mpsc::Receiver<(String, Option<DynamicImage>)>,
pending_icons: HashSet<String>,
pub input: String,
pub selected_index: usize,
pub should_quit: bool,
pub command_to_run: Option<String>,
}
impl App {
pub fn new(is_dmenu: bool) -> Self {
let config = Config::load();
let history = load_history();
let apps = if is_dmenu {
// Mode Dmenu : on lit les lignes envoyées sur l'entrée standard (stdin)
std::io::stdin().lock().lines().filter_map(|l| l.ok()).map(|line| {
Application {
nom: line.clone(),
commande: line,
icone: String::new(),
keywords: String::new(),
description: String::new(),
is_terminal: false,
}
}).collect()
} else {
let mut a = scan_apps();
// Chargement des alias personnalisés
for (nom, cmd) in &config.aliases {
a.push(Application {
nom: nom.clone(),
commande: cmd.clone(),
icone: "utilities-terminal".to_string(),
keywords: nom.clone(),
description: "Alias personnalisé".to_string(),
is_terminal: false,
});
}
a
};
let sys_commands = if is_dmenu { Vec::new() } else { get_system_commands() };
let picker = match Picker::from_termios() {
Ok(mut p) => {
p.protocol_type = ProtocolType::Kitty;
Some(p)
}
Err(_) => None,
};
let (tx_request, rx_request) = mpsc::channel::<String>();
let (tx_response, rx_response) = mpsc::channel::<(String, Option<DynamicImage>)>();
thread::spawn(move || {
while let Ok(path) = rx_request.recv() {
let img = load_image(&path);
let _ = tx_response.send((path, img));
}
});
Self {
config, history, apps, sys_commands, picker, cache_images: HashMap::new(), is_dmenu,
tx_request, rx_response, pending_icons: HashSet::new(),
input: String::new(), selected_index: 0, should_quit: false, command_to_run: None,
}
}
pub fn tick(&mut self) {
while let Ok((path, img)) = self.rx_response.try_recv() {
self.pending_icons.remove(&path);
if let Some(img) = img {
if let Some(picker) = self.picker.as_mut() {
let protocol = picker.new_resize_protocol(img);
self.cache_images.insert(path, protocol);
}
}
}
}
pub fn handle_key(&mut self, key: crossterm::event::KeyCode) {
use crossterm::event::KeyCode;
match key {
// Échap intelligent : efface le texte, sinon quitte
KeyCode::Esc => {
if !self.input.is_empty() {
self.input.clear();
self.selected_index = 0;
} else {
self.should_quit = true;
}
}
KeyCode::Backspace => { self.input.pop(); self.selected_index = 0; }
KeyCode::Char(c) => { self.input.push(c); self.selected_index = 0; }
KeyCode::Down => {
if self.selected_index < self.get_filtered_items_len().saturating_sub(1) {
self.selected_index += 1;
}
}
KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
KeyCode::Enter => self.execute_action(),
_ => {}
}
}
fn is_special_mode(&self) -> bool {
let is_calc = self.config.enable_calculator && self.input.starts_with('=');
let is_trad = self.config.enable_translation && self.input.starts_with('!');
let is_sys = self.config.enable_system_commands && self.input.starts_with(':');
is_calc || is_trad || is_sys
}
fn execute_action(&mut self) {
// En mode Dmenu, on renvoie juste le texte sélectionné
if self.is_dmenu {
let apps = self.get_filtered_apps();
if let Some(app) = apps.get(self.selected_index) {
self.command_to_run = Some(app.nom.clone());
}
self.should_quit = true;
return;
}
let is_calc = self.config.enable_calculator && self.input.starts_with('=');
let is_trad = self.config.enable_translation && self.input.starts_with('!');
let is_sys = self.config.enable_system_commands && self.input.starts_with(':');
let is_link = self.input.starts_with("http://") || self.input.starts_with("https://") || self.input.starts_with('/') || self.input.starts_with("~/");
if is_calc {
let expr = self.input.trim_start_matches('=');
if let Ok(val) = evalexpr::eval(expr) {
let res = val.to_string();
let cmd = if std::env::var("WAYLAND_DISPLAY").is_ok() { format!("printf '%s' '{}' | wl-copy", res) } else { format!("printf '%s' '{}' | xclip -selection clipboard", res) };
self.command_to_run = Some(cmd);
}
} else if is_trad {
let text = self.input.trim_start_matches('!').trim();
if !text.is_empty() {
let url = self.config.translation_url.replace("{}", &text.replace(' ', "%20"));
self.command_to_run = Some(format!("xdg-open \"{}\"", url));
}
} else if is_sys {
let sys_cmds = self.get_filtered_sys_commands();
if let Some(sys) = sys_cmds.get(self.selected_index) {
self.command_to_run = Some(sys.commande.clone());
}
} else if is_link && self.selected_index == 0 {
self.command_to_run = Some(format!("xdg-open \"{}\"", self.input));
} else {
let apps = self.get_filtered_apps();
let sys = self.get_filtered_sys_commands();
let app_index = self.selected_index.saturating_sub(if is_link { 1 } else { 0 }).saturating_sub(sys.len());
if let Some(app) = apps.get(app_index) {
let nom = app.nom.clone();
let is_terminal = app.is_terminal;
let commande = app.commande.clone();
*self.history.entry(nom).or_insert(0) += 1;
save_history(&self.history);
if is_terminal {
self.command_to_run = Some(format!("{} {}", self.config.terminal_emulator, commande));
} else {
self.command_to_run = Some(commande);
}
} else if self.config.enable_web_search && !self.input.is_empty() {
let url = self.config.web_search_url.replace("{}", &self.input.replace(' ', "+"));
self.command_to_run = Some(format!("xdg-open \"{}\"", url));
}
}
self.should_quit = true;
}
pub fn get_filtered_sys_commands(&self) -> Vec<&SystemCommand> {
if self.is_dmenu { return Vec::new(); }
if self.config.enable_system_commands && self.input.starts_with(':') {
let search = self.input.trim_start_matches(':').to_lowercase();
self.sys_commands.iter().filter(|c| c.nom.contains(&search)).collect()
} else {
Vec::new()
}
}
pub fn get_filtered_apps(&self) -> Vec<&Application> {
if self.is_special_mode() && !self.is_dmenu {
return Vec::new();
}
let matcher = SkimMatcherV2::default();
let mut scored_apps: Vec<(i64, &Application)> = self.apps.iter().filter_map(|app| {
if self.input.is_empty() {
let score = *self.history.get(&app.nom).unwrap_or(&0) as i64;
Some((score, app))
} else {
let nom_score = matcher.fuzzy_match(&app.nom.to_lowercase(), &self.input.to_lowercase()).unwrap_or(-100);
let kw_score = matcher.fuzzy_match(&app.keywords.to_lowercase(), &self.input.to_lowercase()).unwrap_or(-100) / 2;
let max_score = nom_score.max(kw_score);
if max_score > 0 {
let hist_bonus = *self.history.get(&app.nom).unwrap_or(&0) as i64 * 10;
Some((max_score + hist_bonus, app))
} else {
None
}
}
}).collect();
scored_apps.sort_by(|a, b| b.0.cmp(&a.0));
scored_apps.into_iter().map(|(_, app)| app).collect()
}
pub fn get_filtered_items_len(&self) -> usize {
let is_link = self.input.starts_with("http://") || self.input.starts_with("https://") || self.input.starts_with('/') || self.input.starts_with("~/");
self.get_filtered_apps().len() + self.get_filtered_sys_commands().len() + if is_link { 1 } else { 0 }
}
pub fn request_icon_if_needed(&mut self, icon_name: &str) {
if let Some(picker) = &self.picker {
if picker.protocol_type != ProtocolType::Kitty { return; }
} else { return; }
if let Some(path) = find_icon_path(icon_name) {
if !self.cache_images.contains_key(&path) && !self.pending_icons.contains(&path) {
let _ = self.tx_request.send(path.clone());
self.pending_icons.insert(path);
}
}
}
}
fn load_history() -> HashMap<String, u32> {
if let Some(path) = get_history_path() {
if let Ok(content) = fs::read_to_string(&path) {
return serde_json::from_str(&content).unwrap_or_default();
}
}
HashMap::new()
}
fn save_history(history: &HashMap<String, u32>) {
if let Some(path) = get_history_path() {
if let Ok(json) = serde_json::to_string(history) {
let _ = fs::write(path, json);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Deserialize, Serialize, Clone)]
pub struct Config {
pub enable_calculator: bool,
pub enable_translation: bool,
pub enable_web_search: bool,
pub web_search_url: String,
pub translation_url: String,
pub terminal_emulator: String,
pub enable_system_commands: bool,
#[serde(default)]
pub aliases: IndexMap<String, String>,
#[serde(default)]
pub colors: Colors,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct Colors {
pub search_text: String,
pub selection_bg: String,
pub selection_fg: String,
}
impl Default for Config {
fn default() -> Self {
let mut aliases = IndexMap::new();
aliases.insert("update".to_string(), "ghostty -e paru -Syu".to_string());
aliases.insert("clean".to_string(), "ghostty -e paru -Sc".to_string());
Self {
enable_calculator: true,
enable_translation: true,
enable_web_search: true,
web_search_url: "https://duckduckgo.com/?q={}".to_string(),
translation_url: "https://www.deepl.com/translator#auto/auto/{}".to_string(),
terminal_emulator: "ghostty -e".to_string(),
enable_system_commands: true,
aliases,
colors: Colors {
search_text: "white".to_string(),
selection_bg: "blue".to_string(),
selection_fg: "black".to_string(),
},
}
}
}
pub fn parse_color(color_str: &str) -> ratatui::style::Color {
match color_str.to_lowercase().as_str() {
"red" => ratatui::style::Color::Red,
"green" => ratatui::style::Color::Green,
"blue" => ratatui::style::Color::Blue,
"yellow" => ratatui::style::Color::Yellow,
"magenta" => ratatui::style::Color::Magenta,
"cyan" => ratatui::style::Color::Cyan,
"black" => ratatui::style::Color::Black,
"white" => ratatui::style::Color::White,
_ => ratatui::style::Color::Reset,
}
}
impl Config {
pub fn load() -> Self {
if let Some(config_dir) = dirs::config_dir() {
let runapp_dir = config_dir.join("runapp");
let config_path = runapp_dir.join("config.toml");
if let Ok(content) = fs::read_to_string(&config_path) {
return toml::from_str(&content).unwrap_or_default();
} else {
let _ = fs::create_dir_all(&runapp_dir);
let default_cfg = Config::default();
if let Ok(toml_str) = toml::to_string(&default_cfg) {
let _ = fs::write(&config_path, toml_str);
}
return default_cfg;
}
}
Config::default()
}
}
pub fn get_history_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("runapp/history.json"))
}
+149
View File
@@ -0,0 +1,149 @@
use std::fs;
#[derive(Debug, Clone)]
pub struct Application {
pub nom: String,
pub commande: String,
pub icone: String,
pub keywords: String,
pub description: String,
pub is_terminal: bool,
}
pub struct SystemCommand {
pub nom: String,
pub commande: String,
}
pub fn get_system_commands() -> Vec<SystemCommand> {
vec![
SystemCommand { nom: "shutdown".to_string(), commande: "systemctl poweroff".to_string() },
SystemCommand { nom: "reboot".to_string(), commande: "systemctl reboot".to_string() },
SystemCommand { nom: "sleep".to_string(), commande: "systemctl suspend".to_string() },
SystemCommand { nom: "lock".to_string(), commande: "loginctl lock-session".to_string() },
SystemCommand { nom: "logout".to_string(), commande: "loginctl terminate-user $USER".to_string() },
]
}
pub fn scan_apps() -> Vec<Application> {
let mut apps = Vec::new();
let mut dirs = vec!["/usr/share/applications/".to_string()];
if let Ok(home) = std::env::var("HOME") {
dirs.push(format!("{}/.local/share/applications/", home));
}
let lang = std::env::var("LANG").unwrap_or_default();
let lang_code = lang.split(['_', '.']).next().unwrap_or("en");
for dir in dirs {
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "desktop") {
if let Ok(content) = fs::read_to_string(&path) {
let parsed_apps = parse_desktop_file(&content, lang_code);
for app in parsed_apps {
if !apps.iter().any(|a: &Application| a.nom == app.nom) {
apps.push(app);
}
}
}
}
}
}
}
apps.sort_by(|a, b| a.nom.to_lowercase().cmp(&b.nom.to_lowercase()));
apps
}
fn parse_desktop_file(content: &str, lang_code: &str) -> Vec<Application> {
let mut apps = Vec::new();
let mut current_section = "[Desktop Entry]".to_string();
// Variables pour l'appli principale
let mut nom_defaut = String::new();
let mut nom_langue = String::new();
let mut commande = String::new();
let mut icone = String::new();
let mut keywords = String::new();
let mut comment = String::new();
let mut generic_name = String::new();
let mut no_display = false;
let mut app_type = String::new();
let mut is_terminal = false;
// Variables pour les actions
let mut act_nom = String::new();
let mut act_exec = String::new();
for line in content.lines() {
if line.starts_with('[') {
// Si on change de section et qu'on était dans une action, on la sauvegarde
if current_section.starts_with("[Desktop Action") && !act_nom.is_empty() && !act_exec.is_empty() {
let main_nom = if !nom_langue.is_empty() { nom_langue.clone() } else { nom_defaut.clone() };
apps.push(Application {
nom: format!("{} - {}", main_nom, act_nom),
commande: clean_command(&act_exec),
icone: icone.clone(),
keywords: keywords.clone(),
description: comment.clone(),
is_terminal,
});
act_nom.clear();
act_exec.clear();
}
current_section = line.trim().to_string();
continue;
}
if current_section == "[Desktop Entry]" {
if line.starts_with("Name=") && nom_defaut.is_empty() { nom_defaut = line.trim_start_matches("Name=").to_string(); }
else if line.starts_with(&format!("Name[{}]=", lang_code)) && nom_langue.is_empty() { nom_langue = line.trim_start_matches(&format!("Name[{}]=", lang_code)).to_string(); }
else if line.starts_with("Exec=") && commande.is_empty() { commande = clean_command(line.trim_start_matches("Exec=")); }
else if line.starts_with("Icon=") && icone.is_empty() { icone = line.trim_start_matches("Icon=").to_string(); }
else if line.starts_with("Keywords=") && keywords.is_empty() { keywords = line.trim_start_matches("Keywords=").to_string(); }
else if line.starts_with("Comment=") && comment.is_empty() { comment = line.trim_start_matches("Comment=").to_string(); }
else if line.starts_with("GenericName=") && generic_name.is_empty() { generic_name = line.trim_start_matches("GenericName=").to_string(); }
else if line.starts_with("NoDisplay=true") { no_display = true; }
else if line.starts_with("Type=") { app_type = line.trim_start_matches("Type=").to_string(); }
else if line.starts_with("Terminal=true") { is_terminal = true; }
} else if current_section.starts_with("[Desktop Action") {
if line.starts_with("Name=") && act_nom.is_empty() { act_nom = line.trim_start_matches("Name=").to_string(); }
else if line.starts_with("Exec=") && act_exec.is_empty() { act_exec = line.trim_start_matches("Exec=").to_string(); }
}
}
// Sauvegarde de la dernière action si le fichier se termine par une action
if current_section.starts_with("[Desktop Action") && !act_nom.is_empty() && !act_exec.is_empty() {
let main_nom = if !nom_langue.is_empty() { nom_langue.clone() } else { nom_defaut.clone() };
apps.push(Application {
nom: format!("{} - {}", main_nom, act_nom),
commande: clean_command(&act_exec),
icone: icone.clone(),
keywords: keywords.clone(),
description: comment.clone(),
is_terminal,
});
}
// On ajoute l'application principale en tête de liste si elle est valide
if !no_display && app_type == "Application" && !commande.is_empty() {
apps.insert(0, Application {
nom: if !nom_langue.is_empty() { nom_langue } else { nom_defaut },
commande,
icone,
keywords,
description: if !comment.is_empty() { comment } else { generic_name },
is_terminal,
});
}
apps
}
fn clean_command(cmd: &str) -> String {
cmd.split_whitespace()
.filter(|mot| !mot.starts_with('%'))
.collect::<Vec<_>>()
.join(" ")
}
+59
View File
@@ -0,0 +1,59 @@
use image::DynamicImage;
use std::path::Path;
use std::fs;
pub fn find_icon_path(icon_name: &str) -> Option<String> {
if icon_name.starts_with('/') { return Some(icon_name.to_string()); }
// On cherche dans les tailles les plus hautes disponibles pour avoir une belle image
let base_paths = [
"/usr/share/pixmaps/",
"/usr/share/icons/hicolor/scalable/apps/",
"/usr/share/icons/hicolor/128x128/apps/",
"/usr/share/icons/hicolor/96x96/apps/",
"/usr/share/icons/hicolor/64x64/apps/",
"/usr/share/icons/hicolor/48x48/apps/",
"/usr/share/icons/hicolor/32x32/apps/",
"/usr/share/icons/Adwaita/scalable/apps/",
"/usr/share/icons/Adwaita/96x96/apps/",
"/usr/share/icons/Adwaita/48x48/apps/",
];
// On priorise les formats vectoriels et modernes, puis les formats classiques,
// et on met le XPM tout à la fin car il n'est pas lisible par la lib "image".
let extensions = ["svg", "png", "webp", "avif", "jpg", "jpeg", "gif", "tiff", "bmp", "xpm"];
for base in base_paths {
for ext in extensions {
let path = format!("{}{}.{}", base, icon_name, ext);
if Path::new(&path).exists() {
return Some(path);
}
}
}
None
}
pub fn load_image(path: &str) -> Option<DynamicImage> {
let img = if path.ends_with(".svg") {
// Conversion du SVG en PNG via resvg (car la lib image ne gère pas le SVG)
let data = fs::read(path).ok()?;
let tree = usvg::Tree::from_data(&data, &usvg::Options::default()).ok()?;
let size = tree.size();
let mut pixmap = tiny_skia::Pixmap::new(size.width() as u32, size.height() as u32)?;
resvg::render(&tree, usvg::Transform::default(), &mut pixmap.as_mut());
let rgba = image::RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
DynamicImage::ImageRgba8(rgba)
} else if path.ends_with(".xpm") {
// Le format XPM n'est pas supporté par la librairie image de Rust.
// On retourne None pour que l'interface affiche "Icône introuvable" proprement.
return None;
} else {
// Pour tous les autres formats (PNG, JPEG, WebP, AVIF, TIFF, GIF, BMP, etc.)
// La librairie image les gère parfaitement grâce aux features activées dans Cargo.toml
image::ImageReader::open(path).ok()?.decode().ok()?
};
// Redimensionne en gardant les proportions pour remplir le cadre sans déformer
Some(img.resize(512, 512, image::imageops::FilterType::Lanczos3))
}
+68
View File
@@ -0,0 +1,68 @@
mod app;
mod config;
mod desktop;
mod image_handler;
mod ui;
use anyhow::Result;
use app::App;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::execute;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io::stdout;
fn main() -> Result<()> {
// On vérifie si l'utilisateur a lancé le programme avec --dmenu
let args: Vec<String> = std::env::args().collect();
let is_dmenu = args.iter().any(|a| a == "--dmenu");
let mut app = App::new(is_dmenu);
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
loop {
terminal.draw(|frame| ui::draw(&mut app, frame))?;
app.tick();
if event::poll(std::time::Duration::from_millis(10))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
app.handle_key(key.code);
while event::poll(std::time::Duration::from_millis(0))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
app.handle_key(k.code);
if app.should_quit { break; }
}
}
}
}
}
}
if app.should_quit {
break;
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
if let Some(cmd) = app.command_to_run {
if app.is_dmenu {
// En mode dmenu, on imprime le résultat sur la sortie standard
print!("{}", cmd);
} else {
// Sinon on lance la commande système
std::process::Command::new("sh").arg("-c").arg(cmd).spawn()?;
}
}
Ok(())
}
+144
View File
@@ -0,0 +1,144 @@
use crate::app::App;
use crate::config::parse_color;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use ratatui_image::StatefulImage;
pub fn draw(app: &mut App, frame: &mut Frame) {
let area = frame.size();
let largeur = area.width;
// Récupération des couleurs personnalisées
let col_search = parse_color(&app.config.colors.search_text);
let col_sel_bg = parse_color(&app.config.colors.selection_bg);
let col_sel_fg = parse_color(&app.config.colors.selection_fg);
let disposition_verticale = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(area);
let zone_recherche = disposition_verticale[0];
let zone_principale = disposition_verticale[1];
let bloc_recherche = Block::default().borders(Borders::ALL).title(" runapp ");
let texte_recherche = if app.input.starts_with('=') && app.config.enable_calculator && !app.is_dmenu {
Paragraph::new(format!("> {}", app.input)).block(bloc_recherche).style(Style::default().fg(Color::Green))
} else if app.input.starts_with('!') && app.config.enable_translation && !app.is_dmenu {
Paragraph::new(format!("> {}", app.input)).block(bloc_recherche).style(Style::default().fg(Color::Magenta))
} else if app.input.starts_with(':') && app.config.enable_system_commands && !app.is_dmenu {
Paragraph::new(format!("> {}", app.input)).block(bloc_recherche).style(Style::default().fg(Color::Red))
} else if app.input.is_empty() {
let hint = if app.is_dmenu { "Mode Dmenu..." } else { "Tapez ici... (= calcul, ! traduction, : systeme)" };
Paragraph::new(hint).block(bloc_recherche).style(Style::default().fg(Color::DarkGray))
} else if app.get_filtered_items_len() == 0 && app.config.enable_web_search && !app.is_dmenu {
Paragraph::new(format!("> {} (Entrée pour le web)", app.input)).block(bloc_recherche).style(Style::default().fg(Color::Yellow))
} else {
Paragraph::new(format!("> {}", app.input)).block(bloc_recherche).style(Style::default().fg(col_search))
};
frame.render_widget(texte_recherche, zone_recherche);
let colonnes = if largeur > 80 && !app.is_dmenu {
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(zone_principale)
} else {
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)])
.split(zone_principale)
};
if app.input.starts_with('=') && app.config.enable_calculator && !app.is_dmenu {
let expr = app.input.trim_start_matches('=');
let result = evalexpr::eval(expr).map(|v| v.to_string()).unwrap_or_else(|_| "Erreur".to_string());
let paragraph = Paragraph::new(format!("{}\n\nEntrée pour copier", result)).style(Style::default().fg(Color::Green)).block(Block::default().borders(Borders::ALL).title(" Calculatrice "));
frame.render_widget(paragraph, colonnes[0]);
} else if app.input.starts_with('!') && app.config.enable_translation && !app.is_dmenu {
let text = app.input.trim_start_matches('!').trim();
let paragraph = Paragraph::new(format!("Texte : {}\n\nEntrée pour DeepL", text)).block(Block::default().borders(Borders::ALL).title(" Traduction "));
frame.render_widget(paragraph, colonnes[0]);
} else {
let mut items: Vec<ListItem> = Vec::new();
let sys = app.get_filtered_sys_commands();
let apps = app.get_filtered_apps();
let is_link = app.input.starts_with("http://") || app.input.starts_with("https://") || app.input.starts_with('/') || app.input.starts_with("~/");
for s in &sys {
items.push(ListItem::new(Line::from(vec![
Span::styled("", Style::default().fg(Color::Red)),
Span::styled(&s.nom, Style::default().fg(Color::White)),
])));
}
if is_link {
items.push(ListItem::new(Line::from(vec![
Span::styled("🌐 ", Style::default().fg(Color::Cyan)),
Span::styled(format!("Ouvrir: {}", app.input), Style::default().fg(Color::Cyan)),
])));
}
for a in &apps {
items.push(ListItem::new(Line::from(vec![
Span::styled(&a.nom, Style::default().fg(Color::White)),
])));
}
let liste = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Actions "))
.highlight_style(Style::default().bg(col_sel_bg).fg(col_sel_fg));
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.selected_index));
frame.render_stateful_widget(liste, colonnes[0], &mut state);
if colonnes.len() > 1 {
let zone_details = Layout::default().direction(Direction::Vertical)
.constraints([Constraint::Length(20), Constraint::Min(1)])
.split(colonnes[1]);
let app_index = app.selected_index.saturating_sub(if is_link { 1 } else { 0 }).saturating_sub(sys.len());
let selected_sys = sys.get(app.selected_index);
let selected_app_data = apps.get(app_index).map(|a| {
(a.nom.clone(), a.icone.clone(), a.commande.clone(), a.description.clone(), a.is_terminal)
});
if let Some(s) = selected_sys {
let bloc_sys = Block::default().borders(Borders::ALL).title(" Commande Système ");
let texte_details = format!("Nom : {}\n\nCommande :\n{}\n\n\n⚠️ Action système ⚠️", s.nom, s.commande);
frame.render_widget(Paragraph::new(texte_details).block(bloc_sys).wrap(Wrap { trim: true }), colonnes[1]);
} else if let Some((nom, icone, commande, description, is_terminal)) = selected_app_data {
let bloc_image = Block::default().borders(Borders::ALL).title(" Icône ");
let inner_area = bloc_image.inner(zone_details[0]);
frame.render_widget(bloc_image, zone_details[0]);
if app.picker.is_some() {
if let Some(path) = crate::image_handler::find_icon_path(&icone) {
app.request_icon_if_needed(&icone);
if let Some(protocol) = app.cache_images.get_mut(&path) {
let image_widget = StatefulImage::new(None);
frame.render_stateful_widget(image_widget, inner_area, protocol);
} else {
frame.render_widget(Paragraph::new("Chargement..."), inner_area);
}
} else {
frame.render_widget(Paragraph::new("Icône introuvable"), inner_area);
}
} else {
frame.render_widget(Paragraph::new("Terminal non compatible"), inner_area);
}
let bloc_texte = Block::default().borders(Borders::ALL).title(" Informations ");
let texte_details = format!("Nom : {}\n\nIcône : {}\n\nCommande :\n{}\n\nDescription :\n{}\n\nTerminal : {}",
nom, icone, commande, description, is_terminal);
frame.render_widget(Paragraph::new(texte_details).block(bloc_texte).wrap(Wrap { trim: true }), zone_details[1]);
} else {
frame.render_widget(Block::default().borders(Borders::ALL).title(" Détails "), colonnes[1]);
}
}
}
}