labels: label color presets in Settings > Labels

A Label Color Presets row under Label Defaults picks the 16 label names
and colours from a preset. The dropdown lists the built-in "Default"
(the labels FilmCraft ships with) and the presets in the Label Presets
folder beside the preferences, and reads "<name> (Modified)" once the
labels differ from the chosen preset. Icon buttons: Revert (back to the
preset's colours and names), Import (copies a preset file into the
folder and selects it), Export (saves the dialog's labels to a file
named as the preset), Show Folder, and Delete (user presets only).
Choosing a preset or reverting applies on OK like the other fields;
Cancel discards it.

Presets are small JSON files: a name and 16 labels, each a name and a
labels, names trimmed and at most 64 characters, valid colours); a bad
file shows its error in the dialog and changes nothing. The same is
reachable as labels.presets, labels.importPreset, labels.exportPreset
and labels.deletePreset, with the selection stored as labels.preset.
This commit is contained in:
ReReddo 2026-10-11 11:49:58 +03:00
parent 8ea545b103
commit 4a09d92fa5
10 changed files with 528 additions and 13 deletions

View File

@ -326,11 +326,17 @@ impl Default for LabelDefaults {
pub struct LabelPrefs {
pub colors: BTreeMap<String, LabelColor>,
pub defaults: LabelDefaults,
/// Label Color Presets: the preset the names and colours were last taken from.
pub preset: String,
}
impl Default for LabelPrefs {
fn default() -> Self {
Self { colors: Label::ALL.iter().map(|l| (label_id(*l), default_label_color(*l))).collect(), defaults: LabelDefaults::default() }
Self {
colors: Label::ALL.iter().map(|l| (label_id(*l), default_label_color(*l))).collect(),
defaults: LabelDefaults::default(),
preset: DEFAULT_LABEL_PRESET.into(),
}
}
}
@ -386,6 +392,164 @@ impl LabelPrefs {
c.name = c.name.chars().take(64).collect();
}
self.colors.retain(|k, _| Label::ALL.iter().any(|l| label_id(*l) == *k));
self.preset = self.preset.chars().take(LABEL_NAME_MAX).collect();
}
}
/// The label colour preset FilmCraft ships with: the labels' own names and colours.
pub const DEFAULT_LABEL_PRESET: &str = "Default";
/// The folder of label colour presets, beside `preferences.json`.
pub const LABEL_PRESETS_DIR: &str = "Label Presets";
/// Largest label preset file read; a real one is under 2 KiB.
const LABEL_PRESET_MAX_BYTES: u64 = 64 * 1024;
/// Longest preset or label name, in characters.
const LABEL_NAME_MAX: usize = 64;
/// Most files looked at in the presets folder.
const LABEL_PRESETS_MAX: usize = 500;
/// A label colour preset (Settings ▸ Labels ▸ Label Color Presets), shared between editors as a
/// small JSON file: its name and the 16 labels' names and colours, in [`Label::ALL`] order.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LabelPreset {
pub name: String,
pub labels: Vec<PresetLabel>,
}
/// One label of a [`LabelPreset`]. A file must give both fields.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PresetLabel {
pub name: String,
/// `#rrggbb`.
pub color: String,
}
impl LabelPreset {
/// The built-in preset ([`DEFAULT_LABEL_PRESET`]).
pub fn builtin() -> Self {
Self::from_prefs(DEFAULT_LABEL_PRESET, &LabelPrefs::default())
}
/// `labels`' names and colours as a preset called `name`.
pub fn from_prefs(name: &str, labels: &LabelPrefs) -> Self {
let entry = |l: Label| labels.colors.get(&label_id(l)).cloned().unwrap_or_else(|| default_label_color(l));
Self { name: name.into(), labels: Label::ALL.iter().map(|l| entry(*l)).map(|c| PresetLabel { name: c.name, color: c.color }).collect() }
}
/// Whether `labels` has exactly this preset's names and colours.
pub fn matches(&self, labels: &LabelPrefs) -> bool {
Self::from_prefs(&self.name, labels) == *self
}
/// Give `labels` this preset's names and colours (the label defaults stay) and select it.
pub fn apply(&self, labels: &mut LabelPrefs) {
for (l, p) in Label::ALL.iter().zip(&self.labels) {
labels.colors.insert(label_id(*l), LabelColor { name: p.name.clone(), color: p.color.clone() });
}
labels.preset = self.name.clone();
}
/// Check a preset file: at most 64 KiB of JSON with a name and exactly 16 labels, each with a
/// name (trimmed, at most 64 characters) and a `#rrggbb` colour.
pub fn parse(bytes: &[u8]) -> std::result::Result<Self, String> {
if bytes.len() as u64 > LABEL_PRESET_MAX_BYTES {
return Err("the file is too large to be a label color preset".into());
}
let mut p: Self = serde_json::from_slice(bytes).map_err(|e| format!("not a label color preset: {e}"))?;
p.name = p.name.trim().to_string();
if p.name.is_empty() || p.name.chars().count() > LABEL_NAME_MAX {
return Err(format!("the preset needs a name of 1 to {LABEL_NAME_MAX} characters"));
}
if p.labels.len() != Label::ALL.len() {
return Err(format!("a label color preset has {} labels; this file has {}", Label::ALL.len(), p.labels.len()));
}
for (i, l) in p.labels.iter_mut().enumerate() {
l.name = l.name.trim().to_string();
if l.name.chars().count() > LABEL_NAME_MAX {
return Err(format!("label {}: the name is longer than {LABEL_NAME_MAX} characters", i + 1));
}
let rgb = l.color.strip_prefix('#').and(parse_hex(&l.color)).ok_or_else(|| format!("label {}: the color is not like #2f6bdf", i + 1))?;
l.color = hex(rgb);
}
Ok(p)
}
/// Read and check a preset file ([`LabelPreset::parse`]).
pub fn read(path: &Path) -> std::result::Result<Self, String> {
use std::io::Read as _;
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut bytes = Vec::new();
file.take(LABEL_PRESET_MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?;
Self::parse(&bytes)
}
pub fn write(&self, path: &Path) -> std::result::Result<(), String> {
let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?;
filmcraft_format::atomic_write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// Save `labels`' names and colours to `path` as a preset named after the file.
pub fn export(path: &Path, labels: &LabelPrefs) -> std::result::Result<Self, String> {
let stem = path.file_stem().map(|s| s.to_string_lossy().trim().chars().take(LABEL_NAME_MAX).collect::<String>()).unwrap_or_default();
let p = Self::from_prefs(if stem.is_empty() { "Labels" } else { &stem }, labels);
p.write(path)?;
Ok(p)
}
}
impl Session {
/// The label colour presets folder (None without a data directory, e.g. tests and the web).
pub fn label_presets_dir(&self) -> Option<PathBuf> {
self.prefs_path.as_deref().and_then(Path::parent).map(|d| d.join(LABEL_PRESETS_DIR))
}
/// The built-in preset, then the presets in the folder by name with the file each is in.
/// Files that fail [`LabelPreset::parse`] and names already taken are left out.
pub fn label_presets(&self) -> Vec<(LabelPreset, Option<PathBuf>)> {
let mut found: Vec<(LabelPreset, Option<PathBuf>)> = self
.label_presets_dir()
.and_then(|d| std::fs::read_dir(d).ok())
.into_iter()
.flatten()
.flatten()
.take(LABEL_PRESETS_MAX)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("json")))
.filter_map(|p| LabelPreset::read(&p).ok().map(|preset| (preset, Some(p))))
.collect();
found.sort_by_key(|(p, _)| p.name.to_lowercase());
let mut out = vec![(LabelPreset::builtin(), None)];
for f in found {
if !out.iter().any(|(p, _)| p.name.eq_ignore_ascii_case(&f.0.name)) {
out.push(f);
}
}
out
}
/// Copy the preset file at `path` into the presets folder, replacing a preset of the same
/// name, and return it. One named like the built-in preset becomes "<name> (imported)".
pub fn import_label_preset(&self, path: &Path) -> std::result::Result<LabelPreset, String> {
let mut p = LabelPreset::read(path)?;
if p.name.eq_ignore_ascii_case(DEFAULT_LABEL_PRESET) {
p.name = format!("{} (imported)", p.name);
}
let dir = self.label_presets_dir().ok_or("there is no presets folder in this session")?;
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for (old, file) in self.label_presets() {
if let Some(file) = file.filter(|_| old.name.eq_ignore_ascii_case(&p.name)) {
std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display()))?;
}
}
let safe: String = p.name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect();
p.write(&dir.join(format!("{}.json", safe.trim())))?;
Ok(p)
}
/// Delete a preset from the presets folder (the built-in one can't be deleted).
pub fn delete_label_preset(&self, name: &str) -> std::result::Result<(), String> {
let (_, file) = self.label_presets().into_iter().find(|(p, _)| p.name == name).ok_or_else(|| format!("there is no label color preset “{name}”"))?;
let file = file.ok_or("the built-in preset can't be deleted")?;
std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display()))
}
}
@ -968,6 +1132,7 @@ static CATEGORIES: &[Category] = &[
f("labels.defaults.captions", "Captions", Kind::Label, false),
],
),
Row::Custom("labelPresets"),
],
},
Category {
@ -1556,10 +1721,41 @@ fn cache_clean(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": files, "bytes": bytes, "location": dir.to_string_lossy()}))
}
fn presets_json(s: &Session) -> Value {
let presets: Vec<Value> = s.label_presets().into_iter().map(|(p, file)| json!({"name": p.name, "builtin": file.is_none(), "labels": p.labels})).collect();
json!({"dir": s.label_presets_dir(), "selected": s.prefs.labels.preset, "presets": presets})
}
fn label_presets(s: &mut Session, _: &Value) -> Result<Value> {
Ok(presets_json(s))
}
fn import_label_preset(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("labels.importPreset", "need `path`"))?;
let preset = s.import_label_preset(Path::new(path)).map_err(|e| bad("labels.importPreset", e))?;
Ok(json!({"name": preset.name, "presets": presets_json(s)}))
}
fn export_label_preset(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("labels.exportPreset", "need `path`"))?;
let preset = LabelPreset::export(Path::new(path), &s.prefs.labels).map_err(|e| bad("labels.exportPreset", e))?;
Ok(json!({"name": preset.name, "path": path}))
}
fn delete_label_preset(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").ok_or_else(|| bad("labels.deletePreset", "need `name`"))?;
s.delete_label_preset(name).map_err(|e| bad("labels.deletePreset", e))?;
Ok(presets_json(s))
}
pub fn commands() -> Vec<CommandSpec> {
vec![
spec("prefs.schema", "Settings Schema", r#"{"category":str?}"#, schema, false),
spec("mediaCache.info", "Media Cache Info", "{}", cache_info, false),
spec("mediaCache.clean", "Delete Media Cache Files", r#"{"all":bool?}"#, cache_clean, true),
spec("labels.presets", "Label Color Presets", "{}", label_presets, false),
spec("labels.importPreset", "Import Label Color Preset", r#"{"path":str}"#, import_label_preset, true),
spec("labels.exportPreset", "Export Label Color Preset", r#"{"path":str}"#, export_label_preset, true),
spec("labels.deletePreset", "Delete Label Color Preset", r#"{"name":str}"#, delete_label_preset, true),
]
}

View File

@ -681,3 +681,94 @@ fn appearance_choices_validate_and_legacy_theme_still_selects() {
}
assert_eq!(seen, ["auto", "light", "dark", "auto"]);
}
fn preset_json(name: &str, n: usize, color: &str) -> Vec<u8> {
let labels: Vec<Value> = (0..n).map(|i| json!({"name": format!(" Label {i} "), "color": color})).collect();
serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap()
}
/// Label color preset files are checked strictly: size, exactly 16 labels, names trimmed and
/// capped, colours `#rrggbb`. A bad file is an error, never a panic or a partial preset.
#[test]
fn label_presets_reject_bad_files() {
use settings::LabelPreset;
let p = LabelPreset::parse(&preset_json(" Team ", 16, "#A0B0C0")).unwrap();
assert_eq!(p.name, "Team");
assert_eq!((p.labels[0].name.as_str(), p.labels[0].color.as_str()), ("Label 0", "#a0b0c0"));
let builtin = LabelPreset::builtin();
assert_eq!(builtin.name, "Default");
assert_eq!(builtin.labels.len(), 16);
assert!(builtin.matches(&settings::LabelPrefs::default()), "the built-in preset is today's labels");
assert_eq!(LabelPreset::parse(&serde_json::to_vec(&builtin).unwrap()).unwrap(), builtin);
let long = "x".repeat(65);
let mut too_big = preset_json("Big", 16, "#000000");
too_big.extend(std::iter::repeat_n(b' ', 64 * 1024));
for (why, bytes) in [
("15 labels", preset_json("A", 15, "#000000")),
("17 labels", preset_json("A", 17, "#000000")),
("no #", preset_json("A", 16, "000000")),
("not hex", preset_json("A", 16, "#12345g")),
("a colour name", preset_json("A", 16, "red")),
("short", preset_json("A", 16, "#fff")),
("empty name", preset_json(" ", 16, "#000000")),
("long name", preset_json(&long, 16, "#000000")),
(
"long label",
serde_json::to_vec(&json!({"name": "A", "labels": (0..16).map(|_| json!({"name": long, "color": "#000000"})).collect::<Vec<_>>()})).unwrap(),
),
("no colour", serde_json::to_vec(&json!({"name": "A", "labels": (0..16).map(|_| json!({"name": "a"})).collect::<Vec<_>>()})).unwrap()),
("not an object", b"[1,2,3]".to_vec()),
("too big", too_big),
("not utf-8", vec![0xff, 0xfe, 0x00]),
] {
assert!(LabelPreset::parse(&bytes).is_err(), "{why} should be refused");
}
// every truncation of a good file is refused cleanly
let good = preset_json("Team", 16, "#102030");
for n in 0..good.len() {
assert!(LabelPreset::parse(&good[..n]).is_err());
}
}
/// Import copies a preset into `Label Presets` beside the preferences, export writes the current
/// labels named after the file, and delete removes user presets only. A bad file changes nothing.
#[test]
fn label_presets_import_export_and_delete() {
let dir = tmp_dir("label-presets");
let mut s = Session { prefs_path: Some(dir.join("preferences.json")), ..Session::default() };
let names = |s: &mut Session| -> Vec<String> {
let v = s.execute("labels.presets", json!({})).unwrap();
v["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect()
};
assert_eq!(names(&mut s), ["Default"]);
let src = dir.join("from someone.json");
std::fs::write(&src, preset_json("Team", 16, "#102030")).unwrap();
let r = s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap();
assert_eq!(r["name"], "Team");
assert!(dir.join("Label Presets").join("Team.json").is_file());
assert_eq!(names(&mut s), ["Default", "Team"]);
// a bad file: an error, and the folder is as it was
let bad = dir.join("bad.json");
std::fs::write(&bad, preset_json("Bad", 3, "#102030")).unwrap();
assert!(s.execute("labels.importPreset", json!({"path": bad.to_string_lossy()})).is_err());
std::fs::write(dir.join("Label Presets").join("junk.json"), b"{not json").unwrap();
assert_eq!(names(&mut s), ["Default", "Team"], "unreadable files in the folder are skipped");
// a preset named like the built-in one keeps both
std::fs::write(&src, preset_json("default", 16, "#405060")).unwrap();
assert_eq!(s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap()["name"], "default (imported)");
// export writes the applied labels, named after the file, and imports back
set(&mut s, "labels.colors.rose.color", json!("#123456"));
let out = dir.join("Our Labels.json");
assert_eq!(s.execute("labels.exportPreset", json!({"path": out.to_string_lossy()})).unwrap()["name"], "Our Labels");
let back = settings::LabelPreset::read(&out).unwrap();
assert!(back.matches(&s.prefs.labels));
assert!(s.execute("labels.deletePreset", json!({"name": "Default"})).is_err(), "the built-in preset stays");
s.execute("labels.deletePreset", json!({"name": "Team"})).unwrap();
assert_eq!(names(&mut s), ["Default", "default (imported)"]);
assert!(s.execute("labels.deletePreset", json!({"name": "Team"})).is_err());
}

View File

@ -3569,3 +3569,10 @@
The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio.
DirectX 12 only Solo DirectX 12
software rendering renderizado por software
Label Color Presets Ajustes preestablecidos de color de etiqueta
{name} (Modified) {name} (modificado)
Revert to the preset's colors and names Volver a los colores y nombres del ajuste preestablecido
Import a preset file… Importar un archivo de ajuste preestablecido…
Export the labels to a preset file… Exportar las etiquetas a un archivo de ajuste preestablecido…
Show the presets folder Mostrar la carpeta de ajustes preestablecidos
Delete this preset Eliminar este ajuste preestablecido

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3552,3 +3552,10 @@
The default graphics are used from the next start. Les graphismes par défaut seront utilisés au prochain démarrage.
DirectX 12 only DirectX 12 uniquement
software rendering rendu logiciel
Label Color Presets Paramètres prédéfinis de couleur d'étiquette
{name} (Modified) {name} (modifié)
Revert to the preset's colors and names Revenir aux couleurs et aux noms du paramètre prédéfini
Import a preset file… Importer un fichier de paramètre prédéfini…
Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini…
Show the presets folder Afficher le dossier des paramètres prédéfinis
Delete this preset Supprimer ce paramètre prédéfini

Can't render this file because it contains an unexpected character in line 5 and column 83.

View File

@ -3551,3 +3551,10 @@
The default graphics are used from the next start. 次回の起動からデフォルトのグラフィックが使用されます。
DirectX 12 only DirectX 12 のみ
software rendering ソフトウェアレンダリング
Label Color Presets ラベルカラープリセット
{name} (Modified) {name} (変更あり)
Revert to the preset's colors and names プリセットのカラーと名前に戻す
Import a preset file… プリセットファイルを読み込み…
Export the labels to a preset file… ラベルをプリセットファイルに書き出し…
Show the presets folder プリセットフォルダーを表示
Delete this preset このプリセットを削除

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -3547,3 +3547,10 @@
The default graphics are used from the next start. 下次启动时将使用默认图形。
DirectX 12 only 仅 DirectX 12
software rendering 软件渲染
Label Color Presets 标签颜色预设
{name} (Modified) {name}(已修改)
Revert to the preset's colors and names 恢复为预设的颜色和名称
Import a preset file… 导入预设文件…
Export the labels to a preset file… 将标签导出为预设文件…
Show the presets folder 显示预设文件夹
Delete this preset 删除此预设

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -73,6 +73,8 @@ pub enum Icon {
Close,
Fullscreen,
Export,
/// An arrow down into a tray (Import).
Import,
Gear,
Info,
Captions,
@ -578,6 +580,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.line(&[(5.0, 5.0), (8.0, 2.0), (11.0, 5.0)]);
pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]);
}
Import => {
pen.line(&[(8.0, 2.0), (8.0, 10.0)]);
pen.line(&[(5.0, 7.0), (8.0, 10.0), (11.0, 7.0)]);
pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]);
}
Gear => {
pen.circle(8.0, 8.0, 2.2);
for i in 0..8 {
@ -866,6 +873,7 @@ mod tests {
Close,
Fullscreen,
Export,
Import,
Gear,
Info,
Captions,

View File

@ -17,6 +17,7 @@ use filmcraft_project::Label;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::icons::Icon;
use crate::theme::Tokens;
use crate::{AudioDevices, Dialog, FilmcraftApp};
@ -42,6 +43,12 @@ pub struct SettingsDraft {
/// Audio devices reported by the platform (Audio Hardware page).
#[serde(default)]
pub devices: Option<AudioDevices>,
/// Label color presets found when the Labels page was shown (the built-in one first).
#[serde(default)]
pub label_presets: Option<Vec<settings::LabelPreset>>,
/// Result of the last Label Color Presets button, or its error.
#[serde(default)]
pub preset_message: String,
}
/// Open the dialog on a category page.
@ -552,6 +559,93 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key:
}
}
/// Settings ▸ Labels ▸ Label Color Presets: take the label names and colours from a preset, and
/// share presets as files. Picking a preset or Revert edits the draft (OK applies it, Cancel
/// discards it); Import, Export and Delete act on the files at once.
fn label_presets(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) {
let t = app.tokens;
let presets = d.label_presets.get_or_insert_with(|| app.session.label_presets().into_iter().map(|(p, _)| p).collect()).clone();
let labels: settings::LabelPrefs = serde_json::from_value(get(&d.values, "labels").clone()).unwrap_or_default();
let selected = presets.iter().find(|p| p.name == labels.preset);
let modified = selected.is_some_and(|p| !p.matches(&labels));
let folder = app.session.label_presets_dir();
let mut pick = None;
group(ui, &t, tl!("Label Color Presets"), |ui| {
ui.horizontal(|ui| {
let opts: Vec<(String, String)> = presets.iter().map(|p| (p.name.clone(), p.name.clone())).collect();
let shown = if modified { tlf!("{name} (Modified)", name = labels.preset) } else { labels.preset.clone() };
if let Some(name) = combo(app, ui, "labels.preset", &shown, &opts, 150.0) {
pick = presets.iter().find(|p| p.name == name).cloned();
}
if preset_button(app, ui, "revert", Icon::Reset, tl!("Revert to the preset's colors and names"), modified) {
pick = selected.cloned();
}
if preset_button(app, ui, "import", Icon::Import, tl!("Import a preset file…"), true)
&& let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Label Color Presets"), &["json"]))
{
d.label_presets = None;
match app.session.import_label_preset(std::path::Path::new(&path)) {
Ok(p) => {
d.preset_message = tlf!("Imported “{name}”", name = p.name);
pick = Some(p);
}
Err(e) => d.preset_message = e,
}
}
if preset_button(app, ui, "export", Icon::Export, tl!("Export the labels to a preset file…"), true)
&& let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Label Color Presets"), &["json"], &format!("{}.json", labels.preset)))
{
d.preset_message = match settings::LabelPreset::export(std::path::Path::new(&path), &labels) {
Ok(_) => tlf!("Exported to {path}", path),
Err(e) => e,
};
}
if preset_button(app, ui, "folder", Icon::Folder, tl!("Show the presets folder"), folder.is_some())
&& let Some(dir) = &folder
{
let ctx = ui.ctx().clone();
if let Err(e) = std::fs::create_dir_all(dir)
.map_err(|e| e.to_string())
.and_then(|()| super::menu_dialogs::open_path(app, &ctx, &dir.to_string_lossy(), false))
{
d.preset_message = e;
}
}
let user = selected.filter(|p| p.name != settings::DEFAULT_LABEL_PRESET);
if preset_button(app, ui, "delete", Icon::Trash, tl!("Delete this preset"), user.is_some())
&& let Some(p) = user
{
d.label_presets = None;
d.preset_message = match app.session.delete_label_preset(&p.name) {
Ok(()) => {
put(&mut d.values, "labels.preset", json!(settings::DEFAULT_LABEL_PRESET));
tlf!("Deleted preset “{active}”", active = p.name)
}
Err(e) => e,
};
}
});
if !d.preset_message.is_empty() {
note(ui, &t, &d.preset_message);
}
});
if let Some(p) = pick {
let mut labels = labels;
p.apply(&mut labels);
if let Ok(v) = serde_json::to_value(&labels) {
put(&mut d.values, "labels", v);
}
}
}
/// An icon button of the Label Color Presets row (`settings.labelPresets.<id>`).
fn preset_button(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, icon: Icon, tip: &str, enabled: bool) -> bool {
let t = app.tokens;
let r = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, icon, 24.0, false, &t, tip)).inner;
app.auto.add(&format!("settings.labelPresets.{id}"), r.rect, tip);
r.clicked()
}
fn note(ui: &mut Ui, t: &Tokens, text: &str) {
ui.add(egui::Label::new(RichText::new(crate::i18n::t(text)).size(11.5).color(t.text_dim)).wrap());
}
@ -619,6 +713,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str
);
}
"appearanceModes" => appearance_modes(app, ui, d),
"labelPresets" => label_presets(app, ui, d),
"autoSaveStatus" => {
let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default();
if let Some(dir) = status["autoSaveDir"].as_str() {
@ -643,25 +738,19 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str
fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row]) {
let t = app.tokens;
let mut skip = false;
for (i, r) in list.iter().enumerate() {
if std::mem::take(&mut skip) {
continue;
}
// Labels: Label Colors on the left, the row after it (Label Defaults) on the right
// Labels: Label Colors on the left, the rest of the page (Label Defaults, Label Color
// Presets) on the right
if matches!(r, Row::Custom("labelColors")) {
let next = list.get(i + 1).copied();
let rest = list.get(i + 1..).unwrap_or_default();
ui.horizontal_top(|ui| {
ui.allocate_ui(vec2(250.0, 0.0), |ui| {
ui.vertical(|ui| custom(app, ui, d, "labelColors"));
});
ui.add_space(12.0);
if let Some(n) = next {
ui.vertical(|ui| rows(app, ui, d, &[n]));
}
ui.vertical(|ui| rows(app, ui, d, rest));
});
skip = true;
continue;
break;
}
match r {
Row::Field(f) if APPEARANCE_KEYS.contains(&f.key) => continue,

View File

@ -461,3 +461,106 @@ fn playhead_jumps_to_a_press_on_the_ruler() {
button(&mut d, false);
assert_eq!(d.harness.state().session.playhead(), ph, "the release leaves it there");
}
/// Settings ▸ Labels ▸ Label Color Presets: the dropdown shows "(Modified)" once the labels differ
/// from the preset and Revert brings them back; Import copies a preset file into the presets folder
/// beside the preferences and selects it (a bad file only shows an error); Export writes the
/// dialog's labels; Show Folder opens the folder; Delete removes user presets only. Choosing a
/// preset applies on OK like the other fields, and Cancel discards it.
#[test]
fn label_color_presets() {
use std::sync::{Arc, Mutex};
let mut d = Driver::demo();
let dir = std::env::temp_dir().join(format!("filmcraft-label-presets-ui-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let folder = dir.join("Label Presets");
let picked: Arc<Mutex<Option<String>>> = Arc::default();
let opened: Arc<Mutex<Vec<String>>> = Arc::default();
{
let app = d.harness.state_mut();
app.session.prefs_path = Some(dir.join("preferences.json"));
let p = picked.clone();
app.hooks.pick_open_file = Some(Box::new(move |_, _| p.lock().unwrap().clone()));
let p = picked.clone();
app.hooks.pick_save_as = Some(Box::new(move |_, _, _| p.lock().unwrap().clone()));
let o = opened.clone();
app.hooks.open_path = Some(Box::new(move |path, _| {
o.lock().unwrap().push(path.to_string());
Ok(())
}));
}
let label = |d: &mut Driver, id: &str| -> String {
let v = d.ok("ui.elements", json!({"prefix": id}));
v.as_array().unwrap().iter().find(|e| e["id"] == id).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default()
};
let preset = |name: &str, color: &str| {
let labels: Vec<Value> = (0..16).map(|i| json!({"name": format!("{name} {i}"), "color": color})).collect();
serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap()
};
d.menu("app.settings.labels");
assert_eq!(label(&mut d, "settings.labels.preset"), "Default");
d.ok("ui.set", json!({"settings": {"values": {"labels.colors.rose.name": "Hero"}}}));
d.frames(2);
assert_eq!(label(&mut d, "settings.labels.preset"), "Default (Modified)");
d.click("settings.labelPresets.revert");
assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"]["name"], "Rose");
assert_eq!(label(&mut d, "settings.labels.preset"), "Default");
// a bad file: an error in the dialog, nothing changes
let bad = dir.join("bad.json");
std::fs::write(&bad, preset("Bad", "#zzzzzz")).unwrap();
*picked.lock().unwrap() = Some(bad.to_string_lossy().into());
let before = d.draft()["values"]["labels"].clone();
d.click("settings.labelPresets.import");
assert!(d.draft()["preset_message"].as_str().unwrap().contains("color"), "{}", d.draft()["preset_message"]);
assert_eq!(d.draft()["values"]["labels"], before);
assert!(!folder.join("Bad.json").exists());
// a good one is copied into the folder and selected
let good = dir.join("from a colleague.json");
std::fs::write(&good, preset("Team", "#102030")).unwrap();
*picked.lock().unwrap() = Some(good.to_string_lossy().into());
d.click("settings.labelPresets.import");
assert!(folder.join("Team.json").is_file());
assert_eq!(label(&mut d, "settings.labels.preset"), "Team");
assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"], json!({"name": "Team 6", "color": "#102030"}));
d.ok("ui.set", json!({"settings": {"values": {"labels.colors.rose.name": "Hero"}}}));
d.shot("labels-presets");
d.click("settings.labelPresets.revert");
// export writes what the dialog shows
d.ok("ui.set", json!({"settings": {"values": {"labels.colors.violet.color": "#abcdef"}}}));
let out = dir.join("Shared.json");
*picked.lock().unwrap() = Some(out.to_string_lossy().into());
d.click("settings.labelPresets.export");
let written: Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
assert_eq!(written["name"], "Shared");
assert_eq!(written["labels"][0], json!({"name": "Team 0", "color": "#abcdef"}));
d.click("settings.labelPresets.folder");
assert_eq!(*opened.lock().unwrap(), [folder.to_string_lossy().to_string()]);
// Cancel discards the choice; picking it again and OK applies it
d.click("settings.cancel");
assert_eq!(d.pref("labels.preset"), "Default");
assert_eq!(d.pref("labels.colors.rose.name"), "Rose");
d.menu("app.settings.labels");
d.click("settings.labels.preset");
d.click("settings.labels.preset.Team");
d.click("settings.ok");
assert_eq!(d.pref("labels.preset"), "Team");
assert_eq!(d.pref("labels.colors.rose.color"), "#102030");
// Delete: user presets only
d.menu("app.settings.labels");
d.click("settings.labelPresets.delete");
assert!(!folder.join("Team.json").exists());
assert_eq!(label(&mut d, "settings.labels.preset"), "Default (Modified)", "the colours stay until reverted");
std::fs::write(folder.join("keep.json"), preset("Keep", "#405060")).unwrap();
d.click("settings.labelPresets.delete");
assert!(folder.join("keep.json").exists(), "the built-in preset can't be deleted, and nothing else goes");
d.click("settings.cancel");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -223,7 +223,7 @@ opens the dialog on a page.
| Auto Save | the auto-save ring and the crash-recovery journal |
| Color | stored only (display colour management, EDR monitoring, HDR graphics white) |
| Graphics | new text layers: smart quotes, ligatures, default font |
| Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences |
| Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences, Label Color Presets: JSON files (`{"name", "labels": [16 × {"name", "color": "#rrggbb"}]}`) in `<data dir>/Label Presets`, the built-in "Default" being the labels FilmCraft ships with (`labels.presets`, `labels.importPreset {path}`, `labels.exportPreset {path}`, `labels.deletePreset {name}`; `labels.preset` is the selected one) |
| Media | Indeterminate Media Timebase (frame rate of stills), Default Media Scaling (Scale to / Set to frame size when a clip of another size is edited in), Enable proxies |
| Media Analysis & Transcription | auto-transcribe imported clips (scope "all imported"), speech model, default language / auto-detect, speaker labelling (defaults of `transcript.generate`) |
| Media Cache | location (`<data dir>/Media Cache` by default; unsaved projects' render previews live there), automatic deletion (older than N days / beyond N GB), Delete… (`mediaCache.clean {all?}`, `mediaCache.info`) |