From c9a4a8fc218181357999df53454d8e70d79ecfb6 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Wed, 15 Jul 2026 19:48:04 +0200 Subject: [PATCH] Built-in settings dialog opened from a panel gear button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New spawn_settings action + '≡ Inställningar' button in the default panel (≡ instead of ⚙ which has ambiguous character width): opens a singleton settings window - Rows: theme (cycles built-in + themes/*.toml), panel (cycles panels/*.toml), shadow and scrollbar toggles — keyboard (arrows/ Enter/Esc) and mouse (click selects, click again activates) - Shows where config.toml, themes/ and panels/ live - Changes apply immediately and persist to config.toml via toml_edit; the hot-reload watcher picks up the file writes for panels - README and --help updated with switcher/snap/copy-mode/theme docs - Integration suite: 30/30 (gear click, dialog opens, shadow toggle persisted to config.toml) Co-Authored-By: Claude Fable 5 --- README.md | 15 +++- panels/topbar.toml | 7 ++ src/app.rs | 172 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 40 ++++++++++ src/main.rs | 11 +++ src/render.rs | 70 ++++++++++++++++++ tests/integration.py | 21 ++++++ 7 files changed, 334 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 54356ae..f9c3b6f 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,15 @@ En terminal-baserad fönsterhanterare med flytande fönster, full mussuport och - Flytande terminaler med fri positionering och storleksändring - Full mussuport: vänster/höger/mitten-klick, scroll, drag, ANSI-muse-protokoll vidarebefordras till appar +- Scrollback-historik med scrollbar + copy-mode med sökning (`alt+c`) +- Fönsterväxlare i MRU-ordning (`alt+tab`/`alt+w`) +- Snap till halvskärm (`alt+left`/`alt+right` eller dra mot kanten) och maximera (`alt+up`/dubbelklick på titelraden) +- Teman i egna TOML-filer (`themes/`) + inbyggd inställningsdialog (kugghjulet `≡` i panelen) +- Hot-reload: config.toml, panel- och temafiler laddas om live när de ändras - Panel (topp/botten) med klickbara knappar och dynamiska status-widgets - Dropdown-menyer med undermenyer - Körbar kommandodialog (`ctrl+space`) -- Dynamisk omrendering vid terminalstorlek-ändringar -- SSH-kompatibel (fungerar via vanliga ANSI escape-koder) +- SSH-kompatibel: daemon + display-klienter med full mus-forwarding - Kompilerar till fristående binärer för Linux och Windows ## Teknikstack @@ -85,6 +89,13 @@ Toppnivå-nycklar i `config.toml`: | `show_scrollbar` | `true` | Scrollbar på terminalfönstrens högerkant | | `background_image` | – | Bakgrundsbild via Kitty graphics | | `terminal_bg_color` | genomskinlig | Terminalfönstrens bakgrundsfärg | +| `theme` | inbyggt | Temafil, t.ex. `"themes/dark.toml"` — se `themes/` för exempel | +| `shadow` | `true` | Skugga under/till höger om fönster | + +Alla dessa (plus panel- och temafiler) **hot-reloadas** — spara filen så +uppdateras TUI-WM inom en sekund, utan omstart. Kugghjulet `≡` i panelen +öppnar den inbyggda inställningsdialogen som ändrar tema/panel/skugga/ +scrollbar och sparar till config.toml. Konfigurationen delas upp i två nivåer: diff --git a/panels/topbar.toml b/panels/topbar.toml index 16e43fb..476dfb6 100644 --- a/panels/topbar.toml +++ b/panels/topbar.toml @@ -7,6 +7,13 @@ action = { type = "spawn_terminal" } label = "mouse-test" action = { type = "run_program", command = "testapps/mouse-test/target/release/mouse-test" } +# Inbyggda inställningar (tema/panel/skugga/scrollbar). +# ≡ används i stället för ⚙ eftersom kugghjuls-emojin har tvetydig +# teckenbredd och förskjuter panelraden i vissa terminaler. +[[item]] +label = "≡ Inställningar" +action = { type = "spawn_settings" } + [[item]] label = "Exit" action = { type = "exit" } diff --git a/src/app.rs b/src/app.rs index 9ec5029..2d3b104 100644 --- a/src/app.rs +++ b/src/app.rs @@ -301,6 +301,11 @@ pub enum WindowContent { input: String, cursor_pos: usize, }, + /// Inbyggd inställningsdialog (tema, panel, skugga, scrollbar) + Settings { + /// Markerad rad (0=tema, 1=panel, 2=skugga, 3=scrollbar) + selected: usize, + }, PopupDialog { message: String, buttons: Vec, @@ -709,6 +714,7 @@ impl App { WindowContent::Terminal { alive, .. } => *alive, WindowContent::RunDialog { .. } => true, WindowContent::PopupDialog { .. } => true, + WindowContent::Settings { .. } => true, }); let alive_ids: Vec = self.windows.iter().map(|w| w.id).collect(); self.focus_history.retain(|h| alive_ids.contains(h)); @@ -832,6 +838,16 @@ impl App { } } + // 2a2. Inställningsdialogen hanterar sina egna tangenter + if let Some(id) = self.focused_id { + let is_settings = self.windows.iter() + .any(|w| w.id == id && matches!(w.content, WindowContent::Settings { .. })); + if is_settings { + self.handle_settings_key(id, key); + return; + } + } + // 2b. PopupDialog hanterar sina egna tangenter if let Some(id) = self.focused_id { let is_popup = self @@ -1275,6 +1291,34 @@ impl App { return; } + // 4b. Klick i inställningsdialogen: välj rad, aktivera vid andra klicket + let settings_hit = self + .windows + .iter() + .rev() + .find(|w| w.in_content(col, row) && matches!(w.content, WindowContent::Settings { .. })) + .map(|w| (w.id, w.content_rect())); + if let Some((id, cr)) = settings_hit { + 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) { + let target = (rel - 1) as usize; + let mut activate = false; + if let Some(WindowContent::Settings { selected }) = self.settings_content(id) { + if *selected == target { + activate = true; + } else { + *selected = target; + } + } + if activate { + self.settings_activate(id, true); + } + } + return; + } + // 5. Innehållsyta → fokus + vidarebefordra musklick till terminal let hit = self.windows.iter().rev().find(|w| w.in_content(col, row)).map(|w| { let (mode, encoding) = match &w.content { @@ -1429,6 +1473,7 @@ impl App { MenuAction::Submenu { .. } => {} MenuAction::WindowSwitcher => self.switcher_open_or_advance(), MenuAction::CopyMode => self.enter_copy_mode(), + MenuAction::SpawnSettings => self.spawn_settings(), MenuAction::SnapLeft => self.snap_focused(true), MenuAction::SnapRight => self.snap_focused(false), MenuAction::Maximize => self.toggle_maximize_focused(), @@ -1657,6 +1702,132 @@ impl App { id } + /// Ö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 { .. })) { + let id = w.id; + self.focus_window(id); + return; + } + let ca = self.content_area; + let w = 62u16.min(ca.width.saturating_sub(2)); + let h = 13u16.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::Settings { selected: 0 }, + 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, + }); + self.focus_window(id); + } + + fn handle_settings_key(&mut self, id: usize, key: KeyEvent) { + const ROWS: usize = 4; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.close_window(id), + KeyCode::Up => { + 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) { + *selected = (*selected + 1) % ROWS; + } + } + KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => self.settings_activate(id, true), + KeyCode::Left => self.settings_activate(id, false), + _ => {} + } + } + + fn settings_content(&mut self, id: usize) -> Option<&mut WindowContent> { + self.windows.iter_mut().find(|w| w.id == id).map(|w| &mut w.content) + } + + /// Lista *.toml i en katalog (relativa sökvägar, sorterade). + fn list_toml(dir: &str) -> Vec { + let mut out: Vec = std::fs::read_dir(dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "toml")) + .map(|e| format!("{}/{}", dir, e.file_name().to_string_lossy())) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out + } + + /// Aktivera/cykla den markerade inställningsraden. + /// Ä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 row = *selected; + match row { + 0 => { + // Tema: cykla inbyggt + themes/*.toml + let mut options: Vec> = vec![None]; + options.extend(Self::list_toml("themes").into_iter().map(Some)); + let cur = options + .iter() + .position(|o| o.as_deref() == self.config.theme.as_deref()) + .unwrap_or(0); + let next = if forward { + (cur + 1) % options.len() + } else { + (cur + options.len() - 1) % options.len() + }; + self.config.theme = options[next].clone(); + self.theme = Theme::load_or_default(self.config.theme.as_deref()); + let _ = Config::save_top_level_str("config.toml", "theme", self.config.theme.as_deref()); + } + 1 => { + // Panel: cykla panels/*.toml för panel 0 + let options = Self::list_toml("panels"); + if options.is_empty() || self.config.panels.is_empty() { + return; + } + let cur_file = self.config.panels[0].file.clone().unwrap_or_default(); + let cur = options.iter().position(|o| *o == cur_file).unwrap_or(0); + let next = if forward { + (cur + 1) % options.len() + } else { + (cur + options.len() - 1) % options.len() + }; + let _ = Config::save_panel_file("config.toml", 0, &options[next]); + // panel-innehållet läses om av hot-reloaden + } + 2 => { + self.config.shadow = !self.config.shadow; + let _ = Config::save_top_level_bool("config.toml", "shadow", self.config.shadow); + } + 3 => { + self.config.show_scrollbar = !self.config.show_scrollbar; + let _ = Config::save_top_level_bool("config.toml", "show_scrollbar", self.config.show_scrollbar); + } + _ => {} + } + } + /// Gå in i copy-mode för det fokuserade terminalfönstret. fn enter_copy_mode(&mut self) { let Some(id) = self.focused_id else { return }; @@ -2232,6 +2403,7 @@ fn action_display(action: &MenuAction) -> &'static str { MenuAction::SpawnRunDialog => "Kommandodialog", MenuAction::WindowSwitcher => "Fönsterväxlare", MenuAction::CopyMode => "Copy-mode", + MenuAction::SpawnSettings => "Inställningar", MenuAction::SnapLeft => "Fäst vänster", MenuAction::SnapRight => "Fäst höger", MenuAction::Maximize => "Maximera/återställ", diff --git a/src/config.rs b/src/config.rs index d3da525..242d138 100644 --- a/src/config.rs +++ b/src/config.rs @@ -125,6 +125,8 @@ pub enum MenuAction { Maximize, /// Copy-mode: bläddra/söka i fokuserat fönsters historik CopyMode, + /// Öppnar den inbyggda inställningsdialogen + SpawnSettings, /// Gör ingenting – används för display-only rader i menyer NoOp, } @@ -271,6 +273,44 @@ impl Config { } } + /// Sätt/ta bort en toppnivå-strängnyckel i config-filen (TOML). + pub fn save_top_level_str(path: &str, key: &str, val: Option<&str>) -> anyhow::Result<()> { + let content = fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_default(); + match val { + Some(v) => { + doc[key] = toml_edit::value(v); + } + None => { + doc.remove(key); + } + } + fs::write(path, doc.to_string())?; + Ok(()) + } + + /// Sätt en toppnivå-boolnyckel i config-filen (TOML). + pub fn save_top_level_bool(path: &str, key: &str, val: bool) -> anyhow::Result<()> { + let content = fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_default(); + doc[key] = toml_edit::value(val); + 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(); + let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_default(); + if let Some(arr) = doc.get_mut("panel").and_then(|p| p.as_array_of_tables_mut()) { + if let Some(tbl) = arr.get_mut(index) { + tbl["file"] = toml_edit::value(file); + } + } + fs::write(path, doc.to_string())?; + Ok(()) + } + /// Uppdatera background_image i config-filen (TOML) pub fn save_background(path: &str, image_path: Option<&str>) -> anyhow::Result<()> { let content = fs::read_to_string(path).unwrap_or_default(); diff --git a/src/main.rs b/src/main.rs index 0a3c073..eea9274 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,8 +97,18 @@ KONFIGURATION (./config.toml + panels/*.toml): TANGENTER (standard, ändras via [[keybind]] i config.toml): ctrl+space Kommandodialog — skriv t.ex. `htop` och tryck Enter alt+enter Ny terminal + alt+tab/alt+w Fönsterväxlare (MRU) — Tab stegar, Enter väljer + alt+left/right Fäst fönster som vänster/höger halvskärm + alt+up Maximera/återställ (även dubbelklick på titelraden) + alt+c Copy-mode: bläddra/söka i historiken (/ n N q) ctrl+q Avsluta (från en ansluten klient: kopplar bara ner den) +TEMA & INSTÄLLNINGAR: + theme = \"themes/dark.toml\" i config.toml väljer tema (egna teman = + egna TOML-filer). Kugghjulet ≡ i panelen öppnar inbyggda + inställningar (tema, panel, skugga, scrollbar). config.toml, + panel- och temafiler hot-reloadas när de ändras. + MUS: Dra titelraden = flytta fönster Dra kant/hörn = ändra storlek Scroll = historik (skal) eller vidarebefordras (appar med mus-stöd) @@ -491,6 +501,7 @@ fn handle_ipc_event( WindowContent::PopupDialog { message, .. } => { format!("[Popup: {}]", &message[..message.len().min(20)]) } + WindowContent::Settings { .. } => "[Inställningar]".to_string(), }; Some(WindowInfo { id: w.id, diff --git a/src/render.rs b/src/render.rs index 85091c6..957ca0e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -77,6 +77,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(), }) .unwrap_or_else(|| format!("fönster {}", id)) }) @@ -298,6 +299,7 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) { WindowContent::Terminal { title, .. } => format!(" {} ", title), WindowContent::RunDialog { .. } => " Tui-run ".to_string(), WindowContent::PopupDialog { .. } => " TUI-WM ".to_string(), + WindowContent::Settings { .. } => " Inställningar ".to_string(), }; // Diskret skugga nedanför/till höger om fönstret (inte när en @@ -386,6 +388,9 @@ 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); + } } // Copy-mode: statusrad + markering av sökträffar @@ -506,6 +511,71 @@ fn highlight_matches(frame: &mut Frame, rect: Rect, query: &str, style: Style) { } } +/// Innehållet i inställningsdialogen: interaktiva rader + info-sektion. +fn render_settings_content( + frame: &mut Frame, + area: Rect, + selected: usize, + app: &App, + th: &crate::theme::Theme, +) { + let theme_name = app.config.theme.as_deref().unwrap_or("(inbyggt)"); + let panel_name = app + .config + .panels + .first() + .and_then(|p| p.file.as_deref()) + .unwrap_or("(inline)"); + 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)), + ("Skugga".to_string(), onoff(app.config.shadow).to_string()), + ("Scrollbar".to_string(), onoff(app.config.show_scrollbar).to_string()), + ]; + + let mut y = area.y + 1; + for (i, (label, value)) in rows.iter().enumerate() { + if y >= area.y + area.height { + break; + } + let style = if i == selected { + th.hover_highlight() + } else { + Style::default().fg(th.text).bg(Color::Black) + }; + let line = format!(" {:<11}{}", format!("{}:", label), value); + frame.render_widget( + Paragraph::new(Span::styled(format!("{:= area.y + area.height { + break; + } + frame.render_widget( + Paragraph::new(Span::styled(line.clone(), Style::default().fg(th.hint))), + Rect::new(area.x, y, area.width, 1), + ); + y += 1; + } +} + fn render_popup_dialog( frame: &mut Frame, window: &FloatingWindow, diff --git a/tests/integration.py b/tests/integration.py index 8640d80..e556fce 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -348,6 +348,27 @@ def main(): check("hot-reload plockar upp panel-ändring", "HOTRELOAD" in text, f"frame-tail: {text[:150]!r}") + # ── 4h. Inbyggda inställningar via kugghjulet i panelen ────────── + text = disp.frame_text(timeout=5.0, contains="Inställningar") + row0 = text[:100] + gx = row0.find("Inställningar") + check("kugghjulet syns i panelen", gx > 0) + disp.send(mouse_event({"Down": "Left"}, gx, 0)) + disp.send(mouse_event({"Up": "Left"}, gx, 0)) + 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 två rader till Skugga och toggla av + disp.send(key_event(code="Down")) + disp.send(key_event(code="Down")) + disp.send(key_event(code="Enter")) + time.sleep(0.5) + cfg_now = open(os.path.join(cfgdir, "config.toml")).read() + check("skugg-toggle sparas till config.toml", "shadow = false" in cfg_now, + f"config innehåller: {[l for l in cfg_now.splitlines() if 'shadow' in l]!r}") + disp.send(key_event(code="Esc")) + time.sleep(0.3) + # ── 5. Popup + popup_result via Enter ─────────────────────────── app.send({ "type": "spawn_popup", "message": "Integrationstest?",