ppc login works in mme main

This commit is contained in:
Sebastian Moser
2026-11-01 14:49:38 +01:00
parent 66b8713e3a
commit 6eedb1826a
68 changed files with 4123 additions and 1467 deletions

View File

@@ -0,0 +1,39 @@
cargo-features = ["profile-rustflags"]
[package]
name = "mize-part-auth-client"
version = "0.1.0"
edition = "2024"
[lib]
name = "mize_part_auth_client"
crate-type = [ "cdylib", "rlib" ]
[features]
default = [ "target-os" ]
target-os = [ "webbrowser" ]
target-js = []
target-obsidian = [ "target-js" ]
[dependencies]
mize = { workspace = true }
log = "0.4.34"
mize-generated-auth = { path = "./generated" }
webbrowser = { version = "1.2.0", optional = true }
serde = "1.0.228"
serde_json = "1.0.149"
tokio = { version = "1.49.0", default-features = false, features = ["time"] }
openidconnect = { version = "4", features = ["reqwest", "rustls-tls"], default-features = false }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"] }
chrono = { version = "0.4.45", features = [], default-features = false }
[profile.dev]
rustflags = ["-C", "prefer-dynamic", "-C", "link-args=-Wl,--export-dynamic"]
[profile.release]
rustflags = ["-C", "prefer-dynamic"]

View File

@@ -0,0 +1,11 @@
[package]
name = "mize-generated-auth"
version = "0.1.0"
edition = "2024"
[lib]
name = "mize_generated_auth"
path = "src/lib.rs"
[dependencies]
mize = { workspace = true }

View File

@@ -0,0 +1,92 @@
// AUTO-GENERATED by mize dev gen -- do not edit manually
use mize::prelude::*;
use mize::config::MizeFun;
pub struct CliPart {
id: MizeId
}
impl MizePart for CliPart {
fn id(&self) -> MizeResult<MizeId> {
Ok(self.id.clone())
}
}
impl CliPart {
pub fn get(name: &str) -> MizeResult<CliPart> {
let mut mize = get_global_mize()?;
let part = mize.get_part(name)?;
Ok(CliPart { id: part.id()? })
}
pub fn set_command(&self, arg0: &str) -> MizeResult<()> {
let mut args = ItemData::new();
args.set_path("0", arg0.into_item_data())?;
dyn_call(self.id.clone(), "set_command", args)?;
Ok(())
}
pub fn subcommand(&self, arg0: Command, arg1: MizeFun) -> MizeResult<()> {
let mut args = ItemData::new();
let mut __arg0 = ItemData::new();
__arg0.set_path("_native", arg0.native_num)?;
args.set_path("0", __arg0)?;
args.set_path("1", ItemData::fun(arg1)?)?;
dyn_call(self.id.clone(), "subcommand", args)?;
Ok(())
}
pub fn run(&self, arg0: Vec<String>) -> MizeResult<()> {
let mut args = ItemData::new();
args.set_path("0", arg0.into_item_data())?;
dyn_call(self.id.clone(), "run", args)?;
Ok(())
}
pub fn opt_state(&self) -> MizeResult<ItemData> {
get_config(&format!("{}/state", self.id.dot()))
}
}
// native type: Command (clap::Command)
pub struct Command {
pub native_num: u32
}
impl FromItemData for Command {
fn from_itemdata(data: ItemData) -> MizeResult<Self> {
Ok(Command { native_num: data.get_path("_native")?.value_u32()? })
}
}
impl Command {
pub fn new(arg0: &str) -> MizeResult<Command> {
let mut args = ItemData::new();
args.set_path("0", arg0.into_item_data())?;
let mut mize = get_global_mize()?;
let id = mize.id_new_dots("cli.Command")?;
let data = dyn_call(id, "new", args)?;
let native_num: u32 = data.get_path("_native")?.value_u32()?;
Ok(Command { native_num })
}
}
// native type: ArgMatches (clap::ArgMatches)
pub struct ArgMatches {
pub native_num: u32
}
impl FromItemData for ArgMatches {
fn from_itemdata(data: ItemData) -> MizeResult<Self> {
Ok(ArgMatches { native_num: data.get_path("_native")?.value_u32()? })
}
}
impl ArgMatches {
}
// part id string: cli

2
parts/auth.client/part Normal file
View File

@@ -0,0 +1,2 @@
id = "auth.client"
gen = [ "cli" ]

View File

