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:
2026-07-15 23:54:27 +02:00
parent 10ef87879a
commit a621fa10d7
5 changed files with 171 additions and 7 deletions

View File

@@ -111,6 +111,9 @@ pub struct App {
last_title_click: Option<(usize, std::time::Instant)>,
/// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet
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)
watch_sig: Vec<Option<std::time::SystemTime>>,
/// Senaste hot-reload-kollen
@@ -310,10 +313,13 @@ pub enum WindowContent {
},
/// Inbyggd inställningsdialog (tema, panel, editor, skugga, scrollbar)
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,
/// Textredigering pågår för editor-raden (buffern som skrivs i)
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
/// format saknar association.
@@ -531,6 +537,7 @@ impl App {
self.status_states = Self::build_status_states(&config);
self.watch_sig = Self::config_signature(&Self::watch_paths(&config));
self.config = config;
self.needs_full_redraw = true;
crate::log::log("config hot-reload: laddade om config/tema/paneler");
}
@@ -565,6 +572,7 @@ impl App {
switcher: None,
last_title_click: None,
copy_mode: None,
needs_full_redraw: false,
watch_sig,
last_watch_check: None,
hovered_tui_btn: false,
@@ -1135,6 +1143,11 @@ impl App {
MouseEventKind::Up(btn) => {
// 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_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 {
w.dragging = None;
w.resizing = None;
@@ -1341,7 +1354,7 @@ 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..=5).contains(&rel) {
if (1..=6).contains(&rel) {
let target = (rel - 1) as usize;
let mut activate = false;
if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) {
@@ -1866,7 +1879,7 @@ impl App {
y,
width: w,
height: h,
content: WindowContent::Settings { selected: 0, editing: None },
content: WindowContent::Settings { selected: 0, editing: None, assoc: None },
dragging: None,
resizing: None,
resizable: false,
@@ -1884,7 +1897,47 @@ impl App {
}
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?
if let Some(WindowContent::Settings { editing, .. }) = self.settings_content(id) {
if editing.is_some() {
@@ -1959,13 +2012,20 @@ impl App {
/// direkt, men tema/flaggor appliceras även omedelbart i minnet.
fn settings_activate(&mut self, id: usize, forward: bool) {
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;
if row == 2 {
// Editor: börja textredigera värdet
*editing = Some(current_editor);
return;
}
if row == 5 {
// Associationer: öppna listvyn
if let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) {
*assoc = Some(0);
}
return;
}
match row {
0 => {
// Tema: cykla inbyggt + themes/*.toml
@@ -2179,6 +2239,7 @@ impl App {
if w.prev_geom.is_none() {
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.y = ca.y as i32;
w.width = if left { half_w } else { ca.width - half_w };
@@ -2195,6 +2256,7 @@ impl App {
if !w.resizable {
return;
}
self.needs_full_redraw = true;
let is_maximized = w.x == ca.x as i32
&& w.y == ca.y as i32
&& w.width == ca.width
@@ -2221,6 +2283,7 @@ impl App {
}
fn close_window(&mut self, id: usize) {
self.needs_full_redraw = true;
self.windows.retain(|w| w.id != id);
if self.focused_id == Some(id) {
self.focused_id = self.windows.last().map(|w| w.id);

View File

@@ -337,6 +337,17 @@ impl Config {
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.
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

@@ -262,6 +262,12 @@ fn run_standalone_loop(
// ── Kitty bakgrundsbild ──────────────────────────────────────────
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| {
let area = frame.area();
app.update_layout(area);

View File

@@ -390,8 +390,8 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) {
WindowContent::PopupDialog { .. } => {
render_popup_dialog(frame, window, focused_id, th);
}
WindowContent::Settings { selected, editing } => {
render_settings_content(frame, cr, *selected, editing.as_deref(), app, th);
WindowContent::Settings { selected, editing, assoc } => {
render_settings_content(frame, cr, *selected, editing.as_deref(), *assoc, app, th);
}
WindowContent::OpenWith { path, input, save_default } => {
render_open_with_content(frame, cr, path, input, *save_default, th);
@@ -522,9 +522,68 @@ fn render_settings_content(
area: Rect,
selected: usize,
editing: Option<&str>,
assoc: Option<usize>,
app: &App,
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 panel_name = app
.config
@@ -547,6 +606,10 @@ fn render_settings_content(
("Editor".to_string(), editor_val),
("Skugga".to_string(), onoff(app.config.shadow).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;

View File

@@ -440,6 +440,27 @@ def main():
app.send({"type": "close_window", "window_id": op2["id"]})
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 ───────────────────────────
app.send({
"type": "spawn_popup", "message": "Integrationstest?",