dyn calling works + vibed dioxus ui components + some mo provision prototyping

This commit is contained in:
Sebastian Moser
2026-10-31 17:50:46 +01:00
parent 37cb94d1d4
commit 66b8713e3a
60 changed files with 9056 additions and 201 deletions

View File

@@ -1,6 +1,6 @@
use clap::{ArgMatches, Command};
use mize::item::ItemData;
use std::collections::HashMap;
use std::{collections::HashMap, ffi::OsString};
use mize::prelude::*;
#[unsafe(no_mangle)]
@@ -13,7 +13,6 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
opt("state")
.fun(Box::new(|| {
println!("hiiiiii");
let mize = get_global_mize().unwrap();
let state = CliPartState {
cmds: HashMap::new(),
@@ -22,15 +21,13 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
parsers: Some(Vec::new()),
mize: mize.clone(),
};
println!("hooooo");
Ok(ItemData::from_native(state)?)
}));
method("set_command", |args| {
println!("in set command");
let mut state = get_config("self.state")?.native_mut::<CliPartState>()?;
let cmd = args.arr_nth(0)?.native::<String>()?;
let cmd = args.get_path("0")?.native::<String>()?;
state.current_cmd_name = cmd.to_owned();
if !state.cmds.contains_key(&*cmd) {
state.cmds
@@ -40,10 +37,9 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
});
method("subcommand", |args| {
println!("in subcommand");
let mut state = get_config("self.state")?.native_mut::<CliPartState>()?;
let subcmd = args.arr_nth(0)?.native_take::<clap::Command>()?;
let action = args.arr_nth(1)?.native_take::<Box<dyn Fn(ArgMatches) -> MizeResult<()> + Send + Sync>>()?;
let subcmd = args.get_path("0")?.native_take::<clap::Command>()?;
let action = args.get_path("1")?;
let name = subcmd.get_name().to_string();
let cur_cmd_name = state.current_cmd_name.clone();
let cmd = state
@@ -56,6 +52,23 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
Ok(ItemData::new())
});
method("run", |args| {
let cmdline = args.get_path("0")?.native_take::<Vec<String>>()?;
let mut state = get_config("self.state")?.native_mut::<CliPartState>()?;
let cmd_name = cmdline.iter().nth(0).ok_or(mize_err!("empty cmdline"))?;
let cmd = state.cmds.remove(cmd_name).ok_or(mize_err!("cmd {} not found in CliPart", cmd_name))?;
let matches = cmd.get_matches_from(cmdline);
let sub_name = matches.subcommand_name().ok_or(mize_err!("subcommand not found"))?;
let sub_matches = matches.subcommand_matches(sub_name).ok_or(mize_err!("subcommand matches not found"))?;
let action = state.actions.remove(sub_name).ok_or(mize_err!("action for subcommand not found"))?;
let mut args = ItemData::new();
args.set_path("0", ItemData::from_native(sub_matches.clone())?);
action.call(args)?;
Ok(ItemData::new())
});
// TODO in future....
// Register methods from the impl block
//CliPartState::methods_from_impl()?;
@@ -67,7 +80,7 @@ pub struct CliPartState {
cmds: HashMap<String, Command>,
current_cmd_name: String,
actions:
HashMap<String, Box<dyn Fn(ArgMatches) -> MizeResult<()> + Send + Sync + 'static>>,
HashMap<String, ItemData>,
parsers: Option<Vec<Box<dyn Fn(Mize, Vec<String>) -> MizeResult<()> + Send + Sync + 'static>>>,
mize: Mize,
}

View File

@@ -0,0 +1,167 @@
//! Code generation for mize bindings.
//!
//! Takes recorded PartMetadata (collected while in generate mode) and produces
//! a Rust source file that looks like the hand-written `generated.rs`.
use mize::prelude::*;
use mize::instance::{PartMetadata, MethodMetadata, OptMetadata, NativeMetadata};
use std::fmt::Write as _;
/// Convert a part name like "cli" into a PascalCase struct name like "CliPart".
fn part_name_to_struct(part_name: &str) -> String {
let mut out = String::new();
let mut upper_next = true;
for c in part_name.chars() {
if c == '.' || c == '-' || c == '_' || c == ' ' {
upper_next = true;
continue;
}
if upper_next {
out.extend(c.to_uppercase());
upper_next = false;
} else {
out.push(c);
}
}
out.push_str("Part");
out
}
/// Generate the full `src/lib.rs` content for the generated bindings package.
pub fn generate_lib_rs(all_metadata: &[PartMetadata]) -> String {
let mut out = String::new();
out.push_str("// AUTO-GENERATED by mize dev gen -- do not edit manually\n");
out.push_str("use mize::prelude::*;\n\n\n");
for meta in all_metadata {
generate_part_struct(&mut out, meta);
}
out
}
fn generate_part_struct(out: &mut String, meta: &PartMetadata) {
let struct_name = part_name_to_struct(&meta.part_name);
let part_id_str = meta.part_name.replace('.', ".");
// struct definition
let _ = writeln!(out, "pub struct {struct_name} {{");
let _ = writeln!(out, " id: MizeId");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
// MizePart impl
let _ = writeln!(out, "impl MizePart for {struct_name} {{");
let _ = writeln!(out, " fn id(&self) -> MizeId {{");
let _ = writeln!(out, " self.id.clone()");
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
// inherent impl
let _ = writeln!(out, "impl {struct_name} {{");
// get() constructor
let _ = writeln!(out, " pub fn get(name: &str) -> MizeResult<{struct_name}> {{");
let _ = writeln!(out, " let mut mize = get_global_mize()?;");
let _ = writeln!(out, " let part = mize.get_part(name)?;");
let _ = writeln!(out, " Ok({struct_name} {{ id: part.id() }})");
let _ = writeln!(out, " }}");
// one method per recorded method/effect/thread
for m in meta.methods.iter().chain(meta.effects.iter()).chain(meta.threads.iter()) {
generate_method_fn(out, &struct_name, m);
}
// one getter-ish fn per recorded opt (opts are config values; expose a dyn_call getter)
for o in &meta.opts {
generate_opt_fn(out, &struct_name, o);
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
// native type wrappers
for n in &meta.natives {
generate_native_struct(out, n);
}
let _ = writeln!(out, "// part id string: {part_id_str}");
let _ = writeln!(out);
}
fn generate_method_fn(out: &mut String, _struct_name: &str, m: &MethodMetadata) {
let fn_name = &m.name;
let _ = writeln!(
out,
" pub fn {fn_name}(&self, args: ItemData) -> MizeResult<ItemData> {{"
);
let _ = writeln!(out, " dyn_call(self.id.clone(), \"{fn_name}\", args)?;");
// note: dyn_call returns ItemData already, so wrap:
let _ = writeln!(out, " dyn_call(self.id.clone(), \"{fn_name}\", args)");
let _ = writeln!(out, " }}");
let _ = writeln!(out);
}
fn generate_opt_fn(out: &mut String, _struct_name: &str, o: &OptMetadata) {
let fn_name = &o.name;
let _ = writeln!(
out,
" pub fn opt_{fn_name}(&self) -> MizeResult<ItemData> {{"
);
let _ = writeln!(out, " get_config(&format!(\"{{}}/{fn_name}\", self.id.dot()))");
let _ = writeln!(out, " }}");
let _ = writeln!(out);
}
fn generate_native_struct(out: &mut String, n: &NativeMetadata) {
let struct_name = &n.name;
let _ = writeln!(out, "// native type: {} ({})", n.name, n.type_name);
let _ = writeln!(out, "pub struct {struct_name} {{");
let _ = writeln!(out, " native_id: u32");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(out, "impl {struct_name} {{");
for assoc in &n.associated {
let _ = writeln!(
out,
" pub fn {assoc}(args: ItemData) -> MizeResult<ItemData> {{"
);
let _ = writeln!(out, " let mut mize = get_global_mize()?;");
let _ = writeln!(out, " let id = mize.id_new_dots(\"{}.{assoc}\")?;", n.name);
let _ = writeln!(out, " dyn_call(id, \"{assoc}\", args)");
let _ = writeln!(out, " }}");
}
for meth in &n.methods {
let _ = writeln!(
out,
" pub fn {meth}(&self, args: ItemData) -> MizeResult<ItemData> {{"
);
let _ = writeln!(out, " let mut mize = get_global_mize()?;");
let _ = writeln!(out, " let id = mize.id_new_dots(\"{}.{meth}\")?;", n.name);
let _ = writeln!(out, " dyn_call(id, \"{meth}\", args)");
let _ = writeln!(out, " }}");
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
}
/// Generate the `Cargo.toml` content for the generated package.
pub fn generate_cargo_toml(package_name: &str) -> String {
format!(
r#"[package]
name = "{package_name}"
version = "0.1.0"
edition = "2024"
[lib]
name = "{lib_name}"
path = "src/lib.rs"
[dependencies]
mize = {{ path = "../../core" }}
"#,
package_name = package_name,
lib_name = package_name.replace('-', "_"),
)
}

View File

@@ -34,7 +34,11 @@ impl CliPart {
pub fn subcommand<T: Fn(ArgMatches) -> MizeResult<()> + Send + Sync + 'static>(&self, subcmd: Command, action: T) -> MizeResult<()> {
let mut subcmd_arg = ItemData::new();
subcmd_arg.set_path("_native", subcmd.native_id)?;
let mut action_arg = ItemData::from_native(action)?;
let mut action_arg = ItemData::fun(Box::new(move |args: ItemData| {
let matches = args.clone().get_path("0")?.native_take_from::<ArgMatches>()?;
action(matches)?;
Ok(ItemData::new())
}))?;
let mut args = ItemData::new();
args.set_path("0", subcmd_arg)?;
args.set_path("1", action_arg)?;
@@ -52,6 +56,12 @@ pub struct ArgMatches {
native_id: u32
}
impl FromItemData for ArgMatches {
fn from_itemdata(data: ItemData) -> MizeResult<Self> {
Ok(ArgMatches { native_id: data.get_path("_native")?.value_u32()? })
}
}
pub struct Action {
native_id: u32
}

View File

@@ -1,4 +1,4 @@
use std::fs::create_dir_all;
use std::fs::{create_dir_all, write};
use mize::prelude::*;
use cmd_lib::run_cmd as sh;
use mize::tracing::info;
@@ -6,6 +6,7 @@ use mize::tracing::info;
// a static version of what would be generated.
// as we need mize.dev to generate anything.
mod generated;
mod codegen;
use generated::*;
// spacetime gen command: sh!(RUST_LOG=error RUST_BACKTRACE=0 spacetime generate --build-options="--lint-dir=" -p $SRC/mize/marts/spacetime -o $SRC/mize/marts/generated/src/spacetime --lang rust)?;
@@ -17,13 +18,16 @@ extern "C" fn part_dev() -> MizeResult<()> {
cli.set_command("dev");
cli.subcommand(Command::new("test")?, |_| {println!("hello from test command"); Ok(())})?;
//cli.subcommand(Command::new("publish").alias("p"), publish_mize)?;
//cli.subcommand(Command::new("genBindings").alias("b"), gen_bindings)?;
cli.subcommand(Command::new("generate")?, gen_bindings)?;
cli.subcommand(Command::new("gen")?, gen_bindings)?; // alias for generate
cli.subcommand(Command::new("publish")?, publish_mize)?;
cli.subcommand(Command::new("p")?, publish_mize)?;
Ok(())
}
pub fn publish_mize(_ : &ArgMatches, mut mize: Mize) -> MizeResult<()> {
pub fn publish_mize(_ : ArgMatches) -> MizeResult<()> {
let mut mize = get_global_mize()?;
let mize_src = mize.get_config("mize.src")?.value_string()?;
let cargo_dir = std::env::home_dir().unwrap().join(".cargo");
@@ -57,7 +61,77 @@ pub fn publish_mize(_ : &ArgMatches, mut mize: Mize) -> MizeResult<()> {
Ok(())
}
pub fn gen_bindings(_ : &ArgMatches, mize: Mize) -> MizeResult<()> {
/// The `dev generate` command (alias `gen` to be added once bindings support it).
///
/// Reads a `part` TOML file from the current directory, loads the parts listed
/// in `gen = [...]` in generate mode (recording metadata instead of executing),
/// then writes a full Cargo package with Rust bindings to `<cwd>/generated/`.
pub fn gen_bindings(_ : ArgMatches) -> MizeResult<()> {
let mut mize = get_global_mize()?;
// 1. read the "part" file from the current directory
let cwd = std::env::current_dir().map_err(|e| mize_err!("could not get current dir: {e}"))?;
let part_file = cwd.join("part");
info!("reading part description file: {}", part_file.display());
let part_toml = std::fs::read_to_string(&part_file)
.map_err(|e| mize_err!("could not read part file {}: {e}", part_file.display()))?;
let part_data = ItemData::from_toml(&part_toml)?;
// 2. parse the part name and the gen = [...] list
let part_name: String = part_data
.get_path("name")
.ok()
.and_then(|v| v.value_string().ok())
.ok_or_else(|| mize_err!("part file must have a 'name' field"))?;
info!("part name: {part_name}");
let gen_data = part_data
.get_path("gen")
.map_err(|_| mize_err!("part file must have a 'gen' array, eg: gen = [\"cli\", \"auth\"]"))?;
let gen_arr = gen_data
.cbor()
.as_array()
.ok_or_else(|| mize_err!("'gen' is not an array"))?;
let mut gen_parts: Vec<String> = Vec::new();
for v in gen_arr.iter() {
let s = v.as_text().ok_or_else(|| mize_err!("'gen' array must contain only strings"))?;
gen_parts.push(s.to_owned());
}
info!("parts to generate bindings for: {:?}", gen_parts);
// 3. enable generate mode and clear any old metadata
mize.set_generate_mode(true)?;
mize.clear_metadata()?;
// 4. load each part — in generate mode their part_smth funcs only record metadata
for p in &gen_parts {
info!("loading part in generate mode: {p}");
mize.load_part(p)?;
}
// 5. disable generate mode again
mize.set_generate_mode(false)?;
// 6. collect all recorded metadata
let all_metadata = mize.get_all_metadata()?;
info!("recorded metadata for {} part(s)", all_metadata.len());
// 7. generate the bindings package at <cwd>/generated
let gen_dir = cwd.join("generated");
let src_dir = gen_dir.join("src");
create_dir_all(&src_dir).map_err(|e| mize_err!("could not create generated/src dir: {e}"))?;
let package_name = format!("mize-generated-{part_name}");
let cargo_toml = codegen::generate_cargo_toml(&package_name);
write(gen_dir.join("Cargo.toml"), cargo_toml)
.map_err(|e| mize_err!("could not write generated/Cargo.toml: {e}"))?;
let lib_rs = codegen::generate_lib_rs(&all_metadata);
write(src_dir.join("lib.rs"), lib_rs)
.map_err(|e| mize_err!("could not write generated/src/lib.rs: {e}"))?;
info!("bindings written to {}", gen_dir.display());
println!("generated bindings package: {}", gen_dir.display());
Ok(())
}

6
parts/mize.ui/Cargo.toml Normal file
View File

@@ -0,0 +1,6 @@
[package]
name = "mize-part-mize-ui"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,17 @@
[package]
name = "dioxus-components"
version = "0.1.0"
edition = "2021"
description = "Styled Dioxus UI components (mize palette)"
license = "MIT OR Apache-2.0"
[dependencies]
dioxus = { version = "0.7", default-features = false }
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", default-features = false }
dioxus-code = { version = "0.1.0", default-features = false, features = ["macro", "lang-css"] }
dioxus-icons = "0.1.0"
[features]
default = []
web = ["dioxus/web", "dioxus-primitives/web"]
desktop = ["dioxus/desktop"]

View File

@@ -0,0 +1,7 @@
# dioxus-components MVP plan (approved)
Library of styled Dioxus components copied from upstream preview, restyled with the mize palette (light + dark).
- MVP: button, input, textarea, label, checkbox, switch, dialog, separator, avatar, badge, card, scroll_area
- dioxus-primitives via git (DioxusLabs/components) — crates.io is a stub
- Plain Rust lib; consumers include theme.css and set `html[data-theme]`

View File

@@ -0,0 +1,44 @@
/* mize UI theme — include once; toggle via html[data-theme="light"|"dark"] */
html[data-theme="dark"] {
--dark: initial;
--light: ;
}
html[data-theme="light"] {
--dark: ;
--light: initial;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--dark: initial;
--light: ;
}
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--dark: ;
--light: initial;
}
}
:root {
--bg: var(--dark, #171d18) var(--light, #f7f8f4);
--surface: var(--dark, #242d25) var(--light, #ffffff);
--surface-2: var(--dark, #2c362d) var(--light, #eff2ea);
--text: var(--dark, #f2f4ea) var(--light, #20251c);
--text-2: var(--dark, #aab5a4) var(--light, #66705e);
--border: var(--dark, #3a463a) var(--light, #dfe4d9);
--accent: var(--dark, #d7e47d) var(--light, #fec251);
--accent-text: var(--dark, #d7e47d) var(--light, #79500f);
--success: var(--dark, #b9d987) var(--light, #526f32);
--info: var(--dark, #b9e4eb) var(--light, #376777);
--warning: var(--dark, #ffd77f) var(--light, #a66b13);
--danger: var(--dark, #ff9b8e) var(--light, #b94d42);
/* On-accent fill text: dark ink on light accent, dark surface on dark accent */
--on-accent: var(--dark, #171d18) var(--light, #79500f);
--focus-ring: var(--accent);
}

View File

@@ -0,0 +1,206 @@
use dioxus::prelude::*;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::avatar::{self, AvatarState};
use dioxus_primitives::merge_attributes;
#[css_module("/src/components/avatar/style.css")]
struct Styles;
#[derive(Clone, Copy, PartialEq, Default)]
pub enum AvatarImageSize {
#[default]
Small,
Medium,
Large,
}
impl AvatarImageSize {
fn to_class(self) -> &'static str {
match self {
AvatarImageSize::Small => Styles::dx_avatar_sm.inner,
AvatarImageSize::Medium => Styles::dx_avatar_md.inner,
AvatarImageSize::Large => Styles::dx_avatar_lg.inner,
}
}
}
#[derive(Clone, Copy, PartialEq, Default)]
pub enum AvatarShape {
#[default]
Circle,
Rounded,
}
impl AvatarShape {
fn to_class(self) -> &'static str {
match self {
AvatarShape::Circle => Styles::dx_avatar_circle.inner,
AvatarShape::Rounded => Styles::dx_avatar_rounded.inner,
}
}
}
/// The props for the [`Avatar`] root component.
#[derive(Props, Clone, PartialEq)]
pub struct AvatarProps {
/// Callback when image loads successfully.
#[props(default)]
pub on_load: Option<EventHandler<()>>,
/// Callback when image fails to load.
#[props(default)]
pub on_error: Option<EventHandler<()>>,
/// Callback when the avatar state changes.
#[props(default)]
pub on_state_change: Option<EventHandler<AvatarState>>,
#[props(default)]
pub size: AvatarImageSize,
#[props(default)]
pub shape: AvatarShape,
/// Additional attributes for the avatar element.
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
/// The fallback content shown while the image is loading or if it fails to load.
pub children: Element,
}
#[component]
pub fn Avatar(props: AvatarProps) -> Element {
let class = format!(
"{} {} {}",
Styles::dx_avatar,
props.size.to_class(),
props.shape.to_class()
);
let base = attributes!(span {
class
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
avatar::Avatar {
on_load: props.on_load,
on_error: props.on_error,
on_state_change: props.on_state_change,
attributes: merged,
{props.children}
}
}
}
#[derive(Props, Clone, PartialEq)]
pub struct AvatarImageProps {
#[props(default)]
pub id: ReadSignal<Option<String>>,
pub src: String,
#[props(default)]
pub alt: String,
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
}
#[component]
pub fn AvatarImage(props: AvatarImageProps) -> Element {
let base = attributes!(img {
class: Styles::dx_avatar_image,
draggable: "false",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
avatar::AvatarImage {
id: props.id,
src: props.src,
alt: props.alt,
attributes: merged,
}
}
}
#[derive(Props, Clone, PartialEq)]
pub struct AvatarFallbackProps {
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
pub children: Element,
}
#[component]
pub fn AvatarFallback(props: AvatarFallbackProps) -> Element {
let base = attributes!(span {
class: Styles::dx_avatar_fallback,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
avatar::AvatarFallback {
attributes: merged,
{props.children}
}
}
}
/// The props for the [`ImageAvatar`] convenience component.
#[derive(Props, Clone, PartialEq)]
pub struct ImageAvatarProps {
/// The image source URL.
pub src: String,
/// The image alt text.
#[props(default)]
pub alt: String,
/// Callback when image loads successfully.
#[props(default)]
pub on_load: Option<EventHandler<()>>,
/// Callback when image fails to load.
#[props(default)]
pub on_error: Option<EventHandler<()>>,
/// Callback when the avatar state changes.
#[props(default)]
pub on_state_change: Option<EventHandler<AvatarState>>,
#[props(default)]
pub size: AvatarImageSize,
#[props(default)]
pub shape: AvatarShape,
/// Additional attributes for the avatar element.
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
/// The fallback content shown while the image is loading or if it fails to load.
pub children: Element,
}
#[component]
pub fn ImageAvatar(props: ImageAvatarProps) -> Element {
rsx! {
Avatar {
on_load: props.on_load,
on_error: props.on_error,
on_state_change: props.on_state_change,
size: props.size,
shape: props.shape,
attributes: props.attributes,
AvatarImage {
src: props.src,
alt: props.alt,
}
AvatarFallback {
{props.children}
}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,84 @@
.dx-avatar {
position: relative;
display: inline-flex;
overflow: hidden;
width: 64px;
height: 64px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--text);
font-weight: 500;
}
.dx-avatar-image {
width: 100%;
height: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.dx-avatar-sm {
width: 2rem;
height: 2rem;
font-size: 0.875rem;
}
.dx-avatar-md {
width: 2.5rem;
height: 2.5rem;
font-size: 1rem;
}
.dx-avatar-lg {
width: 3.5rem;
height: 3.5rem;
font-size: 1.5rem;
}
.dx-avatar-circle {
border-radius: 50%;
}
.dx-avatar-rounded {
border-radius: 0.375rem;
}
.dx-avatar[data-state="loading"] {
animation: dx-pulse 1.5s infinite ease-in-out;
background: var(--surface-2);
}
.dx-avatar[data-state="empty"] {
background: var(--surface-2);
}
@keyframes dx-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
.dx-avatar-fallback {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
background: var(--surface-2);
color: var(--text-2);
font-size: inherit;
}
.dx-avatar[data-state="error"] .dx-avatar-fallback {
background: var(--surface-2);
color: var(--text-2);
}

View File

@@ -0,0 +1,74 @@
use dioxus::prelude::*;
use dioxus_icons::lucide::BadgeCheck;
#[css_module("/src/components/badge/style.css")]
struct Styles;
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum BadgeVariant {
#[default]
Primary,
Secondary,
Destructive,
Outline,
}
impl BadgeVariant {
pub fn class(&self) -> &'static str {
match self {
BadgeVariant::Primary => "primary",
BadgeVariant::Secondary => "secondary",
BadgeVariant::Destructive => "destructive",
BadgeVariant::Outline => "outline",
}
}
}
/// The props for the [`Badge`] component.
#[derive(Props, Clone, PartialEq)]
pub struct BadgeProps {
#[props(default)]
pub variant: BadgeVariant,
/// Additional attributes to extend the badge element
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
/// The children of the badge element
pub children: Element,
}
#[component]
pub fn Badge(props: BadgeProps) -> Element {
rsx! {
BadgeElement {
"padding": true,
variant: props.variant,
attributes: props.attributes,
{props.children}
}
}
}
#[component]
fn BadgeElement(props: BadgeProps) -> Element {
rsx! {
span {
class: Styles::dx_badge,
"data-style": props.variant.class(),
..props.attributes,
{props.children}
}
}
}
#[component]
pub fn VerifiedIcon() -> Element {
rsx! {
BadgeCheck {
size: "12px",
stroke: "var(--text)",
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,37 @@
.dx-badge {
display: inline-flex;
min-width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
gap: 4px;
line-height: 1;
}
.dx-badge[padding="true"] {
padding: 0 8px;
}
.dx-badge[data-style="primary"] {
background-color: var(--accent);
color: var(--on-accent);
}
.dx-badge[data-style="secondary"] {
background-color: var(--surface-2);
color: var(--text);
}
.dx-badge[data-style="outline"] {
border: 1px solid var(--border);
background-color: var(--surface);
color: var(--text-2);
}
.dx-badge[data-style="destructive"] {
background-color: var(--danger);
color: var(--dark, #171d18) var(--light, #ffffff);
}

View File

@@ -0,0 +1,108 @@
use dioxus::prelude::*;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::merge_attributes;
#[css_module("/src/components/button/style.css")]
struct Styles;
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum ButtonVariant {
#[default]
Primary,
Secondary,
Destructive,
Outline,
Ghost,
Link,
}
impl ButtonVariant {
pub fn class(&self) -> &'static str {
match self {
ButtonVariant::Primary => "primary",
ButtonVariant::Secondary => "secondary",
ButtonVariant::Destructive => "destructive",
ButtonVariant::Outline => "outline",
ButtonVariant::Ghost => "ghost",
ButtonVariant::Link => "link",
}
}
}
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum ButtonSize {
Xs,
Sm,
#[default]
Default,
Lg,
Icon,
IconXs,
IconSm,
IconLg,
}
impl ButtonSize {
pub fn class(&self) -> &'static str {
match self {
ButtonSize::Xs => "xs",
ButtonSize::Sm => "sm",
ButtonSize::Default => "default",
ButtonSize::Lg => "lg",
ButtonSize::Icon => "icon",
ButtonSize::IconXs => "icon-xs",
ButtonSize::IconSm => "icon-sm",
ButtonSize::IconLg => "icon-lg",
}
}
}
#[component]
pub fn Button(
#[props(default)] variant: ButtonVariant,
#[props(default)] size: ButtonSize,
#[props(extends=GlobalAttributes)]
#[props(extends=button)]
attributes: Vec<Attribute>,
onclick: Option<EventHandler<MouseEvent>>,
onmousedown: Option<EventHandler<MouseEvent>>,
onmouseup: Option<EventHandler<MouseEvent>>,
onkeydown: Option<EventHandler<KeyboardEvent>>,
children: Element,
) -> Element {
let base = attributes!(button {
class: Styles::dx_button,
"data-style": variant.class(),
"data-size": size.class(),
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
button {
onclick: move |event| {
if let Some(f) = &onclick {
f.call(event);
}
},
onmousedown: move |event| {
if let Some(f) = &onmousedown {
f.call(event);
}
},
onmouseup: move |event| {
if let Some(f) = &onmouseup {
f.call(event);
}
},
onkeydown: move |event| {
if let Some(f) = &onkeydown {
f.call(event);
}
},
..merged,
{children}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,195 @@
.dx-button {
display: inline-flex;
box-sizing: border-box;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
gap: 0.5rem;
line-height: 1.25rem;
outline: none;
transition-duration: 150ms;
transition-property: background-color, color, box-shadow, border-color, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.dx-button svg {
flex-shrink: 0;
pointer-events: none;
}
.dx-button svg:not([class*="size-"]) {
width: 1rem;
height: 1rem;
}
.dx-button[data-size="xs"] {
height: 1.5rem;
padding: 0 0.5rem;
font-size: 0.75rem;
gap: 0.25rem;
line-height: 1rem;
}
.dx-button[data-size="xs"]:has(> svg) {
padding-inline: 0.375rem;
}
.dx-button[data-size="xs"] svg:not([class*="size-"]) {
width: 0.75rem;
height: 0.75rem;
}
.dx-button[data-size="sm"] {
height: 1.875rem;
padding: 0 0.625rem;
gap: 0.375rem;
}
.dx-button[data-size="sm"]:has(> svg) {
padding-inline: 0.5rem;
}
.dx-button[data-size="default"] {
height: 2rem;
padding: 0.375rem 1rem;
}
.dx-button[data-size="default"]:has(> svg) {
padding-inline: 0.75rem;
}
.dx-button[data-size="lg"] {
height: 2.375rem;
padding: 0 1.25rem;
}
.dx-button[data-size="lg"]:has(> svg) {
padding-inline: 1rem;
}
.dx-button[data-size="icon"] {
width: 2rem;
height: 2rem;
}
.dx-button[data-size="icon"] svg:not([class*="size-"]) {
width: 1rem;
height: 1rem;
}
.dx-button[data-size="icon-xs"] {
width: 1.5rem;
height: 1.5rem;
}
.dx-button[data-size="icon-xs"] svg:not([class*="size-"]) {
width: 0.75rem;
height: 0.75rem;
}
.dx-button[data-size="icon-sm"] {
width: 1.875rem;
height: 1.875rem;
}
.dx-button[data-size="icon-sm"] svg:not([class*="size-"]) {
width: 1rem;
height: 1rem;
}
.dx-button[data-size="icon-lg"] {
width: 2.375rem;
height: 2.375rem;
}
.dx-button[data-size="icon-lg"] svg:not([class*="size-"]) {
width: 1rem;
height: 1rem;
}
.dx-button:disabled {
opacity: 0.5;
pointer-events: none;
}
.dx-button[data-style="primary"] {
background-color: var(--accent);
color: var(--on-accent);
}
.dx-button[data-style="primary"]:not(:disabled):hover {
background-color: color-mix(in oklab, var(--accent) 88%, var(--text));
}
.dx-button[data-style="secondary"] {
background-color: var(--surface-2);
color: var(--text);
}
.dx-button[data-style="secondary"]:not(:disabled):hover {
background-color: color-mix(in oklab, var(--surface-2) 85%, var(--text));
}
.dx-button[data-style="ghost"] {
background-color: transparent;
color: var(--text-2);
}
.dx-button[data-style="ghost"]:not(:disabled):hover {
background-color: var(--surface-2);
color: var(--text);
}
.dx-button[data-style="outline"] {
border: 1px solid var(--border);
background-color: var(--surface);
color: var(--text);
}
.dx-button[data-style="outline"]:not(:disabled):hover {
background-color: var(--surface-2);
}
.dx-button[data-style="destructive"] {
background-color: var(--danger);
color: var(--dark, #171d18) var(--light, #ffffff);
}
.dx-button[data-style="destructive"]:not(:disabled):hover {
background-color: color-mix(in oklab, var(--danger) 88%, var(--text));
}
.dx-button[data-style="destructive"]:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--danger) 40%, transparent);
}
.dx-button[data-style="link"] {
background-color: transparent;
color: var(--accent-text);
text-decoration: none;
text-underline-offset: 0.25rem;
}
.dx-button[data-style="link"]:not(:disabled):hover {
text-decoration: underline;
}
.dx-button:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--focus-ring) 45%, transparent);
}
.dx-button[aria-invalid="true"] {
border-color: var(--danger);
}
.dx-button[aria-invalid="true"]:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--danger) 40%, transparent);
}

View File

@@ -0,0 +1,109 @@
use dioxus::prelude::*;
#[css_module("/src/components/card/style.css")]
struct Styles;
#[component]
pub fn Card(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card,
"data-slot": "card",
..attributes,
{children}
}
}
}
#[component]
pub fn CardHeader(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card_header,
"data-slot": "card-header",
..attributes,
{children}
}
}
}
#[component]
pub fn CardTitle(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card_title,
"data-slot": "card-title",
..attributes,
{children}
}
}
}
#[component]
pub fn CardDescription(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card_description,
"data-slot": "card-description",
..attributes,
{children}
}
}
}
#[component]
pub fn CardAction(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card_action,
"data-slot": "card-action",
..attributes,
{children}
}
}
}
#[component]
pub fn CardContent(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card_content,
"data-slot": "card-content",
..attributes,
{children}
}
}
}
#[component]
pub fn CardFooter(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div {
class: Styles::dx_card_footer,
"data-slot": "card-footer",
..attributes,
{children}
}
}
}

View File

@@ -0,0 +1,3 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,52 @@
.dx-card {
display: flex;
flex-direction: column;
padding: 1rem 0;
border: 1px solid var(--border);
border-radius: 0.5rem;
background-color: var(--surface);
box-shadow: 0 1px 0 rgb(0 0 0 / 8%);
color: var(--text);
gap: 1rem;
}
.dx-card-header {
display: grid;
align-items: start;
padding: 0 1rem;
gap: 0.375rem;
grid-auto-rows: min-content;
grid-template-rows: auto auto;
}
.dx-card-header:has([data-slot="card-action"]) {
grid-template-columns: 1fr auto;
}
.dx-card-title {
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
}
.dx-card-description {
color: var(--text-2);
font-size: 0.875rem;
line-height: 1.25rem;
}
.dx-card-action {
grid-column-start: 2;
grid-row: 1 / span 2;
place-self: start end;
}
.dx-card-content {
padding: 0 1rem;
}
.dx-card-footer {
display: flex;
align-items: center;
padding: 0 1rem;
}

View File

@@ -0,0 +1,26 @@
use dioxus::prelude::*;
use dioxus_icons::lucide::Check;
use dioxus_primitives::checkbox::{self, CheckboxProps};
#[css_module("/src/components/checkbox/style.css")]
struct Styles;
#[component]
pub fn Checkbox(props: CheckboxProps) -> Element {
rsx! {
checkbox::Checkbox {
class: Styles::dx_checkbox,
checked: props.checked,
default_checked: props.default_checked,
required: props.required,
disabled: props.disabled,
name: props.name,
value: props.value,
on_checked_change: props.on_checked_change,
attributes: props.attributes,
checkbox::CheckboxIndicator { class: Styles::dx_checkbox_indicator,
Check { size: "1rem" }
}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,34 @@
.dx-checkbox {
width: 1.125rem;
height: 1.125rem;
box-sizing: border-box;
padding: 0;
border: none;
border-radius: 0.25rem;
margin: 0;
background-color: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--border);
color: var(--on-accent);
cursor: pointer;
}
.dx-checkbox-indicator {
display: flex;
align-items: center;
justify-content: center;
}
.dx-checkbox[data-state="checked"] {
background-color: var(--accent);
box-shadow: none;
color: var(--on-accent);
}
.dx-checkbox:focus-visible {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent);
}
.dx-checkbox[data-disabled="true"] {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -0,0 +1,62 @@
use dioxus::prelude::*;
use dioxus_primitives::dialog::{self, DialogDescriptionProps, DialogRootProps, DialogTitleProps};
use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes};
#[css_module("/src/components/dialog/style.css")]
struct Styles;
#[component]
pub fn Dialog(props: DialogRootProps) -> Element {
let base = attributes!(div {
class: Styles::dx_dialog,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
dialog::DialogRoot {
class: Styles::dx_dialog_backdrop,
id: props.id,
is_modal: props.is_modal,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
dialog::DialogContent {
class: None,
attributes: merged,
{props.children}
}
}
}
}
#[component]
pub fn DialogTitle(props: DialogTitleProps) -> Element {
let base = attributes!(h2 {
class: Styles::dx_dialog_title,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
dialog::DialogTitle {
id: props.id,
attributes: merged,
{props.children}
}
}
}
#[component]
pub fn DialogDescription(props: DialogDescriptionProps) -> Element {
let base = attributes!(p {
class: Styles::dx_dialog_description,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
dialog::DialogDescription {
id: props.id,
attributes: merged,
{props.children}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,100 @@
.dx-dialog-backdrop {
position: fixed;
z-index: 1000;
background: rgb(23 29 24 / 55%);
inset: 0;
opacity: 0;
will-change: opacity;
}
.dx-dialog-backdrop[data-state="closed"] {
animation: dx-dialog-backdrop-animate-out 150ms ease-in forwards;
pointer-events: none;
}
@keyframes dx-dialog-backdrop-animate-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.dx-dialog-backdrop[data-state="open"] {
animation: dx-dialog-backdrop-animate-in 150ms ease-out forwards;
}
@keyframes dx-dialog-backdrop-animate-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.dx-dialog {
position: fixed;
z-index: 1001;
top: 50%;
left: 50%;
display: flex;
width: 100%;
max-width: calc(100% - 2rem);
box-sizing: border-box;
flex-direction: column;
padding: 1.5rem 1.25rem 1.25rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
margin: 0;
background: var(--surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
color: var(--text);
font-family: inherit;
gap: 0.75rem;
text-align: center;
transform: translate(-50%, -50%);
}
.dx-dialog-title {
margin: 0;
color: var(--text);
font-size: 1.125rem;
font-weight: 600;
}
.dx-dialog-description {
margin: 0;
color: var(--text-2);
font-size: 0.875rem;
line-height: 1.4;
}
@media (width >= 40rem) {
.dx-dialog {
max-width: 28rem;
text-align: left;
}
}
.dx-dialog-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
align-self: flex-start;
padding: 0;
border: none;
margin: 0;
background: none;
color: var(--text-2);
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.dx-dialog-close:hover {
color: var(--text);
}

View File

@@ -0,0 +1,57 @@
use dioxus::prelude::*;
#[css_module("/src/components/input/style.css")]
struct Styles;
#[component]
pub fn Input(
oninput: Option<EventHandler<FormEvent>>,
onchange: Option<EventHandler<FormEvent>>,
oninvalid: Option<EventHandler<FormEvent>>,
onselect: Option<EventHandler<SelectionEvent>>,
onselectionchange: Option<EventHandler<SelectionEvent>>,
onfocus: Option<EventHandler<FocusEvent>>,
onblur: Option<EventHandler<FocusEvent>>,
onfocusin: Option<EventHandler<FocusEvent>>,
onfocusout: Option<EventHandler<FocusEvent>>,
onkeydown: Option<EventHandler<KeyboardEvent>>,
onkeypress: Option<EventHandler<KeyboardEvent>>,
onkeyup: Option<EventHandler<KeyboardEvent>>,
onwheel: Option<EventHandler<WheelEvent>>,
oncompositionstart: Option<EventHandler<CompositionEvent>>,
oncompositionupdate: Option<EventHandler<CompositionEvent>>,
oncompositionend: Option<EventHandler<CompositionEvent>>,
oncopy: Option<EventHandler<ClipboardEvent>>,
oncut: Option<EventHandler<ClipboardEvent>>,
onpaste: Option<EventHandler<ClipboardEvent>>,
#[props(extends=GlobalAttributes)]
#[props(extends=input)]
attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
input {
class: Styles::dx_input,
oninput: move |e| _ = oninput.map(|callback| callback(e)),
onchange: move |e| _ = onchange.map(|callback| callback(e)),
oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)),
onselect: move |e| _ = onselect.map(|callback| callback(e)),
onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)),
onfocus: move |e| _ = onfocus.map(|callback| callback(e)),
onblur: move |e| _ = onblur.map(|callback| callback(e)),
onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)),
onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)),
onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)),
onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)),
onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)),
onwheel: move |e| _ = onwheel.map(|callback| callback(e)),
oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)),
oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)),
oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)),
oncopy: move |e| _ = oncopy.map(|callback| callback(e)),
oncut: move |e| _ = oncut.map(|callback| callback(e)),
onpaste: move |e| _ = onpaste.map(|callback| callback(e)),
..attributes,
{children}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,40 @@
.dx-input {
position: relative;
display: flex;
box-sizing: border-box;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: none;
border-radius: 0.375rem;
background-color: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--border);
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
gap: 0.25rem;
outline: none;
transition: background-color 100ms ease-out, box-shadow 100ms ease-out;
}
.dx-input::placeholder {
color: var(--text-2);
}
.dx-input:disabled {
color: var(--text-2);
cursor: not-allowed;
opacity: 0.6;
}
.dx-input:hover:not(:disabled) {
background-color: color-mix(in oklab, var(--surface-2) 90%, var(--text));
}
.dx-input:focus-visible {
background-color: var(--surface);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 2px color-mix(in oklab, var(--accent) 35%, transparent);
color: var(--text);
outline: none;
}