@@ -1,22 +1,16 @@
use chrono::{DateTime, TimeDelta, Utc};
use mize::config::OptVal;
use mize::config::thread;
use mize::include_native;
use mize::prelude::*;
use mize::out;
use mize::{
item::ItemData, mize_err, mize_part, Mize, MizeError, MizePart, MizePartCreate, MizeResult,
};
use openidconnect::{
ClientId, DeviceAuthorizationResponse, EmptyExtraDeviceAuthorizationFields, IssuerUrl,
OAuth2TokenResponse, RefreshToken, Scope, TokenResponse,
};
use serde::{Deserialize, Serialize};
use tokio::runtime::Runtime;
use std::fs::OpenOptions;
use std::io::Write;
use std::{path::PathBuf, thread};
use tracing::{info, warn};
use mize_part_cli::CliPartState;
use mize_generated_auth::*;
// console_log macro
// that can be copied into other files for debugging purposes
@@ -37,16 +31,6 @@ macro_rules! console_log {
($($t:tt)*) => (unsafe { log(&format_args!($($t)*).to_string())})
}
#[cfg(feature = "target-os")]
use mize_part_cli::clap::Command;
#[derive(Default)]
#[mize_part]
pub struct ClientAuth {
mize: Mize,
secrets: Option<ClientSecrets>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct ClientSecrets {
id_token: String,
@@ -55,14 +39,6 @@ pub struct ClientSecrets {
expiry: DateTime<Utc>,
}
impl MizePartCreate for ClientAuth {}
impl MizePart for ClientAuth {
fn name(&self) -> &'static str {
"client_auth"
}
}
#[cfg(feature = "target-js")]
pub fn store_secrets_js(mize: &Mize, secrets: &ClientSecrets) -> MizeResult<()> {
use web_sys::window;
@@ -85,6 +61,7 @@ pub fn store_secrets_js(mize: &Mize, secrets: &ClientSecrets) -> MizeResult<()>
}
/*
impl ClientAuth {
pub fn secrets(&self) -> Option<&ClientSecrets> {
self.secrets.as_ref()
@@ -173,20 +150,59 @@ impl ClientAuth {
}
}
*/
async fn test() {
let client = reqwest::Client::builder().tls_built_in_root_certs(true).no_proxy().build().unwrap();
let req = client.get("https://orf.at").build().unwrap();
let res = client.execute(req).await.unwrap();
println!("res: {:?}", res);
}
#[unsafe(no_mangle)]
pub extern "C" fn part_auth() -> MizeResult<()> {
pub extern "C" fn part_auth_client() -> MizeResult<()> {
opt("cred")
.fun(|| {
let mize = get_global_mize()?;
let secrets = do_login()?;
ItemData::from_serializable(secrets)
});
opt("secrets")
.fun(Box::new(|| {
let mut mize = get_global_mize()?;
if let Ok(secrets) = load_secrets(&mut mize) {
if secrets.expiry < Utc::now() {
if let Ok(fresh_secrets) = mize.spawn_async_blocking("refresh", refresh_flow(mize.clone(), secrets.clone())) {
return ItemData::from_serializable(Some(fresh_secrets));
}
}
return ItemData::from_serializable(Some(secrets));
} else {
return ItemData::from_serializable::<Option<ClientSecrets>>(None);
}
}));
let mize = get_global_mize()?;
method("login", "() -> ()", |_args| {
#[cfg(feature = "target-os")] {
println!("hiiiii");
let mut mize = get_global_mize()?;
// i hate rust.
let runtime = Runtime::new()?;
let guard = runtime.enter();
std::mem::forget(guard);
println!("hiiiii");
match do_login() {
Ok(secrets) => {
store_secrets(&mut mize, &secrets)?;
notify_mme_login_state(true)?;
}
Err(err) => {
notify_mme_login_state(false)?;
return Err(err);
}
}
}
Ok(ItemData::new())
});
let mut mize = get_global_mize()?;
// start refresh tread if configured
if mize
@@ -195,41 +211,21 @@ pub extern "C" fn part_auth() -> MizeResult<()> {
.value_bool()
.unwrap_or(false)
{
thread("auth.refresh_thread", start_refresh_thread());
thread("auth.refresh_thread", start_refresh_thread);
}
let cli = get_part_gen::<generated::CliPart>("cli");
// cli command for os
#[cfg(feature = "target-os")] {
println!("hasdfaslkjfasldkfj");
let cli_dyn = mize.get_part("cli")?;
println!("hasdfaslkjfasldkfj three ");
let mut cli = mize_part_cli::downcast_cli_part(cli_dyn)?;
println!("hasdfaslkjfasldkfj two ");
let cli = CliPart::get("cli")?;
//let mut cli = mize.get_part_native::<CliPart>("cli")?;
cli.set_command("ppc");
cli.subcommand(Command::new("login"), move |_, mut mize| {
let mut auth = mize.get_part_native::<ClientAuth>("client_auth")?;
let secrets = if auth.secrets.is_some() {
match mize.spawn_async_blocking(
"client_auth",
refresh_flow(mize.clone(), auth.secrets.clone().unwrap()),
) {
Ok(secrets) => secrets,
Err(err) => {
out!("err refreshing secrets: {:?}", err);
mize.spawn_async_blocking("client_auth", auth_flow(mize_clone.clone()))?
}
}
} else {
mize.spawn_async_blocking("client_auth", auth_flow(mize_clone.clone()))?
};
store_secrets(&mut mize, &secrets)?;
auth.secrets = Some(secrets);
Ok(())
})?;
cli.subcommand(Command::new("login")?, Box::new(|_| {
let mut mize = get_global_mize()?;
dyn_call(mize.id_new_dots("auth.client")?, "login", ItemData::new())?;
Ok(ItemData::new())
}))?;
}
@@ -251,24 +247,20 @@ pub extern "C" fn part_auth() -> MizeResult<()> {
})?;
}
auth_part.secrets = secrets;
mize.register_part(Box::new(auth_part))?;
Ok(())
}
pub fn start_refresh_thread() -> MizeResult<()> {
pub fn start_refresh_thread(_args: ItemData) -> MizeResult<ItemData> {
// background thread for os targets
#[cfg(feature = "target-os")] {
let mut mize_clone = self.mize.clone();
self.mize.spawn_background("auth.refresh", move || loop {
let mut mize = get_global_mize()?;
mize.spawn_background("auth.refresh", move || loop {
let mut mize = get_global_mize()?;
info!("hi from auth.refresh thread...");
thread::sleep(Duration::from_secs(33)); // TERRIBLE hack
let auth = mize_clone.get_part_native::<ClientAuth>("client_auth")?;
let expiry = auth.secrets.as_ref().unwrap().expiry.clone();
let secrets = get_config("self.secrets")?.native::<ClientSecrets>()?;
let expiry = secrets.expiry.clone();
info!("auth.refresh thread: now: {}", Utc::now());
info!("auth.refresh thread: expiry: {}", expiry);
let to_sleep = expiry - Utc::now() - TimeDelta::minutes(20);
@@ -279,17 +271,13 @@ pub fn start_refresh_thread() -> MizeResult<()> {
}
}
*/
drop(auth);
info!("auth.refresh: thread going to sleep for: {}", to_sleep);
thread::sleep(to_sleep.to_std()?);
info!("refresh_thread: sleeping done");
let mut auth = mize_clone.get_part_native::<ClientAuth>("auth")?;
auth.refresh()?;
drop(auth);
dyn_call(mize.id_new_dots("auth.client")?, "refresh", ItemData::new())?;
})?;
Ok(())
}
/*
@@ -345,25 +333,52 @@ pub fn start_refresh_thread() -> MizeResult<()> {
}
*/
Ok(())
Ok(ItemData::new())
}
fn do_login() -> MizeResult<ClientSecrets> {
let mut mize = get_global_mize()?;
let mut secrets;
if let Ok(val) = load_secrets(&mut mize) {
if val.clone().unwrap().expiry < Utc::now() {
if val.clone().expiry < Utc::now() {
// refresh if secrets are expired
secrets = mize.spawn_async_blocking("auth.refresh", refresh_flow(mize, val))?;
secrets = match mize.spawn_async_blocking("auth.refresh", refresh_flow(mize.clone(), val)) {
Ok(secrets) => secrets,
Err(err) => {
out!("err refreshing secrets: {:?}", err);
mize.spawn_async_blocking("auth.login", auth_flow(mize.clone()))?
}
};
} else {
secrets = val;
}
} else {
secrets = mize.spawn_async_blocking("auth.login", auth_flow(mize))?;
secrets = mize.spawn_async_blocking("auth.login", auth_flow(mize.clone()))?;
}
Ok(secrets)
}
fn notify_mme_url_and_code(url: &str, code: &str) -> MizeResult<()> {
let mut mize = get_global_mize()?;
if mize.has_part("mme")? {
let mut args = ItemData::new();
args.set_path("0", url.to_owned().into_item_data())?;
args.set_path("1", code.to_owned().into_item_data())?;
dyn_call(mize.id_new_dots("mme")?, "set_url_and_code", args)?;
}
Ok(())
}
fn notify_mme_login_state(logged_in: bool) -> MizeResult<()> {
let mut mize = get_global_mize()?;
if mize.has_part("mme")? {
let mut args = ItemData::new();
args.set_path("0", logged_in.into_item_data())?;
dyn_call(mize.id_new_dots("mme")?, "set_login_state", args)?;
}
Ok(())
}
fn load_secrets(mize: &mut Mize) -> MizeResult<ClientSecrets> {
#[cfg(feature = "target-os")] {
let mize_dir = mize.get_config("data_dir")?.value_string()?;
@@ -517,7 +532,8 @@ async fn auth_flow(mut mize: Mize) -> MizeResult<ClientSecrets> {
let issuer = IssuerUrl::new(issuer)?;
let client_id = ClientId::new(client_id);
let http_client = HttpClient::new();
let http_client = openidconnect::reqwest::Client::builder().tls_built_in_root_certs(true).build()?;
// Teach openidconnect-rs about the device authorization url.
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -550,7 +566,7 @@ async fn auth_flow(mut mize: Mize) -> MizeResult<ClientSecrets> {
// Discover provider metadata
let provider_metadata: MyProviderMetadata =
MyProviderMetadata::discover_async(issuer, &http_client).await?;
MyProviderMetadata::discover_async(issuer, &http_client).await.map_err(|e| mize_err!("From reqwest::error::Error: {e}"))?;
let device_auth_url = provider_metadata
.additional_metadata()
@@ -601,18 +617,23 @@ async fn auth_flow(mut mize: Mize) -> MizeResult<ClientSecrets> {
}
#[cfg(feature = "target-os")] {
let url = details
.verification_uri_complete()
.map(|u| u.secret().clone())
.unwrap_or_default();
let code = details.user_code().secret().clone();
let _ = notify_mme_url_and_code(&url, &code);
out!(
"If your browser does not open automatically, open this URL in it:\n{}\n",
details.verification_uri_complete().unwrap().secret()
);
out!(
"Or enter this PPC login code:\n{}\n",
details.user_code().secret()
url
);
out!("Or enter this PPC login code:\n{}\n", code);
if let Some(_) = details.verification_uri_complete() {
let _ = webbrowser::open(details.verification_uri_complete().unwrap().secret());
}
if !url.is_empty() {
let _ = webbrowser::open(&url);
}
}
let token_response = client

View File

@@ -1,6 +0,0 @@
pub mod auth {
pub mod client;
pub use client::*;
}
pub use auth::*;

View File

@@ -11,6 +11,8 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
//methods: [arg(arg: clap::Arg), subcommand(cmd: clap::Command)]
});
include_native!("ArgMatches", clap::ArgMatches, {});
opt("state")
.fun(Box::new(|| {
let mize = get_global_mize().unwrap();
@@ -25,7 +27,7 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
}));
method("set_command", |args| {
method("set_command", "String -> ()", |args| {
let mut state = get_config("self.state")?.native_mut::<CliPartState>()?;
let cmd = args.get_path("0")?.native::<String>()?;
state.current_cmd_name = cmd.to_owned();
@@ -36,7 +38,7 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
Ok(ItemData::new())
});
method("subcommand", |args| {
method("subcommand", "Command, Func -> ()", |args| {
let mut state = get_config("self.state")?.native_mut::<CliPartState>()?;
let subcmd = args.get_path("0")?.native_take::<clap::Command>()?;
let action = args.get_path("1")?;
@@ -52,7 +54,7 @@ pub extern "C" fn part_cli() -> MizeResult<()> {
Ok(ItemData::new())
});
method("run", |args| {
method("run", "Vec<String> -> ()", |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"))?;

View File

@@ -0,0 +1,32 @@
FROM rust:1.95.0
RUN apt-get update
RUN apt-get install -y \
mingw-w64 \
build-essential \
gcc-arm-linux-gnueabi \
g++-arm-linux-gnueabi
RUN apt-get install -y \
clang \
lld \
llvm
# Rust cross-compilation targets
RUN rustup target add --toolchain nightly-2026-01-30 x86_64-pc-windows-gnu
RUN rustup target add --toolchain nightly-2026-01-30 aarch64-pc-windows-gnullvm
RUN rustup target add --toolchain nightly-2026-01-30 x86_64-unknown-linux-gnu
RUN rustup target add --toolchain nightly-2026-01-30 aarch64-unknown-linux-gnu
RUN rustup target add --toolchain nightly-2026-01-30 armv7-unknown-linux-gnueabihf
RUN rustup target add --toolchain nightly-2026-01-30 arm-unknown-linux-gnueabi
# Set default Rust toolchain
RUN rustup default nightly-2026-01-30
WORKDIR /src

View File

@@ -0,0 +1,8 @@
FROM joseluisq/rust-linux-darwin-builder:2.0.0-beta.1
RUN rustup target add --toolchain nightly-2026-01-30 x86_64-apple-darwin
RUN rustup target add --toolchain nightly-2026-01-30 aarch64-apple-darwin
RUN rustup default nightly-2026-01-30
WORKDIR /src

View File

@@ -4,7 +4,8 @@
//! a Rust source file that looks like the hand-written `generated.rs`.
use mize::prelude::*;
use mize::instance::{PartMetadata, MethodMetadata, OptMetadata, NativeMetadata};
use mize::instance::{PartMetadata, MethodMetadata, OptMetadata, NativeMetadata, parse_method_signature};
use std::collections::HashSet;
use std::fmt::Write as _;
/// Convert a part name like "cli" into a PascalCase struct name like "CliPart".
@@ -31,7 +32,8 @@ fn part_name_to_struct(part_name: &str) -> String {
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");
out.push_str("use mize::prelude::*;\n");
out.push_str("use mize::config::MizeFun;\n\n\n");
for meta in all_metadata {
generate_part_struct(&mut out, meta);
@@ -40,9 +42,28 @@ pub fn generate_lib_rs(all_metadata: &[PartMetadata]) -> String {
out
}
fn native_names(meta: &PartMetadata) -> HashSet<String> {
meta.natives.iter().map(|n| n.name.clone()).collect()
}
fn native_fn_names(meta: &PartMetadata) -> HashSet<String> {
let mut names = HashSet::new();
for n in &meta.natives {
for a in &n.associated {
names.insert(a.clone());
}
for m in &n.methods {
names.insert(m.clone());
}
}
names
}
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('.', ".");
let natives = native_names(meta);
let native_fns = native_fn_names(meta);
// struct definition
let _ = writeln!(out, "pub struct {struct_name} {{");
@@ -52,8 +73,8 @@ fn generate_part_struct(out: &mut String, meta: &PartMetadata) {
// 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, " fn id(&self) -> MizeResult<MizeId> {{");
let _ = writeln!(out, " Ok(self.id.clone())");
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
@@ -65,16 +86,23 @@ fn generate_part_struct(out: &mut String, meta: &PartMetadata) {
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, " Ok({struct_name} {{ id: part.id()? }})");
let _ = writeln!(out, " }}");
let _ = writeln!(out);
// one method per recorded method/effect/thread
// part methods (skip ones that belong to include_native)
for m in meta.methods.iter().chain(meta.effects.iter()).chain(meta.threads.iter()) {
generate_method_fn(out, &struct_name, m);
if native_fns.contains(&m.name) {
continue;
}
generate_typed_method_fn(out, &struct_name, m);
}
// one getter-ish fn per recorded opt (opts are config values; expose a dyn_call getter)
// opts that are not natives
for o in &meta.opts {
if natives.contains(&o.name) || o.name.starts_with('_') {
continue;
}
generate_opt_fn(out, &struct_name, o);
}
@@ -83,22 +111,143 @@ fn generate_part_struct(out: &mut String, meta: &PartMetadata) {
// native type wrappers
for n in &meta.natives {
generate_native_struct(out, n);
generate_native_struct(out, &meta.part_name, 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) {
/// Map a signature type name to a Rust parameter type for generated bindings.
fn rust_param_type(type_name: &str) -> String {
match type_name {
"String" => "&str".to_string(),
"u32" => "u32".to_string(),
"Vec<String>" => "Vec<String>".to_string(),
"Func" => "MizeFun".to_string(),
"()" => "()".to_string(),
other => other.to_string(), // native wrapper type
}
}
fn is_native_type(type_name: &str) -> bool {
!matches!(
type_name,
"String" | "u32" | "Vec<String>" | "Func" | "()" | "ItemData" | ""
)
}
/// Emit packing of one typed arg into `args` at path index `i`.
fn emit_arg_pack(out: &mut String, i: usize, type_name: &str, param_name: &str) {
match type_name {
"String" => {
let _ = writeln!(
out,
" args.set_path(\"{i}\", {param_name}.into_item_data())?;"
);
}
"u32" => {
let _ = writeln!(
out,
" args.set_path(\"{i}\", {param_name}.into_item_data())?;"
);
}
"Vec<String>" => {
let _ = writeln!(
out,
" args.set_path(\"{i}\", {param_name}.into_item_data())?;"
);
}
"Func" => {
let _ = writeln!(
out,
" args.set_path(\"{i}\", ItemData::fun({param_name})?)?;"
);
}
_ if is_native_type(type_name) => {
let _ = writeln!(out, " let mut __arg{i} = ItemData::new();");
let _ = writeln!(
out,
" __arg{i}.set_path(\"_native\", {param_name}.native_num)?;"
);
let _ = writeln!(out, " args.set_path(\"{i}\", __arg{i})?;");
}
_ => {
let _ = writeln!(
out,
" args.set_path(\"{i}\", {param_name}.into_item_data())?;"
);
}
}
}
fn generate_typed_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 (arg_types, return_type) = if m.arg_types.is_empty() && m.return_type.is_empty() && !m.signature.is_empty() {
parse_method_signature(&m.signature)
} else {
(m.arg_types.clone(), m.return_type.clone())
};
let params: Vec<(String, String)> = arg_types
.iter()
.enumerate()
.map(|(i, t)| (format!("arg{i}"), rust_param_type(t)))
.collect();
let ret_ty = match return_type.as_str() {
"()" => "()".to_string(),
"" | "ItemData" => "ItemData".to_string(),
other => other.to_string(),
};
let param_list = params
.iter()
.map(|(n, t)| format!("{n}: {t}"))
.collect::<Vec<_>>()
.join(", ");
if param_list.is_empty() {
let _ = writeln!(
out,
" pub fn {fn_name}(&self) -> MizeResult<{ret_ty}> {{"
);
} else {
let _ = writeln!(
out,
" pub fn {fn_name}(&self, {param_list}) -> MizeResult<{ret_ty}> {{"
);
}
let _ = writeln!(out, " let mut args = ItemData::new();");
for (i, t) in arg_types.iter().enumerate() {
emit_arg_pack(out, i, t, &format!("arg{i}"));
}
match ret_ty.as_str() {
"()" => {
let _ = writeln!(out, " dyn_call(self.id.clone(), \"{fn_name}\", args)?;");
let _ = writeln!(out, " Ok(())");
}
"ItemData" => {
let _ = writeln!(out, " dyn_call(self.id.clone(), \"{fn_name}\", args)");
}
native if is_native_type(native) => {
let _ = writeln!(out, " let data = dyn_call(self.id.clone(), \"{fn_name}\", args)?;");
let _ = writeln!(
out,
" Ok({native} {{ native_num: data.get_path(\"_native\")?.value_u32()? }})"
);
}
_ => {
let _ = writeln!(out, " dyn_call(self.id.clone(), \"{fn_name}\", args)");
}
}
let _ = writeln!(out, " }}");
let _ = writeln!(out);
}
@@ -114,40 +263,210 @@ fn generate_opt_fn(out: &mut String, _struct_name: &str, o: &OptMetadata) {
let _ = writeln!(out);
}
fn generate_native_struct(out: &mut String, n: &NativeMetadata) {
fn generate_native_struct(out: &mut String, part_name: &str, 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, " pub native_num: u32");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(out, "impl FromItemData for {struct_name} {{");
let _ = writeln!(out, " fn from_itemdata(data: ItemData) -> MizeResult<Self> {{");
let _ = writeln!(
out,
" Ok({struct_name} {{ native_num: data.get_path(\"_native\")?.value_u32()? }})"
);
let _ = writeln!(out, " }}");
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 (i, assoc) in n.associated.iter().enumerate() {
let sig = n.associated_sigs.get(i).map(|s| s.as_str()).unwrap_or("");
generate_native_associated(out, part_name, n, assoc, sig);
}
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, " }}");
for (i, meth) in n.methods.iter().enumerate() {
let sig = n.method_sigs.get(i).map(|s| s.as_str()).unwrap_or("");
generate_native_instance_method(out, part_name, n, meth, sig);
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
}
fn generate_native_associated(out: &mut String, part_name: &str, n: &NativeMetadata, fn_name: &str, signature: &str) {
let (arg_types, return_type) = parse_method_signature(signature);
let ret_ty = if return_type.is_empty() {
n.name.clone()
} else {
return_type
};
let params: Vec<(String, String)> = arg_types
.iter()
.enumerate()
.map(|(i, t)| (format!("arg{i}"), rust_param_type(t)))
.collect();
let param_list = params
.iter()
.map(|(name, ty)| format!("{name}: {ty}"))
.collect::<Vec<_>>()
.join(", ");
if param_list.is_empty() {
let _ = writeln!(out, " pub fn {fn_name}() -> MizeResult<{ret_ty}> {{");
} else {
let _ = writeln!(
out,
" pub fn {fn_name}({param_list}) -> MizeResult<{ret_ty}> {{"
);
}
let _ = writeln!(out, " let mut args = ItemData::new();");
for (i, t) in arg_types.iter().enumerate() {
emit_arg_pack(out, i, t, &format!("arg{i}"));
}
let _ = writeln!(out, " let mut mize = get_global_mize()?;");
let _ = writeln!(out, " let id = mize.id_new_dots(\"{part_name}.{}\")?;", n.name);
if ret_ty == "()" {
let _ = writeln!(out, " dyn_call(id, \"{fn_name}\", args)?;");
let _ = writeln!(out, " Ok(())");
} else if ret_ty == "ItemData" {
let _ = writeln!(out, " dyn_call(id, \"{fn_name}\", args)");
} else if is_native_type(&ret_ty) || ret_ty == n.name {
let _ = writeln!(out, " let data = dyn_call(id, \"{fn_name}\", args)?;");
let _ = writeln!(
out,
" let native_num: u32 = data.get_path(\"_native\")?.value_u32()?;"
);
let _ = writeln!(out, " Ok({ret_ty} {{ native_num }})");
} else {
let _ = writeln!(out, " dyn_call(id, \"{fn_name}\", args)");
}
let _ = writeln!(out, " }}");
let _ = writeln!(out);
}
fn generate_native_instance_method(out: &mut String, part_name: &str, n: &NativeMetadata, fn_name: &str, signature: &str) {
let (arg_types, return_type) = parse_method_signature(signature);
let ret_ty = if return_type.is_empty() {
"ItemData".to_string()
} else {
return_type
};
let params: Vec<(String, String)> = arg_types
.iter()
.enumerate()
.map(|(i, t)| (format!("arg{i}"), rust_param_type(t)))
.collect();
let param_list = params
.iter()
.map(|(name, ty)| format!("{name}: {ty}"))
.collect::<Vec<_>>()
.join(", ");
if param_list.is_empty() {
let _ = writeln!(
out,
" pub fn {fn_name}(&self) -> MizeResult<{ret_ty}> {{"
);
} else {
let _ = writeln!(
out,
" pub fn {fn_name}(&self, {param_list}) -> MizeResult<{ret_ty}> {{"
);
}
let _ = writeln!(out, " let mut args = ItemData::new();");
for (i, t) in arg_types.iter().enumerate() {
emit_arg_pack(out, i, t, &format!("arg{i}"));
}
let _ = writeln!(out, " let mut mize = get_global_mize()?;");
let _ = writeln!(out, " let id = mize.id_new_dots(\"{part_name}.{}\")?;", n.name);
match ret_ty.as_str() {
"()" => {
let _ = writeln!(out, " dyn_call(id, \"{fn_name}\", args)?;");
let _ = writeln!(out, " Ok(())");
}
"ItemData" => {
let _ = writeln!(out, " dyn_call(id, \"{fn_name}\", args)");
}
native if is_native_type(native) => {
let _ = writeln!(out, " let data = dyn_call(id, \"{fn_name}\", args)?;");
let _ = writeln!(
out,
" Ok({native} {{ native_num: data.get_path(\"_native\")?.value_u32()? }})"
);
}
_ => {
let _ = writeln!(out, " dyn_call(id, \"{fn_name}\", args)");
}
}
let _ = writeln!(out, " }}");
let _ = writeln!(out);
}
/// Extract the `mize = ...` dependency assignment from a part's Cargo.toml.
///
/// Looks under `[dependencies]` (and `[dependencies.mize]` table form) and returns
/// a single-line assignment suitable for writing into generated/Cargo.toml, e.g.
/// `mize = { workspace = true }`.
pub fn extract_mize_dependency(cargo_toml: &str) -> Result<String, String> {
let mut in_deps = false;
let mut in_mize_table = false;
let mut mize_table_fields: Vec<String> = Vec::new();
for raw_line in cargo_toml.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
if in_mize_table {
break;
}
in_deps = line == "[dependencies]";
in_mize_table = line == "[dependencies.mize]";
continue;
}
if in_mize_table {
mize_table_fields.push(line.to_string());
continue;
}
if !in_deps {
continue;
}
// Inline: mize = ...
if let Some(rest) = line.strip_prefix("mize") {
let rest = rest.trim_start();
if rest.starts_with('=') {
return Ok(format!("mize {}", rest));
}
}
}
if !mize_table_fields.is_empty() {
return Ok(format!("mize = {{ {} }}", mize_table_fields.join(", ")));
}
Err("no 'mize' dependency found under [dependencies] in part Cargo.toml".to_string())
}
/// Generate the `Cargo.toml` content for the generated package.
pub fn generate_cargo_toml(package_name: &str) -> String {
///
/// `mize_dep` is the full assignment line copied from the part Cargo.toml,
/// e.g. `mize = { workspace = true }` or `mize = { path = "../../core" }`.
pub fn generate_cargo_toml(package_name: &str, mize_dep: &str) -> String {
format!(
r#"[package]
name = "{package_name}"
@@ -159,9 +478,10 @@ name = "{lib_name}"
path = "src/lib.rs"
[dependencies]
mize = {{ path = "../../core" }}
{mize_dep}
"#,
package_name = package_name,
lib_name = package_name.replace('-', "_"),
mize_dep = mize_dep.trim(),
)
}

View File

@@ -6,8 +6,8 @@ pub struct CliPart {
}
impl MizePart for CliPart {
fn id(&self) -> MizeId {
self.id.clone()
fn id(&self) -> MizeResult<MizeId> {
Ok(self.id.clone())
}
}
@@ -15,7 +15,7 @@ impl CliPart {
pub fn get(name: &str) -> MizeResult<CliPart> {
let mut mize = get_global_mize()?;
let part = mize.get_part(name)?;
Ok(CliPart { id: part.id() })
Ok(CliPart { id: part.id()? })
}
pub fn run(&self, cmdline: Vec<String>) -> MizeResult<()> {
let cmdline = cmdline.into_item_data();
@@ -34,7 +34,7 @@ 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::fun(Box::new(move |args: ItemData| {
let 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())

View File

@@ -2,6 +2,7 @@ use std::fs::{create_dir_all, write};
use mize::prelude::*;
use cmd_lib::run_cmd as sh;
use mize::tracing::info;
use mize::tracing::debug;
// a static version of what would be generated.
// as we need mize.dev to generate anything.
@@ -13,8 +14,7 @@ use generated::*;
#[unsafe(no_mangle)]
extern "C" fn part_dev() -> MizeResult<()> {
let mut mize = get_global_mize()?;
let mut cli = generated::CliPart::get("cli")?;
let cli = generated::CliPart::get("cli")?;
cli.set_command("dev");
cli.subcommand(Command::new("test")?, |_| {println!("hello from test command"); Ok(())})?;
@@ -50,11 +50,19 @@ pub fn publish_mize(_ : ArgMatches) -> MizeResult<()> {
"x86_64-apple-darwin" => "darwin",
_ => "base",
};
sh!(docker build -t mize-build-$docker_image $mize_src/parts/mize.dev/targets/$docker_image)?;
let res = sh!(docker run -it --rm -v $cargo_dir/registry:/usr/local/cargo/registry -v $cargo_dir/git:/usr/local/cargo/git -v $mize_src:/src -v $mize_src/target:/src/target mize-build-$docker_image cargo build --target $target_name --release);
let tmp = std::env::var("TMP").unwrap();
let mut build_config = ItemData::new();
build_config.set_path("namespace", "mize.buildtime.ns")?;
std::fs::create_dir_all(&tmp)?;
std::fs::File::create(format!("{tmp}/mize-part-dev-build-config.toml"))?;
std::fs::write(format!("{tmp}/mize-part-dev-build-config.toml"), build_config.to_json()?)?;
sh!(docker build -t mize-build-$docker_image $mize_src/parts/mize.dev/docker/$docker_image)?;
sh!(echo running in container: docker run -it --rm -v "$tmp/mize-part-dev-build-config.toml:/build-config.toml" -e MIZE_BUILD_CONFIG=/build-config.toml -v $cargo_dir/registry:/usr/local/cargo/registry -v $cargo_dir/git:/usr/local/cargo/git -v $mize_src:/src -v $mize_src/target:/src/target mize-build-$docker_image cargo build --target $target_name --release --workspace)?;
let res = sh!(docker run -it --rm -v "$tmp/mize-part-dev-build-config.toml:/build-config.toml" -e MIZE_BUILD_CONFIG=/build-config.toml -v $cargo_dir/registry:/usr/local/cargo/registry -v $cargo_dir/git:/usr/local/cargo/git -v $mize_src:/src -v $mize_src/target:/src/target mize-build-$docker_image cargo build --target $target_name --release);
if res.is_err() {
info!("build for {target_name} failed...");
}
break;
};
@@ -79,11 +87,11 @@ pub fn gen_bindings(_ : ArgMatches) -> MizeResult<()> {
// 2. parse the part name and the gen = [...] list
let part_name: String = part_data
.get_path("name")
.get_path("id")
.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}");
println!("part name: {part_name}");
let gen_data = part_data
.get_path("gen")
@@ -97,7 +105,7 @@ pub fn gen_bindings(_ : ArgMatches) -> MizeResult<()> {
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);
debug!("parts to generate bindings for: {:?}", gen_parts);
// 3. enable generate mode and clear any old metadata
mize.set_generate_mode(true)?;
@@ -122,7 +130,13 @@ pub fn gen_bindings(_ : ArgMatches) -> MizeResult<()> {
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);
let part_cargo_path = cwd.join("Cargo.toml");
let part_cargo = std::fs::read_to_string(&part_cargo_path)
.map_err(|e| mize_err!("could not read part Cargo.toml {}: {e}", part_cargo_path.display()))?;
let mize_dep = codegen::extract_mize_dependency(&part_cargo)
.map_err(|e| mize_err!("{e}"))?;
debug!("using mize dependency from part Cargo.toml: {mize_dep}");
let cargo_toml = codegen::generate_cargo_toml(&package_name, &mize_dep);
write(gen_dir.join("Cargo.toml"), cargo_toml)
.map_err(|e| mize_err!("could not write generated/Cargo.toml: {e}"))?;

View File

@@ -8,10 +8,9 @@ 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"]
desktop = ["dioxus/desktop", "dioxus-primitives/web"]

View File

@@ -1,44 +0,0 @@
/* 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

@@ -3,9 +3,92 @@ 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;
const STYLE: &str = r#".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);
}
"#;
#[derive(Clone, Copy, PartialEq, Default)]
pub enum AvatarImageSize {
#[default]
@@ -17,9 +100,9 @@ pub enum AvatarImageSize {
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,
AvatarImageSize::Small => "dx-avatar-sm",
AvatarImageSize::Medium => "dx-avatar-md",
AvatarImageSize::Large => "dx-avatar-lg",
}
}
}
@@ -34,8 +117,8 @@ pub enum AvatarShape {
impl AvatarShape {
fn to_class(self) -> &'static str {
match self {
AvatarShape::Circle => Styles::dx_avatar_circle.inner,
AvatarShape::Rounded => Styles::dx_avatar_rounded.inner,
AvatarShape::Circle => "dx-avatar-circle",
AvatarShape::Rounded => "dx-avatar-rounded",
}
}
}
@@ -73,7 +156,7 @@ pub struct AvatarProps {
pub fn Avatar(props: AvatarProps) -> Element {
let class = format!(
"{} {} {}",
Styles::dx_avatar,
"dx-avatar",
props.size.to_class(),
props.shape.to_class()
);
@@ -83,6 +166,8 @@ pub fn Avatar(props: AvatarProps) -> Element {
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/avatar", css: STYLE }
avatar::Avatar {
on_load: props.on_load,
on_error: props.on_error,
@@ -110,12 +195,14 @@ pub struct AvatarImageProps {
#[component]
pub fn AvatarImage(props: AvatarImageProps) -> Element {
let base = attributes!(img {
class: Styles::dx_avatar_image,
class: "dx-avatar-image",
draggable: "false",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/avatar", css: STYLE }
avatar::AvatarImage {
id: props.id,
src: props.src,
@@ -136,11 +223,13 @@ pub struct AvatarFallbackProps {
#[component]
pub fn AvatarFallback(props: AvatarFallbackProps) -> Element {
let base = attributes!(span {
class: Styles::dx_avatar_fallback,
class: "dx-avatar-fallback",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/avatar", css: STYLE }
avatar::AvatarFallback {
attributes: merged,
{props.children}
@@ -187,6 +276,8 @@ pub struct ImageAvatarProps {
#[component]
pub fn ImageAvatar(props: ImageAvatarProps) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/avatar", css: STYLE }
Avatar {
on_load: props.on_load,
on_error: props.on_error,

View File

@@ -1,84 +0,0 @@
.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

@@ -1,9 +1,45 @@
use dioxus::prelude::*;
use dioxus_icons::lucide::BadgeCheck;
#[css_module("/src/components/badge/style.css")]
struct Styles;
const STYLE: &str = r#".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);
}
"#;
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum BadgeVariant {
@@ -42,6 +78,8 @@ pub struct BadgeProps {
#[component]
pub fn Badge(props: BadgeProps) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/badge", css: STYLE }
BadgeElement {
"padding": true,
variant: props.variant,
@@ -54,8 +92,10 @@ pub fn Badge(props: BadgeProps) -> Element {
#[component]
fn BadgeElement(props: BadgeProps) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/badge", css: STYLE }
span {
class: Styles::dx_badge,
class: "dx-badge",
"data-style": props.variant.class(),
..props.attributes,
{props.children}
@@ -66,6 +106,8 @@ fn BadgeElement(props: BadgeProps) -> Element {
#[component]
pub fn VerifiedIcon() -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/badge", css: STYLE }
BadgeCheck {
size: "12px",
stroke: "var(--text)",

View File

@@ -1,37 +0,0 @@
.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

@@ -2,9 +2,203 @@ use dioxus::prelude::*;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::merge_attributes;
#[css_module("/src/components/button/style.css")]
struct Styles;
const STYLE: &str = r#".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);
}
"#;
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum ButtonVariant {
@@ -73,13 +267,15 @@ pub fn Button(
children: Element,
) -> Element {
let base = attributes!(button {
class: Styles::dx_button,
class: "dx-button",
"data-style": variant.class(),
"data-size": size.class(),
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/button", css: STYLE }
button {
onclick: move |event| {
if let Some(f) = &onclick {

View File

@@ -1,195 +0,0 @@
.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

@@ -1,16 +1,69 @@
use dioxus::prelude::*;
#[css_module("/src/components/card/style.css")]
struct Styles;
const STYLE: &str = r#".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;
}
"#;
#[component]
pub fn Card(
#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card,
class: "dx-card",
"data-slot": "card",
..attributes,
{children}
@@ -24,8 +77,10 @@ pub fn CardHeader(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card_header,
class: "dx-card-header",
"data-slot": "card-header",
..attributes,
{children}
@@ -39,8 +94,10 @@ pub fn CardTitle(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card_title,
class: "dx-card-title",
"data-slot": "card-title",
..attributes,
{children}
@@ -54,8 +111,10 @@ pub fn CardDescription(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card_description,
class: "dx-card-description",
"data-slot": "card-description",
..attributes,
{children}
@@ -69,8 +128,10 @@ pub fn CardAction(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card_action,
class: "dx-card-action",
"data-slot": "card-action",
..attributes,
{children}
@@ -84,8 +145,10 @@ pub fn CardContent(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card_content,
class: "dx-card-content",
"data-slot": "card-content",
..attributes,
{children}
@@ -99,8 +162,10 @@ pub fn CardFooter(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/card", css: STYLE }
div {
class: Styles::dx_card_footer,
class: "dx-card-footer",
"data-slot": "card-footer",
..attributes,
{children}

View File

@@ -1,52 +0,0 @@
.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

@@ -2,14 +2,49 @@ use dioxus::prelude::*;
use dioxus_icons::lucide::Check;
use dioxus_primitives::checkbox::{self, CheckboxProps};
#[css_module("/src/components/checkbox/style.css")]
struct Styles;
const STYLE: &str = r#".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;
}
"#;
#[component]
pub fn Checkbox(props: CheckboxProps) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/checkbox", css: STYLE }
checkbox::Checkbox {
class: Styles::dx_checkbox,
class: "dx-checkbox",
checked: props.checked,
default_checked: props.default_checked,
required: props.required,
@@ -18,7 +53,7 @@ pub fn Checkbox(props: CheckboxProps) -> Element {
value: props.value,
on_checked_change: props.on_checked_change,
attributes: props.attributes,
checkbox::CheckboxIndicator { class: Styles::dx_checkbox_indicator,
checkbox::CheckboxIndicator { class: "dx-checkbox-indicator",
Check { size: "1rem" }
}
}

View File

@@ -1,34 +0,0 @@
.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,211 @@
use dioxus::prelude::*;
use crate::{Button, ButtonVariant, Dialog, DialogDescription, DialogTitle};
const STYLE: &str = r#".dx-control {
display: inline-flex;
width: 100%;
height: 100%;
min-width: 5.5rem;
min-height: 5.5rem;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.85rem 0.65rem 0.7rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
box-shadow: 0 4px 16px rgb(0 0 0 / 16%);
color: var(--text);
cursor: pointer;
font-family: inherit;
gap: 0.45rem;
outline: none;
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease,
transform 120ms ease;
user-select: none;
}
.dx-control:hover:not(:disabled):not([data-kind="display"]) {
background: var(--surface-2);
border-color: color-mix(in oklab, var(--accent) 45%, var(--border));
transform: translateY(-1px);
}
.dx-control:focus-visible {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 40%, transparent),
0 4px 16px rgb(0 0 0 / 16%);
}
.dx-control[data-enabled="true"] {
border-color: color-mix(in oklab, var(--accent) 70%, var(--border));
background: color-mix(in oklab, var(--accent) 16%, var(--surface));
}
.dx-control[data-kind="display"] {
cursor: default;
}
.dx-control:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.dx-control-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.375rem;
background: var(--surface-2);
color: var(--text);
}
.dx-control[data-enabled="true"] .dx-control-icon {
background: color-mix(in oklab, var(--accent) 28%, var(--surface-2));
color: var(--accent-text);
}
.dx-control-icon svg {
width: 1.25rem;
height: 1.25rem;
}
.dx-control-name {
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
text-align: center;
}
.dx-control-value {
color: var(--text-2);
font-size: 0.7rem;
line-height: 1.2;
text-align: center;
}
.dx-control-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.75rem;
gap: 0.5rem;
}
"#;
#[derive(Copy, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ControlKind {
/// Click toggles an on/off state (optional confirm).
#[default]
Toggle,
/// Click triggers an action (optional confirm).
Action,
/// Read-only display of a setting/value.
Display,
}
impl ControlKind {
fn as_str(self) -> &'static str {
match self {
ControlKind::Toggle => "toggle",
ControlKind::Action => "action",
ControlKind::Display => "display",
}
}
}
#[component]
pub fn Control(
/// Visible label under the icon.
name: String,
#[props(default)]
kind: ControlKind,
/// Toggle enabled state (Toggle kind).
#[props(default)]
enabled: bool,
/// Optional value line (useful for Display, also ok for others).
value: Option<String>,
/// If set, show a confirmation dialog before toggling/acting.
confirm: Option<String>,
/// Fired for Action after confirm (or immediately if no confirm).
onclick: Option<EventHandler<()>>,
/// Fired for Toggle with the new enabled value after confirm (or immediately).
on_enabled_change: Option<EventHandler<bool>>,
/// Icon element (e.g. a dioxus-icons lucide component).
children: Element,
) -> Element {
let mut confirm_open = use_signal(|| false);
let needs_confirm = confirm.is_some();
let do_commit = move || {
match kind {
ControlKind::Toggle => {
if let Some(cb) = on_enabled_change {
cb.call(!enabled);
}
}
ControlKind::Action => {
if let Some(cb) = onclick {
cb.call(());
}
}
ControlKind::Display => {}
}
};
rsx! {
crate::OnceStyle { href: "dioxus-components/control", css: STYLE }
button {
class: "dx-control",
"data-slot": "control",
"data-kind": kind.as_str(),
"data-enabled": enabled.to_string(),
r#type: "button",
disabled: kind == ControlKind::Display,
onclick: move |_| {
if kind == ControlKind::Display {
return;
}
if needs_confirm {
confirm_open.set(true);
} else {
do_commit();
}
},
span { class: "dx-control-icon", {children} }
span { class: "dx-control-name", "{name}" }
if let Some(val) = &value {
span { class: "dx-control-value", "{val}" }
}
}
if let Some(msg) = confirm {
Dialog {
open: Some(confirm_open()),
on_open_change: move |open| confirm_open.set(open),
DialogTitle { "Confirm" }
DialogDescription { "{msg}" }
div { class: "dx-control-actions",
Button {
variant: ButtonVariant::Ghost,
onclick: move |_| confirm_open.set(false),
"Cancel"
}
Button {
variant: ButtonVariant::Primary,
onclick: move |_| {
confirm_open.set(false);
do_commit();
},
"Confirm"
}
}
}
}
}
}

View File

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

View File

@@ -2,19 +2,120 @@ 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;
const STYLE: &str = r#".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);
}
"#;
#[component]
pub fn Dialog(props: DialogRootProps) -> Element {
let base = attributes!(div {
class: Styles::dx_dialog,
class: "dx-dialog",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/dialog", css: STYLE }
dialog::DialogRoot {
class: Styles::dx_dialog_backdrop,
class: "dx-dialog-backdrop",
id: props.id,
is_modal: props.is_modal,
open: props.open,
@@ -32,11 +133,13 @@ pub fn Dialog(props: DialogRootProps) -> Element {
#[component]
pub fn DialogTitle(props: DialogTitleProps) -> Element {
let base = attributes!(h2 {
class: Styles::dx_dialog_title,
class: "dx-dialog-title",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/dialog", css: STYLE }
dialog::DialogTitle {
id: props.id,
attributes: merged,
@@ -48,11 +151,13 @@ pub fn DialogTitle(props: DialogTitleProps) -> Element {
#[component]
pub fn DialogDescription(props: DialogDescriptionProps) -> Element {
let base = attributes!(p {
class: Styles::dx_dialog_description,
class: "dx-dialog-description",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/dialog", css: STYLE }
dialog::DialogDescription {
id: props.id,
attributes: merged,

View File

@@ -1,100 +0,0 @@
.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

@@ -1,7 +1,46 @@
use dioxus::prelude::*;
#[css_module("/src/components/input/style.css")]
struct Styles;
const STYLE: &str = r#".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;
}
"#;
#[component]
pub fn Input(
oninput: Option<EventHandler<FormEvent>>,
@@ -29,8 +68,10 @@ pub fn Input(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/input", css: STYLE }
input {
class: Styles::dx_input,
class: "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)),

View File

@@ -1,40 +0,0 @@
.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

@@ -1,13 +1,23 @@
use dioxus::prelude::*;
use dioxus_primitives::label::{self, LabelProps};
#[css_module("/src/components/label/style.css")]
struct Styles;
const STYLE: &str = r#".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;
}
"#;
#[component]
pub fn Label(props: LabelProps) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/label", css: STYLE }
label::Label {
class: Styles::dx_label,
class: "dx-label",
html_for: props.html_for,
attributes: props.attributes,
{props.children}

View File

@@ -1,9 +0,0 @@
.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

@@ -3,9 +3,11 @@ pub mod badge;
pub mod button;
pub mod card;
pub mod checkbox;
pub mod control;
pub mod dialog;
pub mod input;
pub mod label;
pub mod prompt;
pub mod scroll_area;
pub mod separator;
pub mod switch;
@@ -16,9 +18,11 @@ pub use badge::*;
pub use button::*;
pub use card::*;
pub use checkbox::*;
pub use control::*;
pub use dialog::*;
pub use input::*;
pub use label::*;
pub use prompt::*;
pub use scroll_area::*;
pub use separator::*;
pub use switch::*;

View File

@@ -0,0 +1,103 @@
use dioxus::prelude::*;
const STYLE: &str = r#".dx-prompt {
position: relative;
display: flex;
width: 100%;
box-sizing: border-box;
flex-direction: row;
align-items: center;
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
box-shadow: none;
gap: 0.5rem;
}
.dx-prompt-input {
display: block;
width: 100%;
min-height: 1.5rem;
max-height: 1.5rem;
box-sizing: border-box;
padding: 0;
border: none;
margin: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5rem;
outline: none;
overflow: hidden;
resize: none;
white-space: nowrap;
}
.dx-prompt-input::placeholder {
color: var(--text-2);
}
"#;
#[component]
pub fn Prompt(
/// Controlled value of the prompt text.
#[props(default)]
value: String,
/// Placeholder shown when empty.
#[props(default = "Ask anything…".to_string())]
placeholder: String,
/// Autofocus the textarea when mounted (default true).
#[props(default = true)]
autofocus: bool,
oninput: Option<EventHandler<FormEvent>>,
onkeydown: Option<EventHandler<KeyboardEvent>>,
#[props(extends = GlobalAttributes)]
attributes: Vec<Attribute>,
) -> Element {
let mut textarea_el = use_signal(|| None::<std::rc::Rc<MountedData>>);
use_effect(move || {
if !autofocus {
return;
}
if let Some(el) = textarea_el() {
spawn(async move {
let _ = el.set_focus(true).await;
});
}
});
rsx! {
crate::OnceStyle { href: "dioxus-components/prompt", css: STYLE }
div {
class: "dx-prompt",
"data-slot": "prompt",
..attributes,
textarea {
class: "dx-prompt-input",
"data-slot": "prompt-input",
value,
placeholder,
rows: 1,
onmounted: move |e| textarea_el.set(Some(e.data())),
oninput: move |e| {
if let Some(cb) = &oninput {
cb.call(e);
}
},
onkeydown: move |e| {
// Enter does nothing for now (no submit).
if e.key() == Key::Enter {
e.prevent_default();
}
if let Some(cb) = &onkeydown {
cb.call(e);
}
},
}
}
}
}

View File

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

View File

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

View File

@@ -2,17 +2,31 @@ 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;
const STYLE: &str = r#".dx-separator {
background-color: var(--border);
}
.dx-separator[data-orientation="horizontal"] {
width: 100%;
height: 1px;
}
.dx-separator[data-orientation="vertical"] {
width: 1px;
height: 100%;
}
"#;
#[component]
pub fn Separator(props: SeparatorProps) -> Element {
let base = attributes!(div {
class: Styles::dx_separator,
class: "dx-separator",
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
crate::OnceStyle { href: "dioxus-components/separator", css: STYLE }
separator::Separator {
horizontal: props.horizontal,
decorative: props.decorative,

View File

@@ -1,13 +0,0 @@
.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

@@ -1,14 +1,56 @@
use dioxus::prelude::*;
use dioxus_primitives::switch::{self, SwitchProps};
#[css_module("/src/components/switch/style.css")]
struct Styles;
const STYLE: &str = r#".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;
}
"#;
#[component]
pub fn Switch(props: SwitchProps) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/switch", css: STYLE }
switch::Switch {
class: Styles::dx_switch,
class: "dx-switch",
checked: props.checked,
default_checked: props.default_checked,
disabled: props.disabled,
@@ -17,7 +59,7 @@ pub fn Switch(props: SwitchProps) -> Element {
value: props.value,
on_checked_change: props.on_checked_change,
attributes: props.attributes,
switch::SwitchThumb { class: Styles::dx_switch_thumb }
switch::SwitchThumb { class: "dx-switch-thumb" }
}
}
}

View File

@@ -1,41 +0,0 @@
.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

@@ -1,8 +1,91 @@
use dioxus::prelude::*;
#[css_module("/src/components/textarea/style.css")]
struct Styles;
const STYLE: &str = r#".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);
}
"#;
#[derive(Copy, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum TextareaVariant {
@@ -52,8 +135,10 @@ pub fn Textarea(
children: Element,
) -> Element {
rsx! {
crate::OnceStyle { href: "dioxus-components/textarea", css: STYLE }
textarea {
class: Styles::dx_textarea,
class: "dx-textarea",
"data-slot": "textarea",
"data-style": variant.class(),
oninput: move |e| _ = oninput.map(|callback| callback(e)),

View File

@@ -1,84 +0,0 @@
.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

@@ -1,7 +1,8 @@
//! 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.
//! Include [`THEME_CSS`] once at the app root via `document::Style`, and set
//! `html[data-theme="light"]` or `html[data-theme="dark"]` (or a scoped
//! `data-theme` ancestor) to switch themes.
#![allow(non_snake_case)]
@@ -10,5 +11,75 @@ 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");
/// Global theme CSS variables. Inject with `document::Style { {THEME_CSS} }`.
pub const THEME_CSS: &str = r#"/* 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);
}
"#;
/// Injects a `<style>` into the document head exactly once.
///
/// Wrap `document::Style` in a component whose props are stable (`&'static str`)
/// so parent re-renders do not diff Style props (which dioxus does not support
/// and warns about).
#[component]
pub fn OnceStyle(href: &'static str, css: &'static str) -> Element {
rsx! {
document::Style {
href: "{href}",
{css}
}
}
}
/// Convenience component that injects [`THEME_CSS`] into the document head.
#[component]
pub fn ThemeStyle() -> Element {
rsx! {
OnceStyle {
href: "dioxus-components/theme",
css: THEME_CSS,
}
}
}

View File

@@ -1,21 +1,23 @@
cargo-features = ["profile-rustflags"]
[package]
name = "mize-part-auth"
name = "mize-part-mme"
version = "0.1.0"
edition = "2024"
[lib]
name = "mize_part_auth"
crate-type = [ "cdylib", "rlib" ]
name = "mize_part_mme"
crate-type = ["cdylib", "rlib"]
[dependencies]
mize = { workspace = true }
log = "0.4.34"
mize = { workspace = true }
dioxus = { version = "0.7", features = ["desktop"] }
dioxus-components = { path = "../mize.ui/dioxus-components", features = ["desktop"] }
dioxus-icons = "0.1.0"
[profile.dev]
rustflags = ["-C", "prefer-dynamic", "-C", "link-args=-Wl,--export-dynamic"]
[profile.release]
rustflags = ["-C", "prefer-dynamic"]

389
parts/mme/src/dashboard.rs Normal file
View File

@@ -0,0 +1,389 @@
#![allow(non_snake_case)]
use dioxus::prelude::*;
use dioxus_components::{Button, ButtonSize, ButtonVariant, Control, ControlKind, Prompt};
use dioxus_icons::lucide::{Bell, Bot, Check, Copy, LogIn, Mic, Moon, Power, Settings, Shield, Sun, Wifi};
use crate::{read_hub_state, sleep_ms, start_login};
fn copy_to_clipboard(text: &str) {
let mut literal = String::from('"');
for c in text.chars() {
match c {
'"' => literal.push_str("\\\""),
'\\' => literal.push_str("\\\\"),
'\n' => literal.push_str("\\n"),
'\r' => literal.push_str("\\r"),
'\t' => literal.push_str("\\t"),
c if c.is_control() => literal.push_str(&format!("\\u{:04x}", c as u32)),
c => literal.push(c),
}
}
literal.push('"');
let _ = document::eval(&format!("navigator.clipboard.writeText({literal})"));
}
const APP_CSS: &str = r#"
html, body {
width: 100%;
height: 100%;
margin: 0;
background: #171D18;
color: #F2F4EA;
font-family: "Segoe UI", "IBM Plex Sans", "Helvetica Neue", sans-serif;
}
#main, #main > div {
width: 100%;
height: 100%;
}
.mme-hub {
/* Palette: dark defaults; light overrides below. Values match the mize token table. */
--bg: #171D18;
--surface: #242D25;
--surface-2: #2C362D;
--text: #F2F4EA;
--text-2: #AAB5A4;
--border: #3A463A;
--accent: #D7E47D;
--accent-text: #D7E47D;
--success: #B9D987;
--info: #B9E4EB;
--warning: #FFD77F;
--danger: #FF9B8E;
--on-accent: #171D18;
--focus-ring: var(--accent);
--tile-size: 6.75rem;
--tile-gap: 0.65rem;
--prompt-gap: 2.25rem;
display: flex;
width: 100%;
height: 100%;
box-sizing: border-box;
flex-direction: column;
align-items: stretch;
padding: 1rem 1.25rem 1.5rem;
gap: var(--prompt-gap);
overflow: auto;
background: var(--bg);
color: var(--text);
}
.mme-hub[data-theme="light"] {
--bg: #F7F8F4;
--surface: #FFFFFF;
--surface-2: #EFF2EA;
--text: #20251C;
--text-2: #66705E;
--border: #DFE4D9;
--accent: #FEC251;
--accent-text: #79500F;
--success: #526F32;
--info: #376777;
--warning: #A66B13;
--danger: #B94D42;
--on-accent: #79500F;
}
.mme-topbar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.65rem;
width: 100%;
}
.mme-topbar-prompt {
flex: 1 1 auto;
min-width: 0;
}
.mme-topbar-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
.mme-login-banner {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 0.55rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
color: var(--text);
}
.mme-login-banner-label {
color: var(--text-2);
font-size: 0.75rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.mme-login-banner-row {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.mme-login-banner-code {
flex: 1 1 auto;
min-width: 0;
color: var(--accent-text);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.08em;
}
.mme-login-banner-url {
flex: 1 1 auto;
min-width: 0;
color: var(--info);
font-size: 0.8125rem;
word-break: break-all;
}
.mme-tiles {
display: grid;
flex: 1 1 auto;
align-content: start;
justify-content: start;
justify-items: center;
gap: var(--tile-gap);
grid-template-columns: repeat(3, var(--tile-size));
}
.mme-hub-tile {
width: var(--tile-size);
height: var(--tile-size);
}
@media (max-width: 720px) {
.mme-hub {
--tile-size: 5.75rem;
--prompt-gap: 1.35rem;
}
.mme-topbar {
flex-wrap: wrap;
}
.mme-topbar-actions {
width: 100%;
justify-content: flex-end;
}
.mme-tiles {
grid-template-columns: repeat(2, var(--tile-size));
}
}
"#;
pub fn launch() {
dioxus::LaunchBuilder::new()
.with_cfg(
dioxus::desktop::Config::new()
.with_menu(None)
.with_window(
dioxus::desktop::WindowBuilder::new().with_title("mme"),
),
)
.launch(App);
}
/// Isolated so hub re-renders never diff `document::Style` props.
#[component]
fn AppStyle() -> Element {
rsx! {
document::Style { {APP_CSS} }
}
}
#[component]
fn App() -> Element {
rsx! {
AppStyle {}
document::Title { "mme" }
Hub {}
}
}
#[component]
fn Hub() -> Element {
let mut prompt = use_signal(String::new);
let mut mic_on = use_signal(|| false);
let mut wifi_on = use_signal(|| true);
let mut shield_on = use_signal(|| true);
let mut dark = use_signal(|| true);
let mut bot_armed = use_signal(|| false);
let initial = read_hub_state().unwrap_or_default();
let mut logged_in = use_signal(|| initial.logged_in);
let mut logging_in = use_signal(|| initial.logging_in);
let mut login_url = use_signal(|| initial.login_url.clone());
let mut login_code = use_signal(|| initial.login_code.clone());
use_future(move || async move {
loop {
sleep_ms(200).await;
let Ok(hub) = read_hub_state() else {
continue;
};
// Only write signals when values change — avoids re-rendering the
// whole hub (and its nested document::Style tags) every tick.
if logged_in() != hub.logged_in {
logged_in.set(hub.logged_in);
}
if logging_in() != hub.logging_in {
logging_in.set(hub.logging_in);
}
if login_url() != hub.login_url {
login_url.set(hub.login_url.clone());
}
if login_code() != hub.login_code {
login_code.set(hub.login_code.clone());
}
}
});
let theme = if dark() { "dark" } else { "light" };
let show_banner = logging_in() && (login_url().is_some() || login_code().is_some());
rsx! {
div {
class: "mme-hub",
"data-theme": theme,
div { class: "mme-topbar",
div { class: "mme-topbar-prompt",
Prompt {
value: prompt(),
placeholder: "Ask or run a command…".to_string(),
oninput: move |e: FormEvent| prompt.set(e.value()),
}
}
div { class: "mme-topbar-actions",
Button {
variant: ButtonVariant::Outline,
onclick: move |_| dark.set(!dark()),
if dark() {
Moon {}
} else {
Sun {}
}
if dark() { "Dark" } else { "Light" }
}
if logged_in() {
Button {
variant: ButtonVariant::Outline,
onclick: move |_| {},
Check {}
"Logged in"
}
} else if logging_in() {
Button {
variant: ButtonVariant::Outline,
onclick: move |_| {},
LogIn {}
"Waiting…"
}
} else {
Button {
variant: ButtonVariant::Primary,
onclick: move |_| start_login(),
LogIn {}
"PPC Login"
}
}
}
}
if show_banner {
div { class: "mme-login-banner",
span { class: "mme-login-banner-label", "PPC device login" }
if let Some(code) = login_code() {
div { class: "mme-login-banner-row",
span { class: "mme-login-banner-code", "{code}" }
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
onclick: move |_| copy_to_clipboard(&code),
Copy {}
"Copy code"
}
}
}
if let Some(url) = login_url() {
div { class: "mme-login-banner-row",
span { class: "mme-login-banner-url", "{url}" }
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
onclick: move |_| copy_to_clipboard(&url),
Copy {}
"Copy link"
}
}
}
}
}
div { class: "mme-tiles",
div { class: "mme-hub-tile",
Control {
name: "Mic".to_string(),
kind: ControlKind::Toggle,
enabled: mic_on(),
confirm: Some("Enable microphone access for voice input?".into()),
on_enabled_change: move |v| mic_on.set(v),
Mic {}
}
}
div { class: "mme-hub-tile",
Control {
name: "Alerts".to_string(),
kind: ControlKind::Action,
onclick: move |_| {},
Bell {}
}
}
div { class: "mme-hub-tile",
Control {
name: "Network".to_string(),
kind: ControlKind::Toggle,
enabled: wifi_on(),
on_enabled_change: move |v| wifi_on.set(v),
Wifi {}
}
}
div { class: "mme-hub-tile",
Control {
name: "Agent".to_string(),
kind: ControlKind::Toggle,
enabled: bot_armed(),
confirm: Some("Arm the agent for autonomous actions?".into()),
on_enabled_change: move |v| bot_armed.set(v),
Bot {}
}
}
div { class: "mme-hub-tile",
Control {
name: "Status".to_string(),
kind: ControlKind::Display,
value: Some(if bot_armed() { "armed".into() } else { "idle".into() }),
Power {}
}
}
div { class: "mme-hub-tile",
Control {
name: "Shield".to_string(),
kind: ControlKind::Toggle,
enabled: shield_on(),
confirm: Some("Disable shield? This turns off local protections.".into()),
on_enabled_change: move |v| shield_on.set(v),
Shield {}
}
}
div { class: "mme-hub-tile",
Control {
name: "Settings".to_string(),
kind: ControlKind::Action,
onclick: move |_| {},
Settings {}
}
}
}
}
}
}

View File

@@ -0,0 +1,83 @@
use mize::prelude::*;
pub struct CliPart {
id: MizeId,
}
impl MizePart for CliPart {
fn id(&self) -> MizeResult<MizeId> {
Ok(self.id.clone())
}
}
impl CliPart {
pub fn get(name: &str) -> MizeResult<CliPart> {
let mut mize = get_global_mize()?;
let part = mize.get_part(name)?;
Ok(CliPart { id: part.id()? })
}
pub fn run(&self, cmdline: Vec<String>) -> MizeResult<()> {
let cmdline = cmdline.into_item_data();
let mut args = ItemData::new();
args.set_path("0", cmdline)?;
dyn_call(self.id.clone(), "run", args)?;
Ok(())
}
pub fn set_command(&self, name: &str) -> MizeResult<()> {
let name = name.into_item_data();
let mut args = ItemData::new();
args.set_path("0", name)?;
dyn_call(self.id.clone(), "set_command", args)?;
Ok(())
}
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 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)?;
dyn_call(self.id.clone(), "subcommand", args)?;
Ok(())
}
}
pub struct Command {
native_id: u32,
}
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()?,
})
}
}
impl Command {
pub fn new(name: &str) -> MizeResult<Command> {
let name = name.into_item_data();
let mut args = ItemData::new();
args.set_path("0", name)?;
let mut mize = get_global_mize()?;
let id = mize.id_new_dots("cli.Command")?;
let data = dyn_call(id.clone(), "new", args)?;
let native_id: u32 = data.get_path("_native")?.native_take()?;
Ok(Command { native_id })
}
}

97
parts/mme/src/lib.rs Normal file
View File

@@ -0,0 +1,97 @@
mod dashboard;
mod generated;
use generated::{CliPart, Command};
use mize::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HubState {
pub login_url: Option<String>,
pub login_code: Option<String>,
pub logged_in: bool,
pub logging_in: bool,
}
#[unsafe(no_mangle)]
extern "C" fn part_mme() -> MizeResult<()> {
opt("hub").fun(Box::new(|| Ok(ItemData::from_native(HubState::default())?)));
method("set_url_and_code", "String, String -> ()", |args| {
let url = args.get_path("0")?.value_string()?;
let code = args.get_path("1")?.value_string()?;
let mut hub = get_config("self.hub")?.native_mut::<HubState>()?;
hub.login_url = Some(url);
hub.login_code = Some(code);
hub.logged_in = false;
hub.logging_in = true;
Ok(ItemData::new())
});
method("set_login_state", "bool -> ()", |args| {
let logged_in = args.get_path("0")?.value_bool()?;
let mut hub = get_config("self.hub")?.native_mut::<HubState>()?;
hub.logged_in = logged_in;
hub.logging_in = false;
if logged_in {
hub.login_url = None;
hub.login_code = None;
}
Ok(ItemData::new())
});
// Seed logged-in from existing secrets, if any.
if let Ok(secrets) = get_config("auth.client.secrets") {
if !secrets.cbor().is_null() {
let mut hub = get_config("self.hub")?.native_mut::<HubState>()?;
hub.logged_in = true;
}
}
let cli = CliPart::get("cli")?;
cli.set_command("mme")?;
cli.subcommand(Command::new("main")?, |_| {
dashboard::launch();
Ok(())
})?;
Ok(())
}
pub fn read_hub_state() -> MizeResult<HubState> {
Ok(get_config("mme.hub")?.native::<HubState>()?.clone())
}
pub fn start_login() {
std::thread::spawn(|| {
let result = (|| -> MizeResult<()> {
{
let mut hub = get_config("mme.hub")?.native_mut::<HubState>()?;
hub.logging_in = true;
hub.logged_in = false;
}
let mut mize = get_global_mize()?;
dyn_call(mize.id_new_dots("auth.client")?, "login", ItemData::new())?;
Ok(())
})();
if let Err(err) = result {
out!("mme ppc login failed: {:?}", err);
let _ = (|| -> MizeResult<()> {
let mut hub = get_config("mme.hub")?.native_mut::<HubState>()?;
hub.logging_in = false;
hub.logged_in = false;
Ok(())
})();
}
});
}
/// Tiny async sleep without pulling tokio into the UI crate.
pub async fn sleep_ms(ms: u64) {
let (tx, rx) = mize::flume::bounded(0);
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(ms));
let _ = tx.send(());
});
let _ = rx.recv_async().await;
}

View File

@@ -10,6 +10,7 @@ name = "mize_part_mos"
crate-type = [ "cdylib", "rlib" ]
[dependencies]
cmd_lib = "2.0.1"
log = "0.4.34"
mize = { workspace = true }