File-association list view in settings + fix ghost glyphs after window moves
- New 'Filformat' row in the settings dialog opens a list of all [open_with] associations (.ext → command) plus the default editor; Delete removes an association (persisted to config.toml), Esc goes back - Ghost fix: some terminals leave the right half of wide glyphs (emoji) behind when the diff only rewrites part of them — windows being dragged/resized/closed/snapped now request one full terminal repaint on completion, clearing any leftovers. Verified the compositor itself is clean (headless frames have no ghosts) - Integration suite: 41/41 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
73
src/app.rs
73
src/app.rs
@@ -111,6 +111,9 @@ pub struct App {
|
|||||||
last_title_click: Option<(usize, std::time::Instant)>,
|
last_title_click: Option<(usize, std::time::Instant)>,
|
||||||
/// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet
|
/// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet
|
||||||
pub copy_mode: Option<CopyMode>,
|
pub copy_mode: Option<CopyMode>,
|
||||||
|
/// Begär full omritning av värdterminalen (rensar spök-tecken som
|
||||||
|
/// vissa terminaler lämnar kvar när breda glyfer delvis skrivs över)
|
||||||
|
pub needs_full_redraw: bool,
|
||||||
/// mtime-signatur för bevakade config-filer (hot-reload)
|
/// mtime-signatur för bevakade config-filer (hot-reload)
|
||||||
watch_sig: Vec<Option<std::time::SystemTime>>,
|
watch_sig: Vec<Option<std::time::SystemTime>>,
|
||||||
/// Senaste hot-reload-kollen
|
/// Senaste hot-reload-kollen
|
||||||
@@ -310,10 +313,13 @@ pub enum WindowContent {
|
|||||||
},
|
},
|
||||||
/// Inbyggd inställningsdialog (tema, panel, editor, skugga, scrollbar)
|
/// Inbyggd inställningsdialog (tema, panel, editor, skugga, scrollbar)
|
||||||
Settings {
|
Settings {
|
||||||
/// Markerad rad (0=tema, 1=panel, 2=editor, 3=skugga, 4=scrollbar)
|
/// Markerad rad (0=tema, 1=panel, 2=editor, 3=skugga,
|
||||||
|
/// 4=scrollbar, 5=associationer)
|
||||||
selected: usize,
|
selected: usize,
|
||||||
/// Textredigering pågår för editor-raden (buffern som skrivs i)
|
/// Textredigering pågår för editor-raden (buffern som skrivs i)
|
||||||
editing: Option<String>,
|
editing: Option<String>,
|
||||||
|
/// Associationsvyn är öppen: markerat index i listan
|
||||||
|
assoc: Option<usize>,
|
||||||
},
|
},
|
||||||
/// "Öppna med"-dialog: fråga vilken app som ska öppna en fil vars
|
/// "Öppna med"-dialog: fråga vilken app som ska öppna en fil vars
|
||||||
/// format saknar association.
|
/// format saknar association.
|
||||||
@@ -531,6 +537,7 @@ impl App {
|
|||||||
self.status_states = Self::build_status_states(&config);
|
self.status_states = Self::build_status_states(&config);
|
||||||
self.watch_sig = Self::config_signature(&Self::watch_paths(&config));
|
self.watch_sig = Self::config_signature(&Self::watch_paths(&config));
|
||||||
self.config = config;
|
self.config = config;
|
||||||
|
self.needs_full_redraw = true;
|
||||||
crate::log::log("config hot-reload: laddade om config/tema/paneler");
|
crate::log::log("config hot-reload: laddade om config/tema/paneler");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +572,7 @@ impl App {
|
|||||||
switcher: None,
|
switcher: None,
|
||||||
last_title_click: None,
|
last_title_click: None,
|
||||||
copy_mode: None,
|
copy_mode: None,
|
||||||
|
needs_full_redraw: false,
|
||||||
watch_sig,
|
watch_sig,
|
||||||
last_watch_check: None,
|
last_watch_check: None,
|
||||||
hovered_tui_btn: false,
|
hovered_tui_btn: false,
|
||||||
@@ -1135,6 +1143,11 @@ impl App {
|
|||||||
MouseEventKind::Up(btn) => {
|
MouseEventKind::Up(btn) => {
|
||||||
// Släpptes ett fönster-drag vid skärmkanten? → snap till halva
|
// Släpptes ett fönster-drag vid skärmkanten? → snap till halva
|
||||||
let was_dragging = self.windows.iter().find(|w| w.dragging.is_some()).map(|w| w.id);
|
let was_dragging = self.windows.iter().find(|w| w.dragging.is_some()).map(|w| w.id);
|
||||||
|
let was_resizing = self.windows.iter().any(|w| w.resizing.is_some());
|
||||||
|
if was_dragging.is_some() || was_resizing {
|
||||||
|
// Fönstret har flyttats/ändrats — rensa ev. spökglyfer
|
||||||
|
self.needs_full_redraw = true;
|
||||||
|
}
|
||||||
for w in &mut self.windows {
|
for w in &mut self.windows {
|
||||||
w.dragging = None;
|
w.dragging = None;
|
||||||
w.resizing = None;
|
w.resizing = None;
|
||||||
@@ -1341,7 +1354,7 @@ 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..=5).contains(&rel) {
|
if (1..=6).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) {
|
||||||
@@ -1866,7 +1879,7 @@ impl App {
|
|||||||
y,
|
y,
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
content: WindowContent::Settings { selected: 0, editing: None },
|
content: WindowContent::Settings { selected: 0, editing: None, assoc: None },
|
||||||
dragging: None,
|
dragging: None,
|
||||||
resizing: None,
|
resizing: None,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
@@ -1884,7 +1897,47 @@ 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 = 5;
|
const ROWS: usize = 6;
|
||||||
|
// Associationsvyn är öppen?
|
||||||
|
let assoc_open = matches!(
|
||||||
|
self.settings_content(id),
|
||||||
|
Some(WindowContent::Settings { assoc: Some(_), .. })
|
||||||
|
);
|
||||||
|
if assoc_open {
|
||||||
|
let mut exts: Vec<String> = self.config.open_with.keys().cloned().collect();
|
||||||
|
exts.sort();
|
||||||
|
let len = exts.len();
|
||||||
|
let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) else { return };
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
*assoc = None;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(i) = assoc.as_mut() {
|
||||||
|
*i = i.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(i) = assoc.as_mut() {
|
||||||
|
*i = (*i + 1).min(len.saturating_sub(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete | KeyCode::Backspace => {
|
||||||
|
let idx = assoc.unwrap_or(0);
|
||||||
|
if let Some(ext) = exts.get(idx).cloned() {
|
||||||
|
if let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) {
|
||||||
|
if let Some(i) = assoc.as_mut() {
|
||||||
|
*i = i.saturating_sub(if idx + 1 == len { 1 } else { 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.config.open_with.remove(&ext);
|
||||||
|
let _ = Config::remove_open_with("config.toml", &ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Textredigering av editor-raden pågår?
|
// Textredigering av editor-raden pågår?
|
||||||
if let Some(WindowContent::Settings { editing, .. }) = self.settings_content(id) {
|
if let Some(WindowContent::Settings { editing, .. }) = self.settings_content(id) {
|
||||||
if editing.is_some() {
|
if editing.is_some() {
|
||||||
@@ -1959,13 +2012,20 @@ impl App {
|
|||||||
/// 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 current_editor = self.config.default_editor.clone().unwrap_or_default();
|
let current_editor = self.config.default_editor.clone().unwrap_or_default();
|
||||||
let Some(WindowContent::Settings { selected, editing }) = self.settings_content(id) else { return };
|
let Some(WindowContent::Settings { selected, editing, .. }) = self.settings_content(id) else { return };
|
||||||
let row = *selected;
|
let row = *selected;
|
||||||
if row == 2 {
|
if row == 2 {
|
||||||
// Editor: börja textredigera värdet
|
// Editor: börja textredigera värdet
|
||||||
*editing = Some(current_editor);
|
*editing = Some(current_editor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if row == 5 {
|
||||||
|
// Associationer: öppna listvyn
|
||||||
|
if let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) {
|
||||||
|
*assoc = Some(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
match row {
|
match row {
|
||||||
0 => {
|
0 => {
|
||||||
// Tema: cykla inbyggt + themes/*.toml
|
// Tema: cykla inbyggt + themes/*.toml
|
||||||
@@ -2179,6 +2239,7 @@ impl App {
|
|||||||
if w.prev_geom.is_none() {
|
if w.prev_geom.is_none() {
|
||||||
w.prev_geom = Some((w.x, w.y, w.width, w.height));
|
w.prev_geom = Some((w.x, w.y, w.width, w.height));
|
||||||
}
|
}
|
||||||
|
self.needs_full_redraw = true;
|
||||||
w.x = if left { ca.x as i32 } else { (ca.x + half_w) as i32 };
|
w.x = if left { ca.x as i32 } else { (ca.x + half_w) as i32 };
|
||||||
w.y = ca.y as i32;
|
w.y = ca.y as i32;
|
||||||
w.width = if left { half_w } else { ca.width - half_w };
|
w.width = if left { half_w } else { ca.width - half_w };
|
||||||
@@ -2195,6 +2256,7 @@ impl App {
|
|||||||
if !w.resizable {
|
if !w.resizable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.needs_full_redraw = true;
|
||||||
let is_maximized = w.x == ca.x as i32
|
let is_maximized = w.x == ca.x as i32
|
||||||
&& w.y == ca.y as i32
|
&& w.y == ca.y as i32
|
||||||
&& w.width == ca.width
|
&& w.width == ca.width
|
||||||
@@ -2221,6 +2283,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn close_window(&mut self, id: usize) {
|
fn close_window(&mut self, id: usize) {
|
||||||
|
self.needs_full_redraw = true;
|
||||||
self.windows.retain(|w| w.id != id);
|
self.windows.retain(|w| w.id != id);
|
||||||
if self.focused_id == Some(id) {
|
if self.focused_id == Some(id) {
|
||||||
self.focused_id = self.windows.last().map(|w| w.id);
|
self.focused_id = self.windows.last().map(|w| w.id);
|
||||||
|
|||||||
@@ -337,6 +337,17 @@ impl Config {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ta bort en filassociation ur [open_with] i config-filen.
|
||||||
|
pub fn remove_open_with(path: &str, ext: &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 let Some(tbl) = doc.get_mut("open_with").and_then(|t| t.as_table_mut()) {
|
||||||
|
tbl.remove(ext);
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
|||||||
@@ -262,6 +262,12 @@ fn run_standalone_loop(
|
|||||||
// ── Kitty bakgrundsbild ──────────────────────────────────────────
|
// ── Kitty bakgrundsbild ──────────────────────────────────────────
|
||||||
update_background(terminal, app, &mut bg_state, size.width, size.height);
|
update_background(terminal, app, &mut bg_state, size.width, size.height);
|
||||||
|
|
||||||
|
// Full omritning efter fönsterflytt/-stängning: rensar halva
|
||||||
|
// emoji-glyfer ("spöken") som terminal-diffen annars lämnar kvar.
|
||||||
|
if app.needs_full_redraw {
|
||||||
|
app.needs_full_redraw = false;
|
||||||
|
terminal.clear()?;
|
||||||
|
}
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
app.update_layout(area);
|
app.update_layout(area);
|
||||||
|
|||||||
@@ -390,8 +390,8 @@ 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, editing } => {
|
WindowContent::Settings { selected, editing, assoc } => {
|
||||||
render_settings_content(frame, cr, *selected, editing.as_deref(), app, th);
|
render_settings_content(frame, cr, *selected, editing.as_deref(), *assoc, app, th);
|
||||||
}
|
}
|
||||||
WindowContent::OpenWith { path, input, save_default } => {
|
WindowContent::OpenWith { path, input, save_default } => {
|
||||||
render_open_with_content(frame, cr, path, input, *save_default, th);
|
render_open_with_content(frame, cr, path, input, *save_default, th);
|
||||||
@@ -522,9 +522,68 @@ fn render_settings_content(
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
editing: Option<&str>,
|
editing: Option<&str>,
|
||||||
|
assoc: Option<usize>,
|
||||||
app: &App,
|
app: &App,
|
||||||
th: &crate::theme::Theme,
|
th: &crate::theme::Theme,
|
||||||
) {
|
) {
|
||||||
|
// Associationsvyn: lista filformat → kommandon
|
||||||
|
if let Some(sel) = assoc {
|
||||||
|
let mut exts: Vec<&String> = app.config.open_with.keys().collect();
|
||||||
|
exts.sort();
|
||||||
|
let mut y = area.y;
|
||||||
|
let editor = app.config.default_editor.as_deref().unwrap_or("(ingen)");
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(
|
||||||
|
format!(" Textfiler → {} (default_editor)", editor),
|
||||||
|
Style::default().fg(th.hint),
|
||||||
|
)),
|
||||||
|
Rect::new(area.x, y, area.width, 1),
|
||||||
|
);
|
||||||
|
y += 2;
|
||||||
|
if exts.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(
|
||||||
|
" (inga associationer — spara via Öppna med-dialogen)",
|
||||||
|
Style::default().fg(th.hint),
|
||||||
|
)),
|
||||||
|
Rect::new(area.x, y, area.width, 1),
|
||||||
|
);
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
let list_bottom = area.y + area.height.saturating_sub(2);
|
||||||
|
for (i, ext) in exts.iter().enumerate() {
|
||||||
|
if y >= list_bottom {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(" …fler", Style::default().fg(th.hint))),
|
||||||
|
Rect::new(area.x, y, area.width, 1),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let cmd = app.config.open_with.get(*ext).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let style = if i == sel {
|
||||||
|
th.hover_highlight()
|
||||||
|
} else {
|
||||||
|
Style::default().fg(th.text)
|
||||||
|
};
|
||||||
|
let line = format!(" .{:<8} → {}", ext, cmd);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(
|
||||||
|
format!("{:<width$}", line, width = area.width as usize),
|
||||||
|
style,
|
||||||
|
)),
|
||||||
|
Rect::new(area.x, y, area.width, 1),
|
||||||
|
);
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(
|
||||||
|
" ↑↓ välj Delete=ta bort Esc=tillbaka",
|
||||||
|
Style::default().fg(th.hint),
|
||||||
|
)),
|
||||||
|
Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let theme_name = app.config.theme.as_deref().unwrap_or("(inbyggt)");
|
let theme_name = app.config.theme.as_deref().unwrap_or("(inbyggt)");
|
||||||
let panel_name = app
|
let panel_name = app
|
||||||
.config
|
.config
|
||||||
@@ -547,6 +606,10 @@ fn render_settings_content(
|
|||||||
("Editor".to_string(), editor_val),
|
("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()),
|
||||||
|
(
|
||||||
|
"Filformat".to_string(),
|
||||||
|
format!("{} associationer (Enter=visa/ta bort)", app.config.open_with.len()),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut y = area.y + 1;
|
let mut y = area.y + 1;
|
||||||
|
|||||||
@@ -440,6 +440,27 @@ def main():
|
|||||||
app.send({"type": "close_window", "window_id": op2["id"]})
|
app.send({"type": "close_window", "window_id": op2["id"]})
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# ── 4j. Associationsvyn i inställningarna ────────────────────────
|
||||||
|
disp.send(mouse_event({"Down": "Left"}, gx, 0))
|
||||||
|
disp.send(mouse_event({"Up": "Left"}, gx, 0))
|
||||||
|
text = disp.frame_text(timeout=5.0, contains="Filformat")
|
||||||
|
check("inställningarna visar filformat-raden", "1 associationer" in text,
|
||||||
|
f"frame: {text[:300]!r}")
|
||||||
|
for _ in range(5):
|
||||||
|
disp.send(key_event(code="Down"))
|
||||||
|
disp.send(key_event(code="Enter"))
|
||||||
|
text = disp.frame_text(timeout=5.0, contains=".txt")
|
||||||
|
check("associationsvyn listar .txt → less",
|
||||||
|
".txt" in text and "less" in text)
|
||||||
|
disp.send(key_event(code="Delete"))
|
||||||
|
time.sleep(0.5)
|
||||||
|
cfg_now = open(os.path.join(cfgdir, "config.toml")).read()
|
||||||
|
check("Delete tar bort associationen ur config",
|
||||||
|
'txt = "less"' not in cfg_now)
|
||||||
|
disp.send(key_event(code="Esc"))
|
||||||
|
disp.send(key_event(code="Esc"))
|
||||||
|
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