View File

@@ -0,0 +1,16 @@
use dioxus::prelude::*;
use dioxus_primitives::label::{self, LabelProps};
#[css_module("/src/components/label/style.css")]
struct Styles;
#[component]
pub fn Label(props: LabelProps) -> Element {
rsx! {
label::Label {
class: Styles::dx_label,
html_for: props.html_for,
attributes: props.attributes,
{props.children}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,9 @@
.dx-label {
display: flex;
align-items: center;
color: var(--text-2);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.01em;
line-height: 1.2;
}

View File

@@ -0,0 +1,25 @@
pub mod avatar;
pub mod badge;
pub mod button;
pub mod card;
pub mod checkbox;
pub mod dialog;
pub mod input;
pub mod label;
pub mod scroll_area;
pub mod separator;
pub mod switch;
pub mod textarea;
pub use avatar::*;
pub use badge::*;
pub use button::*;
pub use card::*;
pub use checkbox::*;
pub use dialog::*;
pub use input::*;
pub use label::*;
pub use scroll_area::*;
pub use separator::*;
pub use switch::*;
pub use textarea::*;

View File

@@ -0,0 +1,7 @@
use dioxus::prelude::*;
use dioxus_primitives::scroll_area::{self, ScrollAreaProps};
#[component]
pub fn ScrollArea(props: ScrollAreaProps) -> Element {
scroll_area::ScrollArea(props)
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1 @@
/* Scroll area uses unstyled dioxus-primitives; no extra styles for MVP. */

View File

@@ -0,0 +1,23 @@
use dioxus::prelude::*;
use dioxus_primitives::separator::{self, SeparatorProps};
use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes};
#[css_module("/src/components/separator/style.css")]
struct Styles;
#[component]
pub fn Separator(props: SeparatorProps) -> Element {
let base = attributes!(div {
class: Styles::dx_separator,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
separator::Separator {
horizontal: props.horizontal,
decorative: props.decorative,
attributes: merged,
{props.children}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,13 @@
.dx-separator {
background-color: var(--border);
}
.dx-separator[data-orientation="horizontal"] {
width: 100%;
height: 1px;
}
.dx-separator[data-orientation="vertical"] {
width: 1px;
height: 100%;
}

View File

@@ -0,0 +1,23 @@
use dioxus::prelude::*;
use dioxus_primitives::switch::{self, SwitchProps};
#[css_module("/src/components/switch/style.css")]
struct Styles;
#[component]
pub fn Switch(props: SwitchProps) -> Element {
rsx! {
switch::Switch {
class: Styles::dx_switch,
checked: props.checked,
default_checked: props.default_checked,
disabled: props.disabled,
required: props.required,
name: props.name,
value: props.value,
on_checked_change: props.on_checked_change,
attributes: props.attributes,
switch::SwitchThumb { class: Styles::dx_switch_thumb }
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,41 @@
.dx-switch {
all: unset;
position: relative;
width: 2rem;
height: 1.15rem;
border-radius: 9999px;
background-color: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--border);
cursor: pointer;
transition: background-color 150ms, box-shadow 150ms;
}
.dx-switch[data-state="checked"] {
background-color: var(--accent);
box-shadow: none;
}
.dx-switch:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 45%, transparent);
}
.dx-switch-thumb {
display: block;
width: calc(1.15rem - 2px);
height: calc(1.15rem - 2px);
border-radius: 9999px;
background-color: var(--text);
transform: translateX(1px);
transition: transform 150ms, background-color 150ms;
will-change: transform;
}
.dx-switch[data-state="checked"] .dx-switch-thumb {
background-color: var(--on-accent);
transform: translateX(calc(2rem - 1px - (1.15rem - 2px)));
}
.dx-switch[data-disabled="true"] {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -0,0 +1,82 @@
use dioxus::prelude::*;
#[css_module("/src/components/textarea/style.css")]
struct Styles;
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum TextareaVariant {
#[default]
Default,
Fade,
Outline,
Ghost,
}
impl TextareaVariant {
pub fn class(&self) -> &'static str {
match self {
TextareaVariant::Default => "default",
TextareaVariant::Fade => "fade",
TextareaVariant::Outline => "outline",
TextareaVariant::Ghost => "ghost",
}
}
}
#[component]
pub fn Textarea(
oninput: Option<EventHandler<FormEvent>>,
onchange: Option<EventHandler<FormEvent>>,
oninvalid: Option<EventHandler<FormEvent>>,
onselect: Option<EventHandler<SelectionEvent>>,
onselectionchange: Option<EventHandler<SelectionEvent>>,
onfocus: Option<EventHandler<FocusEvent>>,
onblur: Option<EventHandler<FocusEvent>>,
onfocusin: Option<EventHandler<FocusEvent>>,
onfocusout: Option<EventHandler<FocusEvent>>,
onkeydown: Option<EventHandler<KeyboardEvent>>,
onkeypress: Option<EventHandler<KeyboardEvent>>,
onkeyup: Option<EventHandler<KeyboardEvent>>,
oncompositionstart: Option<EventHandler<CompositionEvent>>,
oncompositionupdate: Option<EventHandler<CompositionEvent>>,
oncompositionend: Option<EventHandler<CompositionEvent>>,
oncopy: Option<EventHandler<ClipboardEvent>>,
oncut: Option<EventHandler<ClipboardEvent>>,
onpaste: Option<EventHandler<ClipboardEvent>>,
onmounted: Option<EventHandler<MountedEvent>>,
#[props(default)] variant: TextareaVariant,
#[props(extends=GlobalAttributes)]
#[props(extends=textarea)]
attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
textarea {
class: Styles::dx_textarea,
"data-slot": "textarea",
"data-style": variant.class(),
oninput: move |e| _ = oninput.map(|callback| callback(e)),
onchange: move |e| _ = onchange.map(|callback| callback(e)),
oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)),
onselect: move |e| _ = onselect.map(|callback| callback(e)),
onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)),
onfocus: move |e| _ = onfocus.map(|callback| callback(e)),
onblur: move |e| _ = onblur.map(|callback| callback(e)),
onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)),
onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)),
onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)),
onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)),
onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)),
oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)),
oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)),
oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)),
oncopy: move |e| _ = oncopy.map(|callback| callback(e)),
oncut: move |e| _ = oncut.map(|callback| callback(e)),
onpaste: move |e| _ = onpaste.map(|callback| callback(e)),
onmounted: move |e| _ = onmounted.map(|callback| callback(e)),
..attributes,
{children}
}
}
}

