Built-in settings dialog opened from a panel gear button

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:48:04 +02:00
parent b1424da4fb
commit c9a4a8fc21
7 changed files with 334 additions and 2 deletions

View File

@@ -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<String>,
@@ -709,6 +714,7 @@ impl App {
WindowContent::Terminal { alive, .. } => *alive,
WindowContent::RunDialog { .. } => true,
WindowContent::PopupDialog { .. } => true,
WindowContent::Settings { .. } => true,
});
let alive_ids: Vec<usize> = 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<String> {
let mut out: Vec<String> = 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<Option<String>> = 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",

View File

@@ -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();

View File

@@ -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,

View File

@@ -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!("{:<width$}", line, width = area.width as usize), style)),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
// Info-sektion: var filerna finns
let config_path = std::fs::canonicalize("config.toml")
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "config.toml".to_string());
let info = [
String::new(),
format!(" Config: {}", config_path),
" Teman: ./themes/*.toml".to_string(),
" Paneler: ./panels/*.toml".to_string(),
String::new(),
" ↑↓ välj ←/→/Enter ändra Esc stäng".to_string(),
];
for line in info.iter() {
if y >= 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,