open_file: file associations, default editor and 'Open with' dialog

- New open_file IPC message: resolves [open_with] extension mapping,
  falls back to default_editor for known text formats, otherwise opens
  an 'Öppna med' dialog where the user types a command (live preview
  of the full command line, path tail-truncated so the extension stays
  visible) and chooses open-once or save-as-default for the extension
  (persisted to [open_with] in config.toml)
- default_editor configurable in the settings dialog (new Editor row
  with inline text editing) and in config.toml; '{}' in commands is
  replaced with the shell-quoted path
- Documented in SOCKET_API.md; integration suite covers dialog →
  save-association → direct open (38/38)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:33:07 +02:00
parent 4cc33cf62c
commit 10ef87879a
7 changed files with 375 additions and 14 deletions

View File

@@ -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).

View File

@@ -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<String>,
},
/// "Ö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<usize> = 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("{}", &quoted)
} else {
format!("{} {}", cmd, quoted)
}
}
fn default_shell() -> String {
if cfg!(windows) {
// Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd)

View File

@@ -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<String>,
/// 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<String>,
/// Filassociationer: ändelse (gemener) → kommando för open_file.
#[serde(default)]
pub open_with: std::collections::HashMap<String, String>,
/// 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();

View File

@@ -84,6 +84,13 @@ pub enum ClientMessage {
#[serde(skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
},
/// 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<String>,
},
SetBackground {
/// Sökväg till bildfil, eller tom/null för att ta bort bakgrund
#[serde(default)]

View File

@@ -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 {

View File

@@ -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<char> = 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<Span>| {
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,

View File

@@ -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 t 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?",