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:
@@ -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).
|
||||||
|
|||||||
202
src/app.rs
202
src/app.rs
@@ -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("{}", "ed)
|
||||||
|
} 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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
11
src/main.rs
11
src/main.rs
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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="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?",
|
||||||
|
|||||||
Reference in New Issue
Block a user