diff --git a/asr-derive/Cargo.toml b/asr-derive/Cargo.toml index 78fd17d..4493e44 100644 --- a/asr-derive/Cargo.toml +++ b/asr-derive/Cargo.toml @@ -6,9 +6,10 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -syn = "2.0.1" -quote = "1.0.18" heck = "0.4.0" +proc-macro2 = "1.0.70" +quote = "1.0.18" +syn = { version = "2.0.41", features = ["full"] } [lib] proc-macro = true diff --git a/asr-derive/src/lib.rs b/asr-derive/src/lib.rs index ef22c5e..78485ed 100644 --- a/asr-derive/src/lib.rs +++ b/asr-derive/src/lib.rs @@ -2,7 +2,8 @@ use heck::ToTitleCase; use proc_macro::TokenStream; use quote::{quote, quote_spanned}; use syn::{ - spanned::Spanned, Data, DataEnum, DataStruct, DeriveInput, Expr, ExprLit, Ident, Lit, Meta, + parse::Parse, punctuated::Punctuated, spanned::Spanned, token::Comma, Data, DataEnum, + DataStruct, DeriveInput, Error, Expr, ExprLit, Ident, Lit, Meta, MetaList, Result, }; // FIXME: https://github.com/rust-lang/rust/issues/117463 @@ -68,6 +69,26 @@ use syn::{ /// # } /// ``` /// +/// A file select filter can be specified like so: +/// +/// ```no_run +/// # struct Settings { +/// #[filter( +/// // File name patterns with names +/// ("PNG images", "*.png"), +/// // Multiple patterns separated by space +/// ("Rust files", "*.rs Cargo.*"), +/// // The name is optional +/// (_, "*.md"), +/// // MIME types +/// "text/plain", +/// // Mime types with wildcards +/// "image/*", +/// )] +/// text_file: FileSelect, +/// # } +/// ``` +/// /// # Choices /// /// You can derive `Gui` for an enum to create a choice widget. You can mark one @@ -111,18 +132,26 @@ use syn::{ /// use_game_time: Pair, /// } /// ``` -#[proc_macro_derive(Gui, attributes(default, heading_level))] +#[proc_macro_derive(Gui, attributes(default, heading_level, filter))] pub fn settings_macro(input: TokenStream) -> TokenStream { let ast: DeriveInput = syn::parse(input).unwrap(); - match ast.data { + let res = match ast.data { Data::Struct(s) => generate_struct_settings(ast.ident, s), Data::Enum(e) => generate_enum_settings(ast.ident, e), - _ => panic!("Only structs and enums are supported"), + _ => Err(Error::new( + ast.span(), + "Only structs and enums are supported.", + )), + }; + + match res { + Ok(v) => v, + Err(e) => e.into_compile_error().into(), } } -fn generate_struct_settings(struct_name: Ident, struct_data: DataStruct) -> TokenStream { +fn generate_struct_settings(struct_name: Ident, struct_data: DataStruct) -> Result { let mut field_names = Vec::new(); let mut field_name_strings = Vec::new(); let mut field_descs = Vec::new(); @@ -188,26 +217,33 @@ fn generate_struct_settings(struct_name: Ident, struct_data: DataStruct) -> Toke let args = field .attrs .iter() - .filter_map(|x| { - let Meta::NameValue(nv) = &x.meta else { - return None; - }; - let span = nv.span(); - if nv.path.is_ident("default") { - let value = &nv.value; - Some(quote_spanned! { span => args.default = #value; }) - } else if nv.path.is_ident("heading_level") { - let value = &nv.value; - Some(quote_spanned! { span => args.heading_level = #value; }) - } else { - None + .filter_map(|x| match &x.meta { + Meta::NameValue(nv) => { + let span = nv.span(); + if nv.path.is_ident("default") { + let value = &nv.value; + Some(Ok(quote_spanned! { span => args.default = #value; })) + } else if nv.path.is_ident("heading_level") { + let value = &nv.value; + Some(Ok(quote_spanned! { span => args.heading_level = #value; })) + } else { + None + } } + Meta::List(list) => { + if list.path.is_ident("filter") { + Some(parse_filter(list)) + } else { + None + } + } + _ => None, }) - .collect::>(); + .collect::>>()?; args_init.push(quote! { #(#args)* }); } - quote! { + Ok(quote! { impl asr::settings::Gui for #struct_name { fn register() -> Self { Self { @@ -234,10 +270,10 @@ fn generate_struct_settings(struct_name: Ident, struct_data: DataStruct) -> Toke } } } - .into() + .into()) } -fn generate_enum_settings(enum_name: Ident, enum_data: DataEnum) -> TokenStream { +fn generate_enum_settings(enum_name: Ident, enum_data: DataEnum) -> Result { let mut variant_names = Vec::new(); let mut variant_name_strings = Vec::new(); let mut variant_descs = Vec::new(); @@ -318,7 +354,7 @@ fn generate_enum_settings(enum_name: Ident, enum_data: DataEnum) -> TokenStream .max() .unwrap_or_default(); - quote! { + Ok(quote! { impl asr::settings::gui::Widget for #enum_name { type Args = (); @@ -345,7 +381,140 @@ fn generate_enum_settings(enum_name: Ident, enum_data: DataEnum) -> TokenStream } } } - .into() + .into()) +} + +fn parse_filter(list: &MetaList) -> Result { + let span = list.span(); + let mut filters = Vec::new(); + + struct FilterArgs { + exprs: Punctuated, + } + + impl Parse for FilterArgs { + fn parse(input: syn::parse::ParseStream) -> Result { + Ok(FilterArgs { + exprs: Punctuated::parse_terminated(input)?, + }) + } + } + + let args: FilterArgs = syn::parse(list.tokens.clone().into())?; + + for expr in args.exprs { + match expr { + Expr::Tuple(tuple) => { + let mut iter = tuple.elems.iter(); + let (Some(first), Some(second), None) = (iter.next(), iter.next(), iter.next()) + else { + return Err(Error::new( + tuple.span(), + "Expected a tuple of two elements.", + )); + }; + + let has_description = match first { + Expr::Lit(ExprLit { + lit: Lit::Str(lit), .. + }) => { + let value = lit.value(); + if value.is_empty() { + return Err(Error::new( + lit.span(), + "The description should not be empty.", + )); + } + if value.trim().len() != value.len() { + return Err(Error::new( + lit.span(), + "The description should not contain leading or trailing whitespace.", + )); + } + true + } + Expr::Infer(_) => false, + _ => { + return Err(Error::new( + first.span(), + "Expected a string literal or an underscore.", + )) + } + }; + + match second { + Expr::Lit(ExprLit { + lit: Lit::Str(lit), .. + }) => { + let value = lit.value(); + if value.is_empty() { + return Err(Error::new(lit.span(), "The pattern must not be empty.")); + } + if value.trim().len() != value.len() { + return Err(Error::new( + lit.span(), + "The pattern must not contain leading or trailing whitespace.", + )); + } + if value.contains(" ") { + return Err(Error::new( + lit.span(), + "The pattern must not contain double whitespace.", + )); + } + if value.contains("*.*") { + return Err(Error::new( + lit.span(), + "The pattern handling all files doesn't need to be specified.", + )); + } + } + _ => return Err(Error::new(second.span(), "Expected a string literal.")), + } + + filters.push(if has_description { + quote! { asr::settings::gui::FileSelectFilter::NamePattern(Some(#first), #second) } + } else { + quote! { asr::settings::gui::FileSelectFilter::NamePattern(None, #second) } + }); + } + Expr::Lit(lit) => match lit { + ExprLit { + lit: Lit::Str(lit), .. + } => { + let value = lit.value(); + if value.bytes().filter(|b| *b == b'/').count() != 1 { + return Err(Error::new( + lit.span(), + "The MIME type has to contain a single `/`.", + )); + } + if value.trim().len() != value.len() { + return Err(Error::new( + lit.span(), + "The MIME type must not contain leading or trailing whitespace.", + )); + } + if value == "*/*" { + return Err(Error::new( + lit.span(), + "The MIME type handling all files doesn't need to be specified.", + )); + } + filters.push(quote! { asr::settings::gui::FileSelectFilter::MimeType(#lit) }) + } + _ => return Err(Error::new(lit.span(), "Expected a string literal.")), + }, + _ => { + return Err(Error::new( + expr.span(), + "Expected a tuple or a string literal.", + )) + } + } + } + + Ok(quote_spanned! { span => args.filter = &[#(#filters),*]; }) } /// Generates an implementation of the `FromEndian` trait for a struct. This diff --git a/src/runtime/settings/gui.rs b/src/runtime/settings/gui.rs index c13acf2..faaecb6 100644 --- a/src/runtime/settings/gui.rs +++ b/src/runtime/settings/gui.rs @@ -1,6 +1,8 @@ //! This module allows you to add settings widgets to the settings GUI that the //! user can modify. +use core::mem; + #[cfg(feature = "derive")] pub use asr_derive::Gui; @@ -89,6 +91,81 @@ pub fn add_choice_option(key: &str, option_key: &str, option_description: &str) } } +/// Adds a new file select setting that the user can modify. This allows the +/// user to choose a file from the file system. The key is used to store the +/// path of the file in the settings map and needs to be unique across all types +/// of settings. The description is what's shown to the user. The path is a path +/// that is accessible through the WASI file system, so a Windows path of +/// `C:\foo\bar.exe` would be stored as `/mnt/c/foo/bar.exe`. +#[inline] +pub fn add_file_select(key: &str, description: &str) { + // SAFETY: We provide valid pointers and lengths to key and description. + // They are also guaranteed to be valid UTF-8 strings. + unsafe { + sys::user_settings_add_file_select( + key.as_ptr(), + key.len(), + description.as_ptr(), + description.len(), + ) + } +} + +/// Adds a filter to a file select setting. The key needs to match the key of +/// the file select setting that it's supposed to be added to. The description +/// is what's shown to the user for the specific filter. The pattern is a [glob +/// pattern](https://en.wikipedia.org/wiki/Glob_(programming)) that is used to +/// filter the files. The pattern generally only supports `*` wildcards, not `?` +/// or brackets. This may however differ between frontends. Additionally `;` +/// can't be used in Windows's native file dialog if it's part of the pattern. +/// Multiple patterns may be specified by separating them with ASCII space +/// characters. There are operating systems where glob patterns are not +/// supported. A best effort lookup of the fitting MIME type may be used by a +/// frontend on those operating systems instead. +#[inline] +pub fn add_file_select_name_filter(key: &str, description: Option<&str>, pattern: &str) { + // SAFETY: We provide valid pointers and lengths to key, description and + // pattern. They are also guaranteed to be valid UTF-8 strings. The + // description is provided as a null pointer in case it is `None` to + // indicate that no description is provided. + unsafe { + let (desc_ptr, desc_len) = match description { + Some(desc) => (desc.as_ptr(), desc.len()), + None => (core::ptr::null(), 0), + }; + sys::user_settings_add_file_select_name_filter( + key.as_ptr(), + key.len(), + desc_ptr, + desc_len, + pattern.as_ptr(), + pattern.len(), + ) + } +} + +/// Adds a filter to a file select setting. The key needs to match the key +/// of the file select setting that it's supposed to be added to. The MIME +/// type is what's used to filter the files. Most operating systems do not +/// support MIME types, but the frontends are encouraged to look up the file +/// extensions that are associated with the MIME type and use those as a +/// filter in those cases. You may also use wildcards as part of the MIME +/// types such as `image/*`. The support likely also varies between +/// frontends however. +#[inline] +pub fn add_file_select_mime_filter(key: &str, mime_type: &str) { + // SAFETY: We provide valid pointers and lengths to key and mime_type. + // They are also guaranteed to be valid UTF-8 strings. + unsafe { + sys::user_settings_add_file_select_mime_filter( + key.as_ptr(), + key.len(), + mime_type.as_ptr(), + mime_type.len(), + ) + } +} + /// Adds a tooltip to a setting widget based on its key. A tooltip is useful for /// explaining the purpose of a setting to the user. #[inline] @@ -187,19 +264,96 @@ impl Widget for Title { fn update_from(&mut self, _settings_map: &Map, _key: &str, _args: Self::Args) {} } -impl Widget for Pair { +impl Widget for Pair { type Args = T::Args; fn register(key: &str, description: &str, args: Self::Args) -> Self { let value = T::register(key, description, args); Pair { - old: value, + old: value.clone(), current: value, } } fn update_from(&mut self, settings_map: &Map, key: &str, args: Self::Args) { - self.old = self.current; + mem::swap(&mut self.old, &mut self.current); self.current.update_from(settings_map, key, args); } } + +/// A file select widget. +/// +/// # Example +/// +/// ```ignore +/// # struct Settings { +/// #[filter( +/// // File name patterns with names +/// ("PNG images", "*.png"), +/// // Multiple patterns separated by space +/// ("Rust files", "*.rs Cargo.*"), +/// // The name is optional +/// (_, "*.md"), +/// // MIME types +/// "text/plain", +/// // MIME types with wildcards +/// "image/*", +/// )] +/// text_file: FileSelect, +/// # } +/// ``` +#[derive(Clone, PartialEq, Eq)] +#[cfg(feature = "alloc")] +pub struct FileSelect { + /// The file path, as accessible through the WASI file system, + /// so a Windows path of `C:\foo\bar.exe` would be represented + /// as `/mnt/c/foo/bar.exe`. + pub path: alloc::string::String, +} + +/// The arguments that are needed to register a file selection widget. +/// This is an internal type that you don't need to worry about. +#[cfg(feature = "alloc")] +#[doc(hidden)] +#[derive(Default)] +#[non_exhaustive] +pub struct FileSelectArgs { + pub filter: &'static [FileSelectFilter], +} + +#[cfg(feature = "alloc")] +#[doc(hidden)] +pub enum FileSelectFilter { + NamePattern(Option<&'static str>, &'static str), + MimeType(&'static str), +} + +#[cfg(feature = "alloc")] +impl Widget for FileSelect { + type Args = FileSelectArgs; + + fn register(key: &str, description: &str, args: Self::Args) -> Self { + add_file_select(key, description); + for filter in args.filter { + match filter { + FileSelectFilter::NamePattern(desc, pattern) => { + add_file_select_name_filter(key, *desc, pattern) + } + FileSelectFilter::MimeType(mime) => add_file_select_mime_filter(key, mime), + } + } + let mut this = FileSelect { + path: alloc::string::String::new(), + }; + this.update_from(&Map::load(), key, args); + this + } + + fn update_from(&mut self, settings_map: &Map, key: &str, _args: Self::Args) { + if let Some(value) = settings_map.get(key) { + value.get_string_into(&mut self.path); + } else { + self.path.clear(); + } + } +} diff --git a/src/runtime/settings/value.rs b/src/runtime/settings/value.rs index 9077016..1d3ff37 100644 --- a/src/runtime/settings/value.rs +++ b/src/runtime/settings/value.rs @@ -224,6 +224,36 @@ impl Value { } } + /// Writes the value as a [`String`](alloc::string::String) into the + /// provided buffer if it is a string. Returns [`true`] if the value is a + /// string. Returns [`false`] if the value is not a string. The buffer is + /// always cleared before writing into it. + #[cfg(feature = "alloc")] + #[inline] + pub fn get_string_into(&self, buf: &mut alloc::string::String) -> bool { + // SAFETY: The handle is valid. We provide a null pointer and 0 as the + // length to get the length of the string. If it failed and the length + // is 0, then that indicates that the value is not a string and we + // return false. Otherwise we allocate a buffer of the returned length + // and call the function again with the buffer. This should now always + // succeed and we can return the string. The function also guarantees + // that the buffer is valid UTF-8. + unsafe { + let buf = buf.as_mut_vec(); + buf.clear(); + let mut len = 0; + let success = sys::setting_value_get_string(self.0, core::ptr::null_mut(), &mut len); + if len == 0 && !success { + return false; + } + buf.reserve(len); + let success = sys::setting_value_get_string(self.0, buf.as_mut_ptr(), &mut len); + assert!(success); + buf.set_len(len); + true + } + } + /// Returns the value as an [`ArrayString`] if it is a string. Returns an /// error if the string is too long. The constant `N` determines the maximum /// length of the string in bytes. diff --git a/src/runtime/sys.rs b/src/runtime/sys.rs index f40988e..4fef9ff 100644 --- a/src/runtime/sys.rs +++ b/src/runtime/sys.rs @@ -260,6 +260,58 @@ extern "C" { option_description_ptr: *const u8, option_description_len: usize, ) -> bool; + /// Adds a new file select setting that the user can modify. This allows the + /// user to choose a file from the file system. The key is used to store the + /// path of the file in the settings map and needs to be unique across all + /// types of settings. The description is what's shown to the user. The + /// pointers need to point to valid UTF-8 encoded text with the respective + /// given length. The path is a path that is accessible through the WASI + /// file system, so a Windows path of `C:\foo\bar.exe` would be stored as + /// `/mnt/c/foo/bar.exe`. + pub fn user_settings_add_file_select( + key_ptr: *const u8, + key_len: usize, + description_ptr: *const u8, + description_len: usize, + ); + /// Adds a filter to a file select setting. The key needs to match the key + /// of the file select setting that it's supposed to be added to. The + /// description is what's shown to the user for the specific filter. The + /// description is optional. You may provide a null pointer if you don't + /// want to specify a description. The pattern is a [glob + /// pattern](https://en.wikipedia.org/wiki/Glob_(programming)) that is used + /// to filter the files. The pattern generally only supports `*` wildcards, + /// not `?` or brackets. This may however differ between frontends. + /// Additionally `;` can't be used in Windows's native file dialog if it's + /// part of the pattern. Multiple patterns may be specified by separating + /// them with ASCII space characters. There are operating systems where glob + /// patterns are not supported. A best effort lookup of the fitting MIME + /// type may be used by a frontend on those operating systems instead. The + /// pointers need to point to valid UTF-8 encoded text with the respective + /// given length. + pub fn user_settings_add_file_select_name_filter( + key_ptr: *const u8, + key_len: usize, + description_ptr: *const u8, + description_len: usize, + pattern_ptr: *const u8, + pattern_len: usize, + ); + /// Adds a filter to a file select setting. The key needs to match the key + /// of the file select setting that it's supposed to be added to. The MIME + /// type is what's used to filter the files. Most operating systems do not + /// support MIME types, but the frontends are encouraged to look up the file + /// extensions that are associated with the MIME type and use those as a + /// filter in those cases. You may also use wildcards as part of the MIME + /// types such as `image/*`. The support likely also varies between + /// frontends however. The pointers need to point to valid UTF-8 encoded + /// text with the respective given length. + pub fn user_settings_add_file_select_mime_filter( + key_ptr: *const u8, + key_len: usize, + mime_type_ptr: *const u8, + mime_type_len: usize, + ); /// Adds a tooltip to a setting based on its key. A tooltip is useful for /// explaining the purpose of a setting to the user. The pointers need to /// point to valid UTF-8 encoded text with the respective given length.