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" } { "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_background`
Set or remove the desktop background image (requires Kitty graphics protocol support in the host terminal). 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, input: String,
cursor_pos: usize, cursor_pos: usize,
}, },
/// Inbyggd inställningsdialog (tema, panel, skugga, scrollbar) /// Inbyggd inställningsdialog (tema, panel, editor, skugga, scrollbar)
Settings { 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, 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 { PopupDialog {
message: String, message: String,
@@ -731,6 +743,7 @@ impl App {
WindowContent::RunDialog { .. } => true, WindowContent::RunDialog { .. } => true,
WindowContent::PopupDialog { .. } => true, WindowContent::PopupDialog { .. } => true,
WindowContent::Settings { .. } => true, WindowContent::Settings { .. } => true,
WindowContent::OpenWith { .. } => true,
}); });
let alive_ids: Vec<usize> = self.windows.iter().map(|w| w.id).collect(); let alive_ids: Vec<usize> = self.windows.iter().map(|w| w.id).collect();
self.focus_history.retain(|h| alive_ids.contains(h)); 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 // 2a2. Inställningsdialogen hanterar sina egna tangenter
if let Some(id) = self.focused_id { if let Some(id) = self.focused_id {
let is_settings = self.windows.iter() let is_settings = self.windows.iter()
@@ -1318,10 +1341,10 @@ impl App {
self.focus_window(id); self.focus_window(id);
// rad 1..=4 i innehållet motsvarar inställningsrad 0..=3 // rad 1..=4 i innehållet motsvarar inställningsrad 0..=3
let rel = row.saturating_sub(cr.y); let rel = row.saturating_sub(cr.y);
if (1..=4).contains(&rel) { if (1..=5).contains(&rel) {
let target = (rel - 1) as usize; let target = (rel - 1) as usize;
let mut activate = false; 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 { if *selected == target {
activate = true; activate = true;
} else { } else {
@@ -1721,6 +1744,108 @@ impl App {
id 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). /// Öppna inställningsdialogen (eller fokusera en redan öppen).
fn spawn_settings(&mut self) { fn spawn_settings(&mut self) {
if let Some(w) = self.windows.iter().find(|w| matches!(w.content, WindowContent::Settings { .. })) { if let Some(w) = self.windows.iter().find(|w| matches!(w.content, WindowContent::Settings { .. })) {
@@ -1741,7 +1866,7 @@ impl App {
y, y,
width: w, width: w,
height: h, height: h,
content: WindowContent::Settings { selected: 0 }, content: WindowContent::Settings { selected: 0, editing: None },
dragging: None, dragging: None,
resizing: None, resizing: None,
resizable: false, resizable: false,
@@ -1759,16 +1884,49 @@ impl App {
} }
fn handle_settings_key(&mut self, id: usize, key: KeyEvent) { 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 { match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_window(id), KeyCode::Esc | KeyCode::Char('q') => self.close_window(id),
KeyCode::Up => { 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; *selected = (*selected + ROWS - 1) % ROWS;
} }
} }
KeyCode::Down | KeyCode::Tab => { 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; *selected = (*selected + 1) % ROWS;
} }
} }
@@ -1800,8 +1958,14 @@ impl App {
/// Ändringar skrivs till config.toml — hot-reload plockar upp dem /// Ändringar skrivs till config.toml — hot-reload plockar upp dem
/// direkt, men tema/flaggor appliceras även omedelbart i minnet. /// direkt, men tema/flaggor appliceras även omedelbart i minnet.
fn settings_activate(&mut self, id: usize, forward: bool) { 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; let row = *selected;
if row == 2 {
// Editor: börja textredigera värdet
*editing = Some(current_editor);
return;
}
match row { match row {
0 => { 0 => {
// Tema: cykla inbyggt + themes/*.toml // Tema: cykla inbyggt + themes/*.toml
@@ -1836,11 +2000,11 @@ impl App {
let _ = Config::save_panel_file("config.toml", 0, &options[next]); let _ = Config::save_panel_file("config.toml", 0, &options[next]);
// panel-innehållet läses om av hot-reloaden // panel-innehållet läses om av hot-reloaden
} }
2 => { 3 => {
self.config.shadow = !self.config.shadow; self.config.shadow = !self.config.shadow;
let _ = Config::save_top_level_bool("config.toml", "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; self.config.show_scrollbar = !self.config.show_scrollbar;
let _ = Config::save_top_level_bool("config.toml", "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 && 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 { fn default_shell() -> String {
if cfg!(windows) { if cfg!(windows) {
// Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd) // 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. /// Sökväg till temafil (t.ex. "themes/dark.toml"). Om None → inbyggt tema.
#[serde(default)] #[serde(default)]
pub theme: Option<String>, 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. /// Rita skugga under/till höger om fönster.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub shadow: bool, pub shadow: bool,
@@ -280,6 +287,8 @@ impl Config {
terminal_bg_color: None, terminal_bg_color: None,
default_shell: None, default_shell: None,
theme: None, theme: None,
default_editor: None,
open_with: std::collections::HashMap::new(),
shadow: true, shadow: true,
scrollback_lines: default_scrollback_lines(), scrollback_lines: default_scrollback_lines(),
show_scrollbar: default_true(), show_scrollbar: default_true(),
@@ -316,6 +325,18 @@ impl Config {
Ok(()) 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. /// 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<()> { pub fn save_panel_file(path: &str, index: usize, file: &str) -> anyhow::Result<()> {
let content = fs::read_to_string(path).unwrap_or_default(); 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")] #[serde(skip_serializing_if = "Option::is_none")]
request_id: Option<String>, 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 { SetBackground {
/// Sökväg till bildfil, eller tom/null för att ta bort bakgrund /// Sökväg till bildfil, eller tom/null för att ta bort bakgrund
#[serde(default)] #[serde(default)]

View File

@@ -597,6 +597,9 @@ fn handle_ipc_event(
format!("[Popup: {}]", &message[..message.len().min(20)]) format!("[Popup: {}]", &message[..message.len().min(20)])
} }
WindowContent::Settings { .. } => "[Inställningar]".to_string(), WindowContent::Settings { .. } => "[Inställningar]".to_string(),
WindowContent::OpenWith { path, .. } => {
format!("[Öppna: {}]", path)
}
}; };
Some(WindowInfo { Some(WindowInfo {
id: w.id, id: w.id,
@@ -615,6 +618,14 @@ fn handle_ipc_event(
ClientMessage::CloseWindow { window_id, .. } => { ClientMessage::CloseWindow { window_id, .. } => {
app.close_window_pub(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 } => { ClientMessage::SetBackground { path, save, request_id } => {
app.config.background_image = path.clone(); app.config.background_image = path.clone();
if save { 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::RunDialog { .. } => "Kommandodialog".to_string(),
WindowContent::PopupDialog { .. } => "Popup".to_string(), WindowContent::PopupDialog { .. } => "Popup".to_string(),
WindowContent::Settings { .. } => "Inställningar".to_string(), WindowContent::Settings { .. } => "Inställningar".to_string(),
WindowContent::OpenWith { .. } => "Öppna med".to_string(),
}) })
.unwrap_or_else(|| format!("fönster {}", id)) .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::RunDialog { .. } => " Tui-run ".to_string(),
WindowContent::PopupDialog { .. } => " TUI-WM ".to_string(), WindowContent::PopupDialog { .. } => " TUI-WM ".to_string(),
WindowContent::Settings { .. } => " Inställningar ".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 // 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 { .. } => { WindowContent::PopupDialog { .. } => {
render_popup_dialog(frame, window, focused_id, th); render_popup_dialog(frame, window, focused_id, th);
} }
WindowContent::Settings { selected } => { WindowContent::Settings { selected, editing } => {
render_settings_content(frame, cr, *selected, app, th); 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, frame: &mut Frame,
area: Rect, area: Rect,
selected: usize, selected: usize,
editing: Option<&str>,
app: &App, app: &App,
th: &crate::theme::Theme, th: &crate::theme::Theme,
) { ) {
@@ -526,10 +532,19 @@ fn render_settings_content(
.first() .first()
.and_then(|p| p.file.as_deref()) .and_then(|p| p.file.as_deref())
.unwrap_or("(inline)"); .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 onoff = |b: bool| if b { "[x] på " } else { "[ ] av " };
let rows: Vec<(String, String)> = vec![ let rows: Vec<(String, String)> = vec![
("Tema".to_string(), format!(" {} ", theme_name)), ("Tema".to_string(), format!(" {} ", theme_name)),
("Panel".to_string(), format!(" {} ", panel_name)), ("Panel".to_string(), format!(" {} ", panel_name)),
("Editor".to_string(), editor_val),
("Skugga".to_string(), onoff(app.config.shadow).to_string()), ("Skugga".to_string(), onoff(app.config.shadow).to_string()),
("Scrollbar".to_string(), onoff(app.config.show_scrollbar).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( fn render_popup_dialog(
frame: &mut Frame, frame: &mut Frame,
window: &FloatingWindow, window: &FloatingWindow,

View File

@@ -396,7 +396,8 @@ def main():
text = disp.frame_text(timeout=5.0, contains="Tema:") text = disp.frame_text(timeout=5.0, contains="Tema:")
check("inställningsdialogen öppnas från panelen", "Tema:" in text check("inställningsdialogen öppnas från panelen", "Tema:" in text
and "Skugga" in text and "config.toml" 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="Down")) disp.send(key_event(code="Down"))
disp.send(key_event(code="Enter")) disp.send(key_event(code="Enter"))
@@ -407,6 +408,38 @@ def main():
disp.send(key_event(code="Esc")) disp.send(key_event(code="Esc"))
time.sleep(0.3) 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 ─────────────────────────── # ── 5. Popup + popup_result via Enter ───────────────────────────
app.send({ app.send({
"type": "spawn_popup", "message": "Integrationstest?", "type": "spawn_popup", "message": "Integrationstest?",