Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ name: CI
on:
push:
branches: [main, develop]
paths-ignore: ["**.md", "docs/**", "assets/icons/**"]
pull_request:
workflow_dispatch:

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"

jobs:
quality:
Expand Down Expand Up @@ -42,6 +44,9 @@ jobs:

coverage:
name: Coverage (core ≥ 80%)
# The instrumented build is the most expensive job, so only run it where it
# matters: pull requests and pushes to main (not on every develop push).
if: github.event_name != 'push' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
26 changes: 20 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,24 @@ lto = true
codegen-units = 1
strip = true

# Keep the dev build snappy: lightly optimize our code, but fully optimize the
# heavy dependencies (resvg/tiny-skia/slint) so the live preview stays smooth.
[profile.dev]
opt-level = 1

[profile.dev.package."*"]
# Keep dev/CI builds fast by optimizing only the SVG → raster hot path, so the
# live preview stays smooth without optimizing all ~560 dependencies (which made
# cold builds and CI slow). Everything else compiles cheaply at the dev default.
[profile.dev.package.usvg]
opt-level = 3
[profile.dev.package.resvg]
opt-level = 3
[profile.dev.package.tiny-skia]
opt-level = 3
[profile.dev.package.tiny-skia-path]
opt-level = 3
[profile.dev.package.rustybuzz]
opt-level = 3
[profile.dev.package.ttf-parser]
opt-level = 3
[profile.dev.package.fontdb]
opt-level = 3
[profile.dev.package.png]
opt-level = 3
[profile.dev.package.fdeflate]
opt-level = 3
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ the sumi-ê palette) with [skvggor.dev](https://skvggor.dev) and
- **Live preview** — every control updates the square preview instantly
- **Adjustable film grain** and **pattern strength** sliders, shown live in the preview
- **Omakase button** — randomizes the visual style and lets the house plate it for you
- **Presets** — save/load the current look as TOML (`~/.config/article-cover-art-generator/`)
- **Named presets** — save any number of looks as TOML and load/delete any of them (`~/.config/article-cover-art-generator/presets/`)
- **WCAG AAA** — all readable text is forced to ≥ 7:1 contrast against its background
- **Montserrat** Black / Bold / Regular, embedded in the binary (no system fonts needed)
- **Export** — **2160² (2K)** or **4096² (4K)** PNG plus the resolution-independent source SVG,
Expand Down Expand Up @@ -84,7 +84,7 @@ It will then show up in the app launcher (walker/rofi). Run it from a terminal w
1. Type a **title** (the only required field).
2. Fill in optional **category / date / number / brand**.
3. Pick a **theme**, **pattern** and **layout**; tune the **pattern strength** and **film grain** sliders.
4. Hit **Omakase** to shuffle the style, or set it by hand; **Save / Load preset** to keep a look.
4. Hit **Omakase** to shuffle the style, or set it by hand; name and **Save** a preset, then **Load** or **Delete** any saved one.
5. Choose **2K or 4K**, then **Export PNG** or **Export SVG** (enable *Open after export* to view it).

## How it works
Expand Down
Binary file modified docs/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub fn output_dir() -> PathBuf {
.join("article-covers")
}

fn slug(title: &str) -> String {
pub(crate) fn slug(title: &str) -> String {
let mut out = String::new();
let mut pending_dash = false;
for ch in title.chars() {
Expand Down
62 changes: 55 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::time::Duration;

use anyhow::Result;
use slint::{
Image, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, Timer, TimerMode, VecModel,
Image, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, Timer, TimerMode, VecModel,
};

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

fn reload_presets(model: &VecModel<SharedString>) {
let names: Vec<SharedString> = preset::list().into_iter().map(SharedString::from).collect();
model.set_vec(names);
}

fn main() -> Result<()> {
let ui = AppWindow::new()?;

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

let presets = Rc::new(VecModel::<SharedString>::default());
ui.set_presets(presets.clone().into());
reload_presets(&presets);

apply_config(&ui, &CoverConfig::default());
ui.set_size_index(1); // 4K by default
ui.set_open_after(true);
Expand Down Expand Up @@ -184,11 +193,23 @@ fn main() -> Result<()> {

ui.on_save_preset({
let handle = ui.as_weak();
let presets = presets.clone();
move || {
if let Some(ui) = handle.upgrade() {
let path = preset::default_path();
let status = match preset::save(&config_from_ui(&ui), &path) {
Ok(()) => format!("Saved preset → {}", path.display()),
let typed = ui.get_preset_name().to_string();
let name = if typed.trim().is_empty() {
ui.get_title_text().to_string()
} else {
typed
};
let status = match preset::save(&name, &config_from_ui(&ui)) {
Ok(stored) => {
reload_presets(&presets);
if let Some(index) = preset::list().iter().position(|n| n == &stored) {
ui.set_preset_index(index as i32);
}
format!("Saved preset \"{stored}\"")
}
Err(error) => format!("Save preset failed: {error}"),
};
ui.set_status(status.into());
Expand All @@ -198,21 +219,48 @@ fn main() -> Result<()> {

ui.on_load_preset({
let handle = ui.as_weak();
let presets = presets.clone();
move || {
if let Some(ui) = handle.upgrade() {
let path = preset::default_path();
match preset::load(&path) {
let index = ui.get_preset_index().max(0) as usize;
let Some(name) = presets.row_data(index) else {
ui.set_status("Select a saved preset first".into());
return;
};
match preset::load(name.as_str()) {
Ok(config) => {
apply_config(&ui, &config);
refresh_preview(&ui);
ui.set_status(format!("Loaded preset ← {}", path.display()).into());
ui.set_status(format!("Loaded preset \"{name}\"").into());
}
Err(error) => ui.set_status(format!("Load preset failed: {error}").into()),
}
}
}
});

ui.on_delete_preset({
let handle = ui.as_weak();
let presets = presets.clone();
move || {
if let Some(ui) = handle.upgrade() {
let index = ui.get_preset_index().max(0) as usize;
let Some(name) = presets.row_data(index) else {
return;
};
let status = match preset::delete(name.as_str()) {
Ok(()) => {
reload_presets(&presets);
ui.set_preset_index(0);
format!("Deleted preset \"{name}\"")
}
Err(error) => format!("Delete preset failed: {error}"),
};
ui.set_status(status.into());
}
}
});

refresh_preview(&ui);
ui.run()?;
Ok(())
Expand Down
155 changes: 105 additions & 50 deletions src/preset.rs
Original file line number Diff line number Diff line change
@@ -1,90 +1,145 @@
//! Save and load a cover preset as TOML, to reuse a look across articles.
//! Named presets: save the current look under a name and load any saved one
//! later. Each preset is a TOML file in the presets directory
//! (`~/.config/article-cover-art-generator/presets/`, override with
//! `ACAG_PRESETS_DIR`).

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::cover::CoverConfig;
use crate::export::slug;

/// Default preset location: `~/.config/article-cover-art-generator/preset.toml`.
pub fn default_path() -> PathBuf {
/// Directory holding the preset files.
pub fn presets_dir() -> PathBuf {
if let Some(custom) = std::env::var_os("ACAG_PRESETS_DIR") {
return PathBuf::from(custom);
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("article-cover-art-generator")
.join("preset.toml")
.join("presets")
}

pub fn save(config: &CoverConfig, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
fn list_in(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = match std::fs::read_dir(dir) {
Ok(entries) => entries
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml"))
.filter_map(|entry| {
entry
.path()
.file_stem()
.and_then(|stem| stem.to_str())
.map(str::to_owned)
})
.collect(),
Err(_) => Vec::new(),
};
names.sort();
names
}

fn save_in(dir: &Path, name: &str, config: &CoverConfig) -> Result<String> {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let stem = slug(name);
let path = dir.join(format!("{stem}.toml"));
let toml = toml::to_string_pretty(config).context("serializing preset")?;
std::fs::write(path, toml).with_context(|| format!("writing {}", path.display()))?;
Ok(())
std::fs::write(&path, toml).with_context(|| format!("writing {}", path.display()))?;
Ok(stem)
}

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

fn delete_in(dir: &Path, name: &str) -> Result<()> {
let path = dir.join(format!("{name}.toml"));
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
Ok(())
}

/// Names of all saved presets, sorted.
pub fn list() -> Vec<String> {
list_in(&presets_dir())
}

/// Save `config` under `name`; returns the stored (slugged) name.
pub fn save(name: &str, config: &CoverConfig) -> Result<String> {
save_in(&presets_dir(), name, config)
}

/// Load the preset stored under `name`.
pub fn load(name: &str) -> Result<CoverConfig> {
load_in(&presets_dir(), name)
}

/// Delete the preset stored under `name`.
pub fn delete(name: &str) -> Result<()> {
delete_in(&presets_dir(), name)
}

#[cfg(test)]
mod tests {
#![allow(clippy::field_reassign_with_default)]
use super::*;
use crate::design::themes::ThemeName;

fn temp_dir(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("acag-presets-{tag}-{}", std::process::id()))
}

#[test]
fn roundtrips_through_toml() {
fn save_list_load_and_delete_roundtrip() {
let dir = temp_dir("roundtrip");
std::fs::remove_dir_all(&dir).ok();

assert!(list_in(&dir).is_empty());

let mut config = CoverConfig::default();
config.theme = ThemeName::Ai;
config.grain = 0.3;
config.title = "Reusable look".to_owned();
let toml = toml::to_string_pretty(&config).unwrap();
let back: CoverConfig = toml::from_str(&toml).unwrap();
assert_eq!(back.theme, ThemeName::Ai);
assert_eq!(back.title, "Reusable look");
assert!((back.grain - 0.3).abs() < 1e-9);
}
let stored = save_in(&dir, "My LinkedIn Look!", &config).unwrap();
assert_eq!(stored, "my-linkedin-look");

#[test]
fn saves_and_loads_a_file() {
let path = std::env::temp_dir().join(format!("acag-preset-{}.toml", std::process::id()));
let config = CoverConfig::default();
save(&config, &path).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.title, config.title);
assert_eq!(loaded.pattern, config.pattern);
std::fs::remove_file(&path).ok();
assert_eq!(list_in(&dir), vec!["my-linkedin-look".to_owned()]);

let loaded = load_in(&dir, &stored).unwrap();
assert_eq!(loaded.theme, ThemeName::Ai);

delete_in(&dir, &stored).unwrap();
assert!(list_in(&dir).is_empty());

std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn default_path_points_at_config_dir() {
let path = default_path();
assert!(path.ends_with("preset.toml"));
assert!(
path.to_string_lossy()
.contains("article-cover-art-generator")
);
fn list_sorts_and_ignores_non_toml() {
let dir = temp_dir("sort");
std::fs::create_dir_all(&dir).unwrap();
save_in(&dir, "zeta", &CoverConfig::default()).unwrap();
save_in(&dir, "alpha", &CoverConfig::default()).unwrap();
std::fs::write(dir.join("notes.txt"), b"ignore me").unwrap();
assert_eq!(list_in(&dir), vec!["alpha".to_owned(), "zeta".to_owned()]);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn load_errors_on_missing_and_invalid_files() {
assert!(load(Path::new("/no/such/acag/preset.toml")).is_err());
let path = std::env::temp_dir().join(format!("acag-bad-{}.toml", std::process::id()));
std::fs::write(&path, "this = is = not = toml").unwrap();
assert!(load(&path).is_err());
std::fs::remove_file(&path).ok();
fn load_and_delete_error_on_missing() {
let dir = temp_dir("missing");
std::fs::create_dir_all(&dir).unwrap();
assert!(load_in(&dir, "nope").is_err());
assert!(delete_in(&dir, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn save_errors_when_parent_is_unusable() {
let blocker = std::env::temp_dir().join(format!("acag-pblock-{}", std::process::id()));
std::fs::write(&blocker, b"x").unwrap();
let path = blocker.join("preset.toml");
assert!(save(&CoverConfig::default(), &path).is_err());
std::fs::remove_file(&blocker).ok();
fn presets_dir_honors_env_override() {
// SAFETY: only this test touches this variable.
unsafe { std::env::set_var("ACAG_PRESETS_DIR", "/tmp/acag-presets-x") };
assert_eq!(presets_dir(), PathBuf::from("/tmp/acag-presets-x"));
unsafe { std::env::remove_var("ACAG_PRESETS_DIR") };
}
}
Loading
Loading