Skip to content

Commit fbbc8e7

Browse files
committed
Support multiple named presets (save, list, load, delete)
Presets are now individual TOML files under ~/.config/article-cover-art-generator/presets/ (override with ACAG_PRESETS_DIR). The panel gets a Presets section: a name field, a combo of saved presets, and Save/Load/Delete buttons. Replaces the previous single-slot preset.
1 parent b6aae85 commit fbbc8e7

6 files changed

Lines changed: 198 additions & 66 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ the sumi-ê palette) with [skvggor.dev](https://skvggor.dev) and
4444
- **Live preview** — every control updates the square preview instantly
4545
- **Adjustable film grain** and **pattern strength** sliders, shown live in the preview
4646
- **Omakase button** — randomizes the visual style and lets the house plate it for you
47-
- **Presets** — save/load the current look as TOML (`~/.config/article-cover-art-generator/`)
47+
- **Named presets** — save any number of looks as TOML and load/delete any of them (`~/.config/article-cover-art-generator/presets/`)
4848
- **WCAG AAA** — all readable text is forced to ≥ 7:1 contrast against its background
4949
- **Montserrat** Black / Bold / Regular, embedded in the binary (no system fonts needed)
5050
- **Export****2160² (2K)** or **4096² (4K)** PNG plus the resolution-independent source SVG,
@@ -84,7 +84,7 @@ It will then show up in the app launcher (walker/rofi). Run it from a terminal w
8484
1. Type a **title** (the only required field).
8585
2. Fill in optional **category / date / number / brand**.
8686
3. Pick a **theme**, **pattern** and **layout**; tune the **pattern strength** and **film grain** sliders.
87-
4. Hit **Omakase** to shuffle the style, or set it by hand; **Save / Load preset** to keep a look.
87+
4. Hit **Omakase** to shuffle the style, or set it by hand; name and **Save** a preset, then **Load** or **Delete** any saved one.
8888
5. Choose **2K or 4K**, then **Export PNG** or **Export SVG** (enable *Open after export* to view it).
8989

9090
## How it works

docs/screenshot.png

-76.1 KB
Loading

src/export.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub fn output_dir() -> PathBuf {
3636
.join("article-covers")
3737
}
3838

39-
fn slug(title: &str) -> String {
39+
pub(crate) fn slug(title: &str) -> String {
4040
let mut out = String::new();
4141
let mut pending_dash = false;
4242
for ch in title.chars() {

src/main.rs

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::time::Duration;
55

66
use anyhow::Result;
77
use slint::{
8-
Image, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, Timer, TimerMode, VecModel,
8+
Image, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, Timer, TimerMode, VecModel,
99
};
1010

1111
use article_cover_art_generator::cover::config::CoverConfig;
@@ -89,6 +89,11 @@ fn string_model(items: Vec<&str>) -> ModelRc<SharedString> {
8989
.into()
9090
}
9191

92+
fn reload_presets(model: &VecModel<SharedString>) {
93+
let names: Vec<SharedString> = preset::list().into_iter().map(SharedString::from).collect();
94+
model.set_vec(names);
95+
}
96+
9297
fn main() -> Result<()> {
9398
let ui = AppWindow::new()?;
9499

@@ -103,6 +108,10 @@ fn main() -> Result<()> {
103108
));
104109
ui.set_sizes(string_model(vec!["2160 · 2K", "4096 · 4K"]));
105110

111+
let presets = Rc::new(VecModel::<SharedString>::default());
112+
ui.set_presets(presets.clone().into());
113+
reload_presets(&presets);
114+
106115
apply_config(&ui, &CoverConfig::default());
107116
ui.set_size_index(1); // 4K by default
108117
ui.set_open_after(true);
@@ -184,11 +193,23 @@ fn main() -> Result<()> {
184193

185194
ui.on_save_preset({
186195
let handle = ui.as_weak();
196+
let presets = presets.clone();
187197
move || {
188198
if let Some(ui) = handle.upgrade() {
189-
let path = preset::default_path();
190-
let status = match preset::save(&config_from_ui(&ui), &path) {
191-
Ok(()) => format!("Saved preset → {}", path.display()),
199+
let typed = ui.get_preset_name().to_string();
200+
let name = if typed.trim().is_empty() {
201+
ui.get_title_text().to_string()
202+
} else {
203+
typed
204+
};
205+
let status = match preset::save(&name, &config_from_ui(&ui)) {
206+
Ok(stored) => {
207+
reload_presets(&presets);
208+
if let Some(index) = preset::list().iter().position(|n| n == &stored) {
209+
ui.set_preset_index(index as i32);
210+
}
211+
format!("Saved preset \"{stored}\"")
212+
}
192213
Err(error) => format!("Save preset failed: {error}"),
193214
};
194215
ui.set_status(status.into());
@@ -198,21 +219,48 @@ fn main() -> Result<()> {
198219

199220
ui.on_load_preset({
200221
let handle = ui.as_weak();
222+
let presets = presets.clone();
201223
move || {
202224
if let Some(ui) = handle.upgrade() {
203-
let path = preset::default_path();
204-
match preset::load(&path) {
225+
let index = ui.get_preset_index().max(0) as usize;
226+
let Some(name) = presets.row_data(index) else {
227+
ui.set_status("Select a saved preset first".into());
228+
return;
229+
};
230+
match preset::load(name.as_str()) {
205231
Ok(config) => {
206232
apply_config(&ui, &config);
207233
refresh_preview(&ui);
208-
ui.set_status(format!("Loaded preset ← {}", path.display()).into());
234+
ui.set_status(format!("Loaded preset \"{name}\"").into());
209235
}
210236
Err(error) => ui.set_status(format!("Load preset failed: {error}").into()),
211237
}
212238
}
213239
}
214240
});
215241

242+
ui.on_delete_preset({
243+
let handle = ui.as_weak();
244+
let presets = presets.clone();
245+
move || {
246+
if let Some(ui) = handle.upgrade() {
247+
let index = ui.get_preset_index().max(0) as usize;
248+
let Some(name) = presets.row_data(index) else {
249+
return;
250+
};
251+
let status = match preset::delete(name.as_str()) {
252+
Ok(()) => {
253+
reload_presets(&presets);
254+
ui.set_preset_index(0);
255+
format!("Deleted preset \"{name}\"")
256+
}
257+
Err(error) => format!("Delete preset failed: {error}"),
258+
};
259+
ui.set_status(status.into());
260+
}
261+
}
262+
});
263+
216264
refresh_preview(&ui);
217265
ui.run()?;
218266
Ok(())

src/preset.rs

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,145 @@
1-
//! Save and load a cover preset as TOML, to reuse a look across articles.
1+
//! Named presets: save the current look under a name and load any saved one
2+
//! later. Each preset is a TOML file in the presets directory
3+
//! (`~/.config/article-cover-art-generator/presets/`, override with
4+
//! `ACAG_PRESETS_DIR`).
25
36
use std::path::{Path, PathBuf};
47

58
use anyhow::{Context, Result};
69

710
use crate::cover::CoverConfig;
11+
use crate::export::slug;
812

9-
/// Default preset location: `~/.config/article-cover-art-generator/preset.toml`.
10-
pub fn default_path() -> PathBuf {
13+
/// Directory holding the preset files.
14+
pub fn presets_dir() -> PathBuf {
15+
if let Some(custom) = std::env::var_os("ACAG_PRESETS_DIR") {
16+
return PathBuf::from(custom);
17+
}
1118
dirs::config_dir()
1219
.unwrap_or_else(|| PathBuf::from("."))
1320
.join("article-cover-art-generator")
14-
.join("preset.toml")
21+
.join("presets")
1522
}
1623

17-
pub fn save(config: &CoverConfig, path: &Path) -> Result<()> {
18-
if let Some(parent) = path.parent() {
19-
std::fs::create_dir_all(parent)
20-
.with_context(|| format!("creating {}", parent.display()))?;
21-
}
24+
fn list_in(dir: &Path) -> Vec<String> {
25+
let mut names: Vec<String> = match std::fs::read_dir(dir) {
26+
Ok(entries) => entries
27+
.flatten()
28+
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml"))
29+
.filter_map(|entry| {
30+
entry
31+
.path()
32+
.file_stem()
33+
.and_then(|stem| stem.to_str())
34+
.map(str::to_owned)
35+
})
36+
.collect(),
37+
Err(_) => Vec::new(),
38+
};
39+
names.sort();
40+
names
41+
}
42+
43+
fn save_in(dir: &Path, name: &str, config: &CoverConfig) -> Result<String> {
44+
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
45+
let stem = slug(name);
46+
let path = dir.join(format!("{stem}.toml"));
2247
let toml = toml::to_string_pretty(config).context("serializing preset")?;
23-
std::fs::write(path, toml).with_context(|| format!("writing {}", path.display()))?;
24-
Ok(())
48+
std::fs::write(&path, toml).with_context(|| format!("writing {}", path.display()))?;
49+
Ok(stem)
2550
}
2651

27-
pub fn load(path: &Path) -> Result<CoverConfig> {
52+
fn load_in(dir: &Path, name: &str) -> Result<CoverConfig> {
53+
let path = dir.join(format!("{name}.toml"));
2854
let toml =
29-
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
55+
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
3056
toml::from_str(&toml).context("parsing preset")
3157
}
3258

59+
fn delete_in(dir: &Path, name: &str) -> Result<()> {
60+
let path = dir.join(format!("{name}.toml"));
61+
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
62+
Ok(())
63+
}
64+
65+
/// Names of all saved presets, sorted.
66+
pub fn list() -> Vec<String> {
67+
list_in(&presets_dir())
68+
}
69+
70+
/// Save `config` under `name`; returns the stored (slugged) name.
71+
pub fn save(name: &str, config: &CoverConfig) -> Result<String> {
72+
save_in(&presets_dir(), name, config)
73+
}
74+
75+
/// Load the preset stored under `name`.
76+
pub fn load(name: &str) -> Result<CoverConfig> {
77+
load_in(&presets_dir(), name)
78+
}
79+
80+
/// Delete the preset stored under `name`.
81+
pub fn delete(name: &str) -> Result<()> {
82+
delete_in(&presets_dir(), name)
83+
}
84+
3385
#[cfg(test)]
3486
mod tests {
3587
#![allow(clippy::field_reassign_with_default)]
3688
use super::*;
3789
use crate::design::themes::ThemeName;
3890

91+
fn temp_dir(tag: &str) -> PathBuf {
92+
std::env::temp_dir().join(format!("acag-presets-{tag}-{}", std::process::id()))
93+
}
94+
3995
#[test]
40-
fn roundtrips_through_toml() {
96+
fn save_list_load_and_delete_roundtrip() {
97+
let dir = temp_dir("roundtrip");
98+
std::fs::remove_dir_all(&dir).ok();
99+
100+
assert!(list_in(&dir).is_empty());
101+
41102
let mut config = CoverConfig::default();
42103
config.theme = ThemeName::Ai;
43-
config.grain = 0.3;
44-
config.title = "Reusable look".to_owned();
45-
let toml = toml::to_string_pretty(&config).unwrap();
46-
let back: CoverConfig = toml::from_str(&toml).unwrap();
47-
assert_eq!(back.theme, ThemeName::Ai);
48-
assert_eq!(back.title, "Reusable look");
49-
assert!((back.grain - 0.3).abs() < 1e-9);
50-
}
104+
let stored = save_in(&dir, "My LinkedIn Look!", &config).unwrap();
105+
assert_eq!(stored, "my-linkedin-look");
51106

52-
#[test]
53-
fn saves_and_loads_a_file() {
54-
let path = std::env::temp_dir().join(format!("acag-preset-{}.toml", std::process::id()));
55-
let config = CoverConfig::default();
56-
save(&config, &path).unwrap();
57-
let loaded = load(&path).unwrap();
58-
assert_eq!(loaded.title, config.title);
59-
assert_eq!(loaded.pattern, config.pattern);
60-
std::fs::remove_file(&path).ok();
107+
assert_eq!(list_in(&dir), vec!["my-linkedin-look".to_owned()]);
108+
109+
let loaded = load_in(&dir, &stored).unwrap();
110+
assert_eq!(loaded.theme, ThemeName::Ai);
111+
112+
delete_in(&dir, &stored).unwrap();
113+
assert!(list_in(&dir).is_empty());
114+
115+
std::fs::remove_dir_all(&dir).ok();
61116
}
62117

63118
#[test]
64-
fn default_path_points_at_config_dir() {
65-
let path = default_path();
66-
assert!(path.ends_with("preset.toml"));
67-
assert!(
68-
path.to_string_lossy()
69-
.contains("article-cover-art-generator")
70-
);
119+
fn list_sorts_and_ignores_non_toml() {
120+
let dir = temp_dir("sort");
121+
std::fs::create_dir_all(&dir).unwrap();
122+
save_in(&dir, "zeta", &CoverConfig::default()).unwrap();
123+
save_in(&dir, "alpha", &CoverConfig::default()).unwrap();
124+
std::fs::write(dir.join("notes.txt"), b"ignore me").unwrap();
125+
assert_eq!(list_in(&dir), vec!["alpha".to_owned(), "zeta".to_owned()]);
126+
std::fs::remove_dir_all(&dir).ok();
71127
}
72128

73129
#[test]
74-
fn load_errors_on_missing_and_invalid_files() {
75-
assert!(load(Path::new("/no/such/acag/preset.toml")).is_err());
76-
let path = std::env::temp_dir().join(format!("acag-bad-{}.toml", std::process::id()));
77-
std::fs::write(&path, "this = is = not = toml").unwrap();
78-
assert!(load(&path).is_err());
79-
std::fs::remove_file(&path).ok();
130+
fn load_and_delete_error_on_missing() {
131+
let dir = temp_dir("missing");
132+
std::fs::create_dir_all(&dir).unwrap();
133+
assert!(load_in(&dir, "nope").is_err());
134+
assert!(delete_in(&dir, "nope").is_err());
135+
std::fs::remove_dir_all(&dir).ok();
80136
}
81137

82138
#[test]
83-
fn save_errors_when_parent_is_unusable() {
84-
let blocker = std::env::temp_dir().join(format!("acag-pblock-{}", std::process::id()));
85-
std::fs::write(&blocker, b"x").unwrap();
86-
let path = blocker.join("preset.toml");
87-
assert!(save(&CoverConfig::default(), &path).is_err());
88-
std::fs::remove_file(&blocker).ok();
139+
fn presets_dir_honors_env_override() {
140+
// SAFETY: only this test touches this variable.
141+
unsafe { std::env::set_var("ACAG_PRESETS_DIR", "/tmp/acag-presets-x") };
142+
assert_eq!(presets_dir(), PathBuf::from("/tmp/acag-presets-x"));
143+
unsafe { std::env::remove_var("ACAG_PRESETS_DIR") };
89144
}
90145
}

0 commit comments

Comments
 (0)