View File

@@ -0,0 +1,2 @@
mod component;
pub use component::*;

View File

@@ -0,0 +1,84 @@
.dx-textarea {
width: 100%;
min-height: 4rem;
box-sizing: border-box;
padding: 8px 12px;
border: none;
border-radius: 0.375rem;
margin: 0;
appearance: none;
background: none;
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5;
outline: none;
resize: vertical;
transition: background-color 100ms ease-out, border-color 100ms ease-out, box-shadow 100ms ease-out;
}
.dx-textarea:disabled {
color: var(--text-2);
cursor: not-allowed;
opacity: 0.6;
}
.dx-textarea::placeholder {
color: var(--text-2);
}
.dx-textarea[data-style="default"] {
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--border);
}
.dx-textarea[data-style="default"]:hover:not(:disabled),
.dx-textarea[data-style="default"]:focus {
background: var(--surface);
color: var(--text);
}
.dx-textarea[data-style="default"]:focus {
box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 2px color-mix(in oklab, var(--accent) 35%, transparent);
}
.dx-textarea[data-style="fade"] {
background: var(--surface-2);
}
.dx-textarea[data-style="fade"]:hover:not(:disabled),
.dx-textarea[data-style="fade"]:focus {
background: var(--surface);
color: var(--text);
}
.dx-textarea[data-style="outline"] {
border: 1px solid var(--border);
background-color: var(--surface);
}
.dx-textarea[data-style="outline"]:hover:not(:disabled, :focus) {
border-color: color-mix(in oklab, var(--border) 70%, var(--text));
}
.dx-textarea[data-style="outline"]:focus {
border-color: var(--accent);
}
.dx-textarea[data-style="outline"]:invalid,
.dx-textarea[data-style="outline"][aria-invalid="true"] {
border-color: var(--danger);
}
.dx-textarea[data-style="ghost"] {
background-color: transparent;
}
.dx-textarea[data-style="ghost"]:hover:not(:disabled) {
background-color: var(--surface-2);
color: var(--text);
}
.dx-textarea[data-style="ghost"]:focus {
box-shadow: inset 0 0 0 1px var(--accent);
}

View File

@@ -0,0 +1,14 @@
//! Styled Dioxus UI components using the mize color palette.
//!
//! Include [`THEME`] once at the app root (e.g. a stylesheet link), and set
//! `html[data-theme="light"]` or `html[data-theme="dark"]` to switch themes.
#![allow(non_snake_case)]
use dioxus::prelude::*;
pub mod components;
pub use components::*;
/// Global theme stylesheet (`assets/theme.css`). Link this once in your app.
pub const THEME: Asset = asset!("/assets/theme.css");

View File

@@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

View File

@@ -0,0 +1,83 @@
import subprocess
config = {
"qcow_disk": "/home/me/work/config/mize/parts/mos/build/stick.qcow2",
"root_dir": "/home/me/work/config/mize/parts/mos/build/root",
"target": "/dev/nbd0",
"partitions": [
{
"type": "fat32" ,
"size": "200M",
"flags": [ "esp" ]
},
{
"type": "ext4" ,
"size": "100%",
},
],
"isofs": {
"volid": "archiso-project",
},
}
def set_config(path, val):
config[path] = val
def get_config(path):
return config[path]
def connect_qemu_nbd():
disk = get_config("qcow_disk")
sh(f"sudo qemu-nbd --connect=/dev/nbd0 -f qcow2 {disk}")
def disconnect_qemu_nbd():
sh(f"sudo qemu-nbd --disconnect=/dev/nbd0")
def make_busybod_root_from_nixpkgs():
path = sub
pass
def flash_isofs():
volid = get_config("isofs.volid")
isolinux = get_config("isofs.linux")
boot_cat = get_config("isofs.boot_cat")
isohybrid_mbr = get_config("isofs.hybrid_mbr") # /usr/lib/syslinux/bios/isohdpfx.bin"
efiboot_img = get_config("isofs.efiboot.img")
output = get_config("target")
root_fs = get_config("root_dir")
subprocess.run([
"xorriso", "as", "mkisofs",
"-iso-level", "3", "-full-iso9660-filenames",
"-volid", volid,
"-eltorito-boot", isolinux,
"-eltorito-catalog", boot_cat,
"-no-emul-boot", "-boot-load-size", "4", "-boot-info-table",
"-isohybrid-mbr", isohybrid_mbr,
"-eltorito-alt-boot",
"-e", efiboot_img,
"-no-emul-boot",
"-isohybrid-gpt-basdat",
"-output", output,
root_fs,
])
def sh(cmd):
subprocess.run(["sh", "-c", cmd])
def provision():
try:
connect_qemu_nbd()
finally:
disconnect_qemu_nbd()
def main():
provision()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,313 @@
import json
import os
import re
import subprocess
from pathlib import Path
FILESYSTEMS = {
"fat32": {
"mkfs": ["mkfs.fat", "-F", "32"],
"parted_type": "fat32",
"parted_flags": {"esp": "esp"},
},
"ext4": {
"mkfs": ["mkfs.ext4", "-F"],
"parted_type": "ext4",
"parted_flags": {},
},
}
def run(*args, check=True):
"""Run a system command and return stdout."""
result = subprocess.run(
[str(arg) for arg in args],
check=check,
text=True,
capture_output=True,
)
return result.stdout.strip()
def partition_device(disk, number):
"""Return the correct partition path for common Linux devices."""
disk = str(disk)
suffix = "p" if re.search(r"\d$", disk) else ""
return f"{disk}{suffix}{number}"
def parse_size(size, disk_bytes):
"""Convert a size such as 200M or 100% into bytes."""
if size == "100%":
return disk_bytes
match = re.fullmatch(
r"\s*(\d+(?:\.\d+)?)\s*([KMGT]?)B?\s*",
size.upper(),
)
if not match:
raise ValueError(f"Invalid partition size: {size!r}")
value = float(match.group(1))
unit = match.group(2)
multiplier = {
"": 1,
"K": 1024,
"M": 1024**2,
"G": 1024**3,
"T": 1024**4,
}[unit]
return int(value * multiplier)
def get_table(disk):
"""Read the partition table and return its partitions."""
try:
data = json.loads(run("sfdisk", "--json", disk))
except subprocess.CalledProcessError:
return None
table = data.get("partitiontable")
if not table:
return None
return table
def get_filesystem(device):
"""Return the detected filesystem type, or None."""
result = run(
"blkid", "-p", "-s", "TYPE", "-o", "value",
device, check=False,
)
return result.lower() if result else None
def apply_partition_config(
disk,
config,
*,
dry_run=True,
format_mismatches=False,
):
"""
Apply a partition configuration to a disk.
Safety behavior:
- Dry-run by default.
- Creates GPT only if no partition table exists.
- Does not automatically resize, delete, or reorder partitions.
- Refuses to format filesystems unless format_mismatches=True.
- Verifies the expected partition count and filesystem types.
Requires root privileges and: parted, sfdisk, blkid, blockdev,
mkfs.fat, mkfs.ext4, and partprobe.
"""
disk = str(Path(disk))
if os.geteuid() != 0:
raise PermissionError("Run this function as root.")
if not os.path.exists(disk):
raise FileNotFoundError(disk)
if run("lsblk", "-dn", "-o", "TYPE", disk) != "disk":
raise ValueError(f"{disk} is not a whole-disk device")
partitions = config["partitions"]
if not partitions:
raise ValueError("At least one partition is required")
for p in partitions:
fs = p["type"].lower()
if fs not in FILESYSTEMS:
raise ValueError(f"Unsupported filesystem: {fs}")
if not isinstance(p.get("flags", []), list):
raise ValueError("'flags' must be a list")
disk_bytes = int(run("blockdev", "--getsize64", disk))
sector_size = int(run("blockdev", "--getss", disk))
alignment = 1024 * 1024 # 1 MiB
def align_up(n):
return ((n + alignment - 1) // alignment) * alignment
# Calculate partition boundaries. Reserve 1 MiB at the start.
layout = []
start = alignment
for i, p in enumerate(partitions):
requested = p["size"]
if requested == "100%":
end = disk_bytes - alignment
else:
size_bytes = parse_size(requested, disk_bytes)
end = align_up(start + size_bytes)
if i == len(partitions) - 1 and requested == "100%":
end = disk_bytes - alignment
if end <= start:
raise ValueError(f"Invalid size for partition {i + 1}")
if end > disk_bytes - alignment:
raise ValueError(
f"Partition {i + 1} exceeds disk capacity"
)
layout.append({
"number": i + 1,
"start": start,
"end": end,
"type": p["type"].lower(),
"flags": p.get("flags", []),
})
start = end
def parted(*args):
if dry_run:
print("+ parted", "-s", disk, *args)
else:
run("parted", "-s", disk, *args)
table = get_table(disk)
# Create GPT only when the disk has no partition table.
if table is None:
print(f"No readable partition table found on {disk}.")
parted("mklabel", "gpt")
table = None
elif table.get("label") != "gpt":
raise RuntimeError(
f"{disk} has a {table.get('label')} table, not GPT. "
"Refusing to overwrite it."
)
existing = table.get("partitions", []) if table else []
# Never guess how to repair an incomplete or mismatched layout.
if existing and len(existing) != len(layout):
raise RuntimeError(
f"Found {len(existing)} partitions; "
f"configuration requires {len(layout)}. "
"Automatic resizing/deletion is disabled."
)
if not existing:
for item in layout:
fs = item["type"]
start_mib = item["start"] / (1024**2)
end_mib = item["end"] / (1024**2)
# parted accepts MiB boundaries.
parted(
"mkpart",
"primary",
FILESYSTEMS[fs]["parted_type"],
f"{start_mib:.6f}MiB",
f"{end_mib:.6f}MiB",
)
for flag in item["flags"]:
if flag not in FILESYSTEMS[fs]["parted_flags"]:
raise ValueError(
f"Unsupported flag {flag!r} for {fs}"
)
parted(
"set",
str(item["number"]),
FILESYSTEMS[fs]["parted_flags"][flag],
"on",
)
elif not dry_run:
# Re-read the on-disk table before inspecting filesystems.
table = get_table(disk)
existing = table.get("partitions", [])
# Validate the existing boundaries. Do not modify them.
for expected, actual in zip(layout, existing):
actual_start = int(actual["start"]) * sector_size
actual_end = (
int(actual["start"]) + int(actual["size"])
) * sector_size
if (
abs(actual_start - expected["start"]) > alignment
or abs(actual_end - expected["end"]) > alignment
):
raise RuntimeError(
f"Partition {expected['number']} has incorrect "
"boundaries. Automatic resizing is disabled."
)
# Ask the kernel to re-read the partition table.
if not dry_run:
run("partprobe", disk)
run("udevadm", "settle")
results = []
for item in layout:
device = partition_device(disk, item["number"])
expected_fs = item["type"]
if dry_run:
print(
f"Would inspect {device}; expected filesystem "
f"{expected_fs}"
)
if format_mismatches:
print(f"Would format {device} if needed")
results.append({
"device": device,
"expected": expected_fs,
"status": "dry-run",
})
continue
if not os.path.exists(device):
raise RuntimeError(
f"Partition device {device} did not appear"
)
actual_fs = get_filesystem(device)
if actual_fs != expected_fs:
if not format_mismatches:
raise RuntimeError(
f"{device}: expected {expected_fs}, "
f"found {actual_fs!r}. "
"Set format_mismatches=True to format it."
)
# Avoid accidentally formatting a mounted filesystem.
mounts = run(
"lsblk", "-n", "-o", "MOUNTPOINTS", device
)
if mounts.strip():
raise RuntimeError(
f"{device} appears to be mounted; refusing "
"to format it."
)
fs = FILESYSTEMS[expected_fs]
run(*fs["mkfs"], device)
actual_fs = get_filesystem(device)
if actual_fs != expected_fs:
raise RuntimeError(
f"Filesystem verification failed for {device}"
)
results.append({
"device": device,
"expected": expected_fs,
"actual": actual_fs,
"status": "ok",
})
return results

6
parts/mos/run_vm.py Normal file
View File

@@ -0,0 +1,6 @@
def runVm():
sh("qemu-system-x86_64 -drive file=disk.img,format=raw -enable-kvm")
if __name__ == "__main__":
runVm()