diff --git a/SOCKET_API.md b/SOCKET_API.md index 3fda87c..dc32ae9 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -176,6 +176,21 @@ Close the window with the given ID. { "type": "close_window", "window_id": 3, "request_id": "req-4" } ``` +### `open_file` + +Ask TUI-WM to open a file. Resolution order: `[open_with]` association +for the file extension → `default_editor` (for known text formats) → +an interactive "Öppna med" dialog where the user types a command and +can save it as the default for that extension. Replies with +`window_opened` (the id of the spawned window or dialog). + +```json +{ "type": "open_file", "path": "/home/user/anteckningar.md", "request_id": "o1" } +``` + +In commands, `{}` is replaced with the (shell-quoted) file path; +otherwise the path is appended. + ### `set_background` Set or remove the desktop background image (requires Kitty graphics protocol support in the host terminal). diff --git a/src/app.rs b/src/app.rs index 0f6add4..bd8f647 100644 --- a/src/app.rs +++ b/src/app.rs @@ -308,10 +308,22 @@ pub enum WindowContent { input: String, cursor_pos: usize, }, - /// Inbyggd inställningsdialog (tema, panel, skugga, scrollbar) + /// Inbyggd inställningsdialog (tema, panel, editor, skugga, scrollbar) Settings { - /// Markerad rad (0=tema, 1=panel, 2=skugga, 3=scrollbar) + /// Markerad rad (0=tema, 1=panel, 2=editor, 3=skugga, 4=scrollbar) selected: usize, + /// Textredigering pågår för editor-raden (buffern som skrivs i) + editing: Option, + }, + /// "Öppna med"-dialog: fråga vilken app som ska öppna en fil vars + /// format saknar association. + OpenWith { + /// Filen som ska öppnas + path: String, + /// Kommandot som skrivs in + input: String, + /// Spara som standard för filformatet (Tab växlar) + save_default: bool, }, PopupDialog { message: String, @@ -731,6 +743,7 @@ impl App { WindowContent::RunDialog { .. } => true, WindowContent::PopupDialog { .. } => true, WindowContent::Settings { .. } => true, + WindowContent::OpenWith { .. } => true, }); let alive_ids: Vec = self.windows.iter().map(|w| w.id).collect(); self.focus_history.retain(|h| alive_ids.contains(h)); @@ -854,6 +867,16 @@ impl App { } } + // 2a1b. "Öppna med"-dialogen hanterar sina egna tangenter + if let Some(id) = self.focused_id { + let is_ow = self.windows.iter() + .any(|w| w.id == id && matches!(w.content, WindowContent::OpenWith { .. })); + if is_ow { + self.handle_open_with_key(id, key); + return; + } + } + // 2a2. Inställningsdialogen hanterar sina egna tangenter if let Some(id) = self.focused_id { let is_settings = self.windows.iter() @@ -1318,10 +1341,10 @@ impl App { self.focus_window(id); // rad 1..=4 i innehållet motsvarar inställningsrad 0..=3 let rel = row.saturating_sub(cr.y); - if (1..=4).contains(&rel) { + if (1..=5).contains(&rel) { let target = (rel - 1) as usize; let mut activate = false; - if let Some(WindowContent::Settings { selected }) = self.settings_content(id) { + if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) { if *selected == target { activate = true; } else { @@ -1721,6 +1744,108 @@ impl App { id } + /// Kända text-ändelser som öppnas med default_editor. + const TEXT_EXTENSIONS: &'static [&'static str] = &[ + "txt", "md", "rs", "py", "js", "ts", "jsx", "tsx", "c", "cpp", "h", + "hpp", "toml", "json", "yaml", "yml", "xml", "html", "css", "sh", + "bash", "zsh", "fish", "log", "conf", "cfg", "ini", "csv", "sql", + "go", "java", "kt", "lua", "rb", "php", "vim", "tex", "gitignore", + "lock", "service", "desktop", + ]; + + /// Öppna en fil: filassociation ([open_with]) vinner, textfiler går + /// till default_editor, annars visas "Öppna med"-dialogen. + /// Returnerar id för fönstret/dialogen som skapades. + pub fn open_file(&mut self, path: &str) -> usize { + let ext = std::path::Path::new(path) + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if let Some(cmd) = self.config.open_with.get(&ext).cloned() { + return self.spawn_terminal(&build_open_command(&cmd, path)); + } + if Self::TEXT_EXTENSIONS.contains(&ext.as_str()) { + if let Some(ed) = self.config.default_editor.clone() { + return self.spawn_terminal(&build_open_command(&ed, path)); + } + } + self.spawn_open_with_dialog(path.to_string()) + } + + /// Öppna "Öppna med"-dialogen för en fil utan association. + fn spawn_open_with_dialog(&mut self, path: String) -> usize { + let ca = self.content_area; + let w = 64u16.min(ca.width.saturating_sub(2)); + let h = 10u16.min(ca.height.saturating_sub(2)); + let x = ca.x as i32 + ((ca.width.saturating_sub(w)) / 2) as i32; + let y = ca.y as i32 + ((ca.height.saturating_sub(h)) / 2) as i32; + let id = self.next_id; + self.next_id += 1; + self.windows.push(FloatingWindow { + id, + x, + y, + width: w, + height: h, + content: WindowContent::OpenWith { path, input: String::new(), save_default: false }, + dragging: None, + resizing: None, + resizable: false, + mouse_mode: vt100::MouseProtocolMode::None, + mouse_encoding: vt100::MouseProtocolEncoding::Default, + mouse_seq_carry: Vec::new(), + selection: None, + kitty_gfx_carry: Vec::new(), + pending_graphics: Vec::new(), + scrollback_total: 0, + prev_geom: None, + pty_log: Vec::new(), + }); + self.focus_window(id); + id + } + + fn handle_open_with_key(&mut self, id: usize, key: KeyEvent) { + // Plocka ut fälten utan långlivat lån + let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return }; + let WindowContent::OpenWith { path, input, save_default } = &mut w.content else { return }; + match key.code { + KeyCode::Esc => { + self.close_window(id); + } + KeyCode::Tab => { + *save_default = !*save_default; + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Enter => { + let cmd = input.trim().to_string(); + if cmd.is_empty() { + return; + } + let path = path.clone(); + let save = *save_default; + self.close_window(id); + if save { + let ext = std::path::Path::new(&path) + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if !ext.is_empty() { + self.config.open_with.insert(ext.clone(), cmd.clone()); + let _ = Config::save_open_with("config.toml", &ext, &cmd); + } + } + self.spawn_terminal(&build_open_command(&cmd, &path)); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + input.push(c); + } + _ => {} + } + } + /// Öppna inställningsdialogen (eller fokusera en redan öppen). fn spawn_settings(&mut self) { if let Some(w) = self.windows.iter().find(|w| matches!(w.content, WindowContent::Settings { .. })) { @@ -1741,7 +1866,7 @@ impl App { y, width: w, height: h, - content: WindowContent::Settings { selected: 0 }, + content: WindowContent::Settings { selected: 0, editing: None }, dragging: None, resizing: None, resizable: false, @@ -1759,16 +1884,49 @@ impl App { } fn handle_settings_key(&mut self, id: usize, key: KeyEvent) { - const ROWS: usize = 4; + const ROWS: usize = 5; + // Textredigering av editor-raden pågår? + if let Some(WindowContent::Settings { editing, .. }) = self.settings_content(id) { + if editing.is_some() { + match key.code { + KeyCode::Enter => { + let buf = editing.take().unwrap_or_default(); + let val = buf.trim().to_string(); + self.config.default_editor = + if val.is_empty() { None } else { Some(val) }; + let _ = Config::save_top_level_str( + "config.toml", + "default_editor", + self.config.default_editor.as_deref(), + ); + } + KeyCode::Esc => { + *editing = None; + } + KeyCode::Backspace => { + if let Some(b) = editing.as_mut() { + b.pop(); + } + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(b) = editing.as_mut() { + b.push(c); + } + } + _ => {} + } + return; + } + } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_window(id), KeyCode::Up => { - if let Some(WindowContent::Settings { selected }) = self.settings_content(id) { + if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) { *selected = (*selected + ROWS - 1) % ROWS; } } KeyCode::Down | KeyCode::Tab => { - if let Some(WindowContent::Settings { selected }) = self.settings_content(id) { + if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) { *selected = (*selected + 1) % ROWS; } } @@ -1800,8 +1958,14 @@ impl App { /// Ändringar skrivs till config.toml — hot-reload plockar upp dem /// direkt, men tema/flaggor appliceras även omedelbart i minnet. fn settings_activate(&mut self, id: usize, forward: bool) { - let Some(WindowContent::Settings { selected }) = self.settings_content(id) else { return }; + let current_editor = self.config.default_editor.clone().unwrap_or_default(); + let Some(WindowContent::Settings { selected, editing }) = self.settings_content(id) else { return }; let row = *selected; + if row == 2 { + // Editor: börja textredigera värdet + *editing = Some(current_editor); + return; + } match row { 0 => { // Tema: cykla inbyggt + themes/*.toml @@ -1836,11 +2000,11 @@ impl App { let _ = Config::save_panel_file("config.toml", 0, &options[next]); // panel-innehållet läses om av hot-reloaden } - 2 => { + 3 => { self.config.shadow = !self.config.shadow; let _ = Config::save_top_level_bool("config.toml", "shadow", self.config.shadow); } - 3 => { + 4 => { self.config.show_scrollbar = !self.config.show_scrollbar; let _ = Config::save_top_level_bool("config.toml", "show_scrollbar", self.config.show_scrollbar); } @@ -2383,6 +2547,22 @@ fn in_rect(col: u16, row: u16, rect: Rect) -> bool { && row < rect.y + rect.height } +/// Shell-citera en sökväg för sh -c. +fn shell_quote(p: &str) -> String { + format!("'{}'", p.replace('\'', r"'\''")) +} + +/// Bygg kommandot som öppnar `path` med `cmd`: "{}" ersätts med citerad +/// sökväg, annars läggs sökvägen sist. +pub fn build_open_command(cmd: &str, path: &str) -> String { + let quoted = shell_quote(path); + if cmd.contains("{}") { + cmd.replace("{}", "ed) + } else { + format!("{} {}", cmd, quoted) + } +} + fn default_shell() -> String { if cfg!(windows) { // Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd) diff --git a/src/config.rs b/src/config.rs index 3ed7a36..4523ccc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,13 @@ pub struct Config { /// Sökväg till temafil (t.ex. "themes/dark.toml"). Om None → inbyggt tema. #[serde(default)] pub theme: Option, + /// Standard-editor för textfiler (open_file via socket/TUI-FM). + /// "{}" i kommandot ersätts med filens sökväg, annars läggs den sist. + #[serde(default)] + pub default_editor: Option, + /// Filassociationer: ändelse (gemener) → kommando för open_file. + #[serde(default)] + pub open_with: std::collections::HashMap, /// Rita skugga under/till höger om fönster. #[serde(default = "default_true")] pub shadow: bool, @@ -280,6 +287,8 @@ impl Config { terminal_bg_color: None, default_shell: None, theme: None, + default_editor: None, + open_with: std::collections::HashMap::new(), shadow: true, scrollback_lines: default_scrollback_lines(), show_scrollbar: default_true(), @@ -316,6 +325,18 @@ impl Config { Ok(()) } + /// Spara en filassociation ([open_with] ändelse → kommando). + pub fn save_open_with(path: &str, ext: &str, cmd: &str) -> anyhow::Result<()> { + let content = fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_default(); + if doc.get("open_with").is_none() { + doc["open_with"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + doc["open_with"][ext] = toml_edit::value(cmd); + fs::write(path, doc.to_string())?; + Ok(()) + } + /// Sätt fil-referensen för panel nummer `index` i config-filen. pub fn save_panel_file(path: &str, index: usize, file: &str) -> anyhow::Result<()> { let content = fs::read_to_string(path).unwrap_or_default(); diff --git a/src/ipc.rs b/src/ipc.rs index 0bebdeb..f416866 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -84,6 +84,13 @@ pub enum ClientMessage { #[serde(skip_serializing_if = "Option::is_none")] request_id: Option, }, + /// Be TUI-WM öppna en fil: filassociation eller standard-editor + /// används om möjligt, annars visas "Öppna med"-dialogen. + OpenFile { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, SetBackground { /// Sökväg till bildfil, eller tom/null för att ta bort bakgrund #[serde(default)] diff --git a/src/main.rs b/src/main.rs index 8bd1c56..b18e992 100644 --- a/src/main.rs +++ b/src/main.rs @@ -597,6 +597,9 @@ fn handle_ipc_event( format!("[Popup: {}]", &message[..message.len().min(20)]) } WindowContent::Settings { .. } => "[Inställningar]".to_string(), + WindowContent::OpenWith { path, .. } => { + format!("[Öppna: {}]", path) + } }; Some(WindowInfo { id: w.id, @@ -615,6 +618,14 @@ fn handle_ipc_event( ClientMessage::CloseWindow { window_id, .. } => { app.close_window_pub(window_id); } + ClientMessage::OpenFile { path, request_id } => { + let window_id = app.open_file(&path); + app.ipc_out.push(AppIpcOut::WindowOpened { + client_id, + request_id, + window_id, + }); + } ClientMessage::SetBackground { path, save, request_id } => { app.config.background_image = path.clone(); if save { diff --git a/src/render.rs b/src/render.rs index 957ca0e..113ab74 100644 --- a/src/render.rs +++ b/src/render.rs @@ -78,6 +78,7 @@ fn render_switcher(frame: &mut Frame, app: &App, sw: &crate::app::SwitcherState) WindowContent::RunDialog { .. } => "Kommandodialog".to_string(), WindowContent::PopupDialog { .. } => "Popup".to_string(), WindowContent::Settings { .. } => "Inställningar".to_string(), + WindowContent::OpenWith { .. } => "Öppna med".to_string(), }) .unwrap_or_else(|| format!("fönster {}", id)) }) @@ -300,6 +301,7 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) { WindowContent::RunDialog { .. } => " Tui-run ".to_string(), WindowContent::PopupDialog { .. } => " TUI-WM ".to_string(), WindowContent::Settings { .. } => " Inställningar ".to_string(), + WindowContent::OpenWith { .. } => " Öppna med ".to_string(), }; // Diskret skugga nedanför/till höger om fönstret (inte när en @@ -388,8 +390,11 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) { WindowContent::PopupDialog { .. } => { render_popup_dialog(frame, window, focused_id, th); } - WindowContent::Settings { selected } => { - render_settings_content(frame, cr, *selected, app, th); + WindowContent::Settings { selected, editing } => { + render_settings_content(frame, cr, *selected, editing.as_deref(), app, th); + } + WindowContent::OpenWith { path, input, save_default } => { + render_open_with_content(frame, cr, path, input, *save_default, th); } } @@ -516,6 +521,7 @@ fn render_settings_content( frame: &mut Frame, area: Rect, selected: usize, + editing: Option<&str>, app: &App, th: &crate::theme::Theme, ) { @@ -526,10 +532,19 @@ fn render_settings_content( .first() .and_then(|p| p.file.as_deref()) .unwrap_or("(inline)"); + let editor_val = match editing { + Some(buf) => format!("{}▏ (Enter=spara, Esc=avbryt)", buf), + None => app + .config + .default_editor + .clone() + .unwrap_or_else(|| "(ingen — Enter för att ange)".to_string()), + }; let onoff = |b: bool| if b { "[x] på " } else { "[ ] av " }; let rows: Vec<(String, String)> = vec![ ("Tema".to_string(), format!("‹ {} ›", theme_name)), ("Panel".to_string(), format!("‹ {} ›", panel_name)), + ("Editor".to_string(), editor_val), ("Skugga".to_string(), onoff(app.config.shadow).to_string()), ("Scrollbar".to_string(), onoff(app.config.show_scrollbar).to_string()), ]; @@ -576,6 +591,85 @@ fn render_settings_content( } } +/// Trunkera en sträng från vänster så att slutet (med filändelsen) +/// alltid syns: "…/lång/sökväg/fil.txt". +fn tail_fit(s: &str, max_cols: usize) -> String { + let w = unicode_width::UnicodeWidthStr::width(s); + if w <= max_cols { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut out = String::new(); + let mut used = 1; // för "…" + for &c in chars.iter().rev() { + let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + if used + cw > max_cols { + break; + } + used += cw; + out.insert(0, c); + } + format!("…{}", out) +} + +/// Innehållet i "Öppna med"-dialogen. +fn render_open_with_content( + frame: &mut Frame, + area: Rect, + path: &str, + input: &str, + save_default: bool, + th: &crate::theme::Theme, +) { + let w = area.width as usize; + let ext = std::path::Path::new(path) + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_else(|| "?".to_string()); + + let mut y = area.y; + let mut line = |frame: &mut Frame, y: u16, spans: Vec| { + if y < area.y + area.height { + frame.render_widget(Paragraph::new(Line::from(spans)), Rect::new(area.x, y, area.width, 1)); + } + }; + + // Fil (svans-trunkerad så ändelsen syns) + line(frame, y, vec![ + Span::styled(" Fil: ", Style::default().fg(th.hint)), + Span::styled(tail_fit(path, w.saturating_sub(12)), Style::default().fg(th.text)), + ]); + y += 2; + // Kommando-input + line(frame, y, vec![ + Span::styled(" Kommando: ", Style::default().fg(th.hint)), + Span::styled(input.to_string(), Style::default().fg(th.text)), + Span::styled("▏", Style::default().fg(th.accent)), + ]); + y += 1; + // Förhandsgranskning av det fullständiga kommandot + let preview = if input.trim().is_empty() { + "(skriv ett kommando — \"{}\" ersätts med sökvägen)".to_string() + } else { + crate::app::build_open_command(input.trim(), path) + }; + line(frame, y, vec![ + Span::styled(" Kör: ", Style::default().fg(th.hint)), + Span::styled(tail_fit(&preview, w.saturating_sub(12)), Style::default().fg(th.ok)), + ]); + y += 2; + // Spara som standard-kryssruta + let check = if save_default { "[x]" } else { "[ ]" }; + line(frame, y, vec![ + Span::styled(format!(" {} Spara som standard för .{}", check, ext), + if save_default { Style::default().fg(th.accent) } else { Style::default().fg(th.text) }), + ]); + y += 2; + line(frame, y, vec![ + Span::styled(" Enter=Öppna Tab=växla spara Esc=avbryt", Style::default().fg(th.hint)), + ]); +} + fn render_popup_dialog( frame: &mut Frame, window: &FloatingWindow, diff --git a/tests/integration.py b/tests/integration.py index 9218034..8b89df0 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -396,7 +396,8 @@ def main(): text = disp.frame_text(timeout=5.0, contains="Tema:") check("inställningsdialogen öppnas från panelen", "Tema:" in text and "Skugga" in text and "config.toml" in text) - # Ned två rader till Skugga och toggla av + # Ned tre rader till Skugga (0=Tema, 1=Panel, 2=Editor, 3=Skugga) + disp.send(key_event(code="Down")) disp.send(key_event(code="Down")) disp.send(key_event(code="Down")) disp.send(key_event(code="Enter")) @@ -407,6 +408,38 @@ def main(): disp.send(key_event(code="Esc")) time.sleep(0.3) + # ── 4i. open_file: Öppna med-dialog, spara association, direktöppning ─ + marker = "OPENFILE-MARKER-77" + txt = os.path.join(cfgdir, "prov.txt") + with open(txt, "w") as f: + f.write(marker + "\n") + # 1) ingen editor konfigurerad → textfil ger Öppna med-dialogen + app.send({"type": "open_file", "path": txt, "request_id": "of1"}) + app.recv_until("window_opened") + text = disp.frame_text(timeout=5.0, contains="Öppna med") + check("open_file utan editor visar Öppna med-dialogen", + "Öppna med" in text and "prov.txt" in text) + # skriv kommandot, kryssa i "spara som standard", öppna + for ch in "less": + disp.send(key_event(ch=ch)) + disp.send(key_event(code="Tab")) + disp.send(key_event(code="Enter")) + text = disp.frame_text(timeout=6.0, contains=marker) + check("Öppna med öppnar filen med angivet kommando", marker in text) + cfg_now = open(os.path.join(cfgdir, "config.toml")).read() + check("'spara som standard' skriver [open_with] till config", + 'txt = "less"' in cfg_now, f"config: {cfg_now[-200:]!r}") + disp.send(key_event(ch="q")) # avsluta less → fönstret stängs + time.sleep(0.4) + # 2) associationen finns nu → öppnas direkt utan dialog + app.send({"type": "open_file", "path": txt, "request_id": "of2"}) + op2 = app.recv_until("window_opened") + text = disp.frame_text(timeout=6.0, contains=marker) + check("filassociationen öppnar direkt", marker in text + and "Öppna med" not in text) + app.send({"type": "close_window", "window_id": op2["id"]}) + time.sleep(0.3) + # ── 5. Popup + popup_result via Enter ─────────────────────────── app.send({ "type": "spawn_popup", "message": "Integrationstest?",