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
41 changes: 25 additions & 16 deletions app/src/settings_view/import_theme_modal.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
//! Import-theme modal for tweakcn CSS exports.
//! Import-theme modal for tweakcn CSS and registry JSON exports.
//!
//! Opens when the user presses "Import theme…" in Appearance settings.
//! The user pastes tweakcn CSS, optionally edits the theme name, and clicks
//! The user pastes tweakcn CSS or registry JSON, optionally edits the theme name, and clicks
//! Save. The modal calls `write_imported` to write YAML(s) to disk, then
//! dispatches a theme-reload+select event so the new theme is immediately
//! active.
//!
//! ## Drag-and-drop
//! The modal body is wrapped in a `FileDropZone` element (inner module) that
//! intercepts `Event::DragAndDropFiles` from the OS and dispatches a
//! `ImportThemeBodyAction::FileDropped` action. Only `.css` files are
//! `ImportThemeBodyAction::FileDropped` action. Only `.css` and `.json` files are
//! accepted; anything else is rejected with an inline error.

use std::any::Any;
Expand All @@ -19,7 +19,9 @@ use crate::appearance::Appearance;
use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, SingleLineEditorOptions};
use crate::modal::Modal;
use crate::themes::theme::{CustomTheme, ThemeKind};
use crate::themes::tweakcn_import::{parse_blocks, write_imported, GamutPolicy, ParsedBlocks};
use crate::themes::tweakcn_import::{
parse_blocks_or_json, write_imported, GamutPolicy, ParsedBlocks,
};
#[cfg(feature = "local_fs")]
use crate::user_config;
use warpui::elements::Point;
Expand Down Expand Up @@ -243,7 +245,7 @@ impl ImportThemeBody {
return;
}

match parse_blocks(&self.css_text) {
match parse_blocks_or_json(&self.css_text) {
Ok(blocks) => {
// Auto-fill name from CSS comment hint if the name field is still empty.
if self.name.is_empty() {
Expand Down Expand Up @@ -339,7 +341,8 @@ impl ImportThemeBody {

/// Handle a file dropped onto the modal (OS DragAndDropFiles event).
///
/// Accepts the first `.css` file found in `paths`. Non-`.css` files (or
/// Accepts the first `.css` or `.json` file found in `paths`. Unsupported
/// files (or
/// an empty list) show an inline error and leave the paste box untouched.
///
/// Gated on `local_fs` because the fallback (web) has no filesystem access
Expand All @@ -348,15 +351,17 @@ impl ImportThemeBody {
pub fn on_file_dropped(&mut self, paths: Vec<String>, ctx: &mut ViewContext<Self>) {
use std::path::Path;

let css_path = paths
.iter()
.map(Path::new)
.find(|p| p.extension().and_then(|e| e.to_str()) == Some("css"));
let import_path = paths.iter().map(Path::new).find(|p| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("css") | Some("json")
)
});

let path = match css_path {
let path = match import_path {
Some(p) => p,
None => {
self.show_error = Some("Only .css files are supported.".to_string());
self.show_error = Some("Only .css and .json files are supported.".to_string());
ctx.notify();
return;
}
Expand Down Expand Up @@ -622,9 +627,13 @@ impl View for ImportThemeBody {

// CSS paste label + field
layout.add_child(
Text::new_inline("Paste tweakcn CSS", appearance.ui_font_family(), 12.)
.with_color(theme.active_ui_text_color().into())
.finish(),
Text::new_inline(
"Paste tweakcn CSS or registry JSON",
appearance.ui_font_family(),
12.,
)
.with_color(theme.active_ui_text_color().into())
.finish(),
);
layout.add_child(css_input);

Expand Down Expand Up @@ -654,7 +663,7 @@ impl View for ImportThemeBody {
// Button row
layout.add_child(button_row);

// Wrap the whole layout in a FileDropZone so OS-level .css file drops
// Wrap the whole layout in a FileDropZone so OS-level import file drops
// are captured and dispatched as ImportThemeBodyAction::FileDropped.
Box::new(FileDropZone::new(layout.finish()))
}
Expand Down
221 changes: 163 additions & 58 deletions app/src/themes/tweakcn_import.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! Convert tweakcn CSS exports (OKLCH colors in shadcn token format) into
//! CastCodes `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear
//! sRGB formulas are short enough to vendor.
//! Convert tweakcn CSS exports and shadcn registry JSON themes into CastCodes
//! `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear sRGB formulas
//! are short enough to vendor.

use std::collections::HashMap;

use pathfinder_color::ColorU;
use serde_json::Value;

/// Convert OKLCH (L: 0..1, C: 0..0.4 typical, H: 0..360 degrees) to
/// 8-bit sRGB. Returns `Err((r, g, b))` if any channel was out of the
Expand Down Expand Up @@ -63,11 +66,117 @@ pub enum ImportError {

#[derive(Debug, Default, PartialEq)]
pub struct ParsedBlocks {
pub light: std::collections::HashMap<String, (f64, f64, f64)>, // var → (L, C, H_deg)
pub dark: std::collections::HashMap<String, (f64, f64, f64)>,
pub light: HashMap<String, (f64, f64, f64)>, // var → (L, C, H_deg)
pub dark: HashMap<String, (f64, f64, f64)>,
pub name_comment: Option<String>,
}

pub fn parse_blocks_or_json(input: &str) -> Result<ParsedBlocks, ImportError> {
if input.trim_start().starts_with('{') {
parse_registry_json(input)
} else {
parse_blocks(input)
}
}

pub fn parse_registry_json(input: &str) -> Result<ParsedBlocks, ImportError> {
let value: Value =
serde_json::from_str(input).map_err(|e| ImportError::Io(format!("invalid JSON: {e}")))?;
let css_vars = value
.get("cssVars")
.and_then(Value::as_object)
.ok_or(ImportError::NoColorBlocksFound)?;
Comment thread
BunsDev marked this conversation as resolved.

let mut blocks = ParsedBlocks {
name_comment: value
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
..Default::default()
};

for (key, target) in [("light", &mut blocks.light), ("dark", &mut blocks.dark)] {
let Some(vars) = css_vars.get(key).and_then(Value::as_object) else {
continue;
};

for (name, value) in vars {
let Some(value) = value.as_str() else {
continue;
};
if let Some(triple) = parse_oklch_value(name, value)? {
Comment thread
BunsDev marked this conversation as resolved.
target.insert(name.clone(), triple);
}
}
}

if blocks.light.is_empty() && blocks.dark.is_empty() {
return Err(ImportError::NoColorBlocksFound);
}

Ok(blocks)
}

fn parse_oklch_value(var: &str, value: &str) -> Result<Option<(f64, f64, f64)>, ImportError> {
let Some(args) = value
.trim()
.strip_prefix("oklch(")
.and_then(|s| s.strip_suffix(')'))
else {
return Ok(None);
};

let triple: Vec<&str> = args.split_whitespace().take(3).collect();
if triple.len() < 3 {
return Err(ImportError::InvalidOklch {
var: var.to_string(),
raw: value.to_string(),
});
}

let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN);
// tweakcn emits L as 0..1 (no `%`), but tolerate `%` style:
let l = if triple[0].ends_with('%') {
l / 100.0
} else {
l
};
let c: f64 = triple[1].parse().unwrap_or(f64::NAN);
let h: f64 = triple[2]
.trim_end_matches("deg")
.parse()
.unwrap_or(f64::NAN);

if l.is_finite() && c.is_finite() && h.is_finite() {
Ok(Some((l, c, h)))
} else {
Err(ImportError::InvalidOklch {
var: var.to_string(),
raw: value.to_string(),
})
}
}

fn parse_css_decls(
body: &str,
target: &mut HashMap<String, (f64, f64, f64)>,
) -> Result<(), ImportError> {
for decl in body.split(';') {
let decl = decl.trim();
if !decl.starts_with("--") {
continue;
}
let Some((name, value)) = decl.split_once(':') else {
continue;
};
let name = name.trim().trim_start_matches("--").to_string();
if let Some(triple) = parse_oklch_value(&name, value)? {
target.insert(name, triple);
}
}
Ok(())
}

/// Pull `:root { ... }` and `.dark { ... }` blocks out of a tweakcn CSS
/// export. Parses each `--var: oklch(L C H);` line into a (L,C,H) triple.
/// `oklch()` is the only color function supported — anything else is
Expand Down Expand Up @@ -132,62 +241,11 @@ pub fn parse_blocks(css: &str) -> Result<ParsedBlocks, ImportError> {
Some(&haystack[body_start..end])
}

let parse_decls = |body: &str,
target: &mut std::collections::HashMap<String, (f64, f64, f64)>|
-> Result<(), ImportError> {
for decl in body.split(';') {
let decl = decl.trim();
if !decl.starts_with("--") {
continue;
}
let Some((name, value)) = decl.split_once(':') else {
continue;
};
let name = name.trim().trim_start_matches("--").to_string();
let value = value.trim();
// Only `oklch(L C H[ / a])` is supported; anything else is silently skipped.
let Some(args) = value
.strip_prefix("oklch(")
.and_then(|s| s.strip_suffix(')'))
else {
continue;
};
let triple: Vec<&str> = args.split_whitespace().take(3).collect();
if triple.len() < 3 {
return Err(ImportError::InvalidOklch {
var: name,
raw: value.to_string(),
});
}
let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN);
// tweakcn emits L as 0..1 (no `%`), but tolerate `%` style:
let l = if triple[0].ends_with('%') {
l / 100.0
} else {
l
};
let c: f64 = triple[1].parse().unwrap_or(f64::NAN);
let h: f64 = triple[2]
.trim_end_matches("deg")
.parse()
.unwrap_or(f64::NAN);
if l.is_finite() && c.is_finite() && h.is_finite() {
target.insert(name, (l, c, h));
} else {
return Err(ImportError::InvalidOklch {
var: name,
raw: value.to_string(),
});
}
}
Ok(())
};

if let Some(body) = extract_block(&cleaned, ":root") {
parse_decls(body, &mut blocks.light)?;
parse_css_decls(body, &mut blocks.light)?;
}
if let Some(body) = extract_block(&cleaned, ".dark") {
parse_decls(body, &mut blocks.dark)?;
parse_css_decls(body, &mut blocks.dark)?;
}

if blocks.light.is_empty() && blocks.dark.is_empty() {
Expand Down Expand Up @@ -579,6 +637,53 @@ mod parse_block_tests {
assert!(blocks.light.is_empty());
assert_eq!(blocks.dark.len(), 1);
}

#[test]
fn registry_json_extracts_light_and_dark_blocks() {
let json = r##"{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "vercel",
"type": "registry:style",
"cssVars": {
"theme": { "font-sans": "Geist, sans-serif" },
"light": {
"background": "oklch(0.99 0 0)",
"foreground": "oklch(0 0 0)",
"shadow-color": "#000000"
},
"dark": {
"background": "oklch(0 0 0)",
"foreground": "oklch(1 0 0)",
"border": "oklch(1 0 0 / 10%)"
}
}
}"##;

let blocks = parse_blocks_or_json(json).unwrap();

assert_eq!(blocks.name_comment.as_deref(), Some("vercel"));
assert_eq!(blocks.light.len(), 2);
assert_eq!(blocks.dark.len(), 3);
assert_eq!(blocks.dark["foreground"], (1.0, 0.0, 0.0));
}

#[test]
fn registry_json_invalid_oklch_reports_the_variable() {
let json = r#"{
"cssVars": {
"light": {
"background": "oklch(nope 0 0)"
}
}
}"#;

let result = parse_blocks_or_json(json);

assert!(matches!(
result,
Err(ImportError::InvalidOklch { var, .. }) if var == "background"
));
}
}

#[cfg(test)]
Expand Down
Loading