Skip to content

Commit 79edae3

Browse files
authored
Merge pull request #783 from fpdotmonkey/export-range-suffix
Support `#[export(range = (radians_as_degrees, suffix="XX"))]`
2 parents c41fc06 + d1a4fee commit 79edae3

File tree

7 files changed

+146
-30
lines changed

7 files changed

+146
-30
lines changed

godot-core/src/deprecated.rs

+7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ pub const fn base_attribute() {}
2020
More information on https://github.com/godot-rust/gdext/pull/702."]
2121
pub const fn feature_custom_godot() {}
2222

23+
#[cfg_attr(
24+
since_api = "4.2",
25+
deprecated = "Use #[export(range = (radians_as_degrees))] and not #[export(range = (radians))]. \n\
26+
More information on https://github.com/godotengine/godot/pull/82195."
27+
)]
28+
pub const fn export_range_radians() {}
29+
2330
#[macro_export]
2431
macro_rules! emit_deprecated_warning {
2532
($warning_fn:ident) => {

godot-core/src/meta/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ impl PropertyInfo {
117117
/// false,
118118
/// false,
119119
/// false,
120+
/// Some("mm".to_string()),
120121
/// ));
121122
/// ```
122123
pub fn with_hint_info(self, hint_info: PropertyHintInfo) -> Self {

godot-core/src/registry/property.rs

+36-9
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ pub mod export_info_functions {
168168
}
169169

170170
// We want this to match the options available on `@export_range(..)`
171+
/// Mark an exported numerical value to use the editor's range UI.
172+
///
173+
/// You'll never call this function itself, but will instead use the macro `#[export(range=(...))]`, as below. The syntax is
174+
/// very similar to Godot's [`@export_range`](https://docs.godotengine.org/en/stable/classes/class_%40gdscript.html#class-gdscript-annotation-export-range).
175+
/// `min`, `max`, and `step` are `f32` positional arguments, with `step` being optional and defaulting to `1.0`. The rest of
176+
/// the arguments can be written in any order. The symbols of type `bool` just need to have those symbols written, and those of type `Option<T>` will be written as `{KEY}={VALUE}`, e.g. `suffix="px"`.
177+
///
178+
/// ```
179+
/// # use godot::prelude::*;
180+
/// #[derive(GodotClass)]
181+
/// #[class(init, base=Node)]
182+
/// struct MyClassWithRangedValues {
183+
/// #[export(range=(0.0, 400.0, 1.0, or_greater, suffix="px"))]
184+
/// icon_width: i32,
185+
/// #[export(range=(-180.0, 180.0, degrees))]
186+
/// angle: f32,
187+
/// }
188+
/// ```
171189
#[allow(clippy::too_many_arguments)]
172190
pub fn export_range(
173191
min: f64,
@@ -176,23 +194,32 @@ pub mod export_info_functions {
176194
or_greater: bool,
177195
or_less: bool,
178196
exp: bool,
179-
radians: bool,
197+
radians_as_degrees: bool,
180198
degrees: bool,
181199
hide_slider: bool,
200+
suffix: Option<String>,
182201
) -> PropertyHintInfo {
183202
let hint_beginning = if let Some(step) = step {
184203
format!("{min},{max},{step}")
185204
} else {
186205
format!("{min},{max}")
187206
};
188-
let rest =
189-
comma_separate_boolean_idents!(or_greater, or_less, exp, radians, degrees, hide_slider);
190-
191-
let hint_string = if rest.is_empty() {
192-
hint_beginning
193-
} else {
194-
format!("{hint_beginning},{rest}")
195-
};
207+
let rest = comma_separate_boolean_idents!(
208+
or_greater,
209+
or_less,
210+
exp,
211+
radians_as_degrees,
212+
degrees,
213+
hide_slider
214+
);
215+
216+
let mut hint_string = hint_beginning;
217+
if !rest.is_empty() {
218+
hint_string.push_str(&format!(",{rest}"));
219+
}
220+
if let Some(suffix) = suffix {
221+
hint_string.push_str(&format!(",suffix:{suffix}"));
222+
}
196223

197224
PropertyHintInfo {
198225
hint: PropertyHint::RANGE,

godot-macros/src/class/data_models/field_export.rs

+57-18
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
use proc_macro2::{Ident, TokenStream};
99
use quote::quote;
10-
use std::collections::HashSet;
10+
use std::collections::{HashMap, HashSet};
1111

1212
use crate::util::{KvParser, ListParser};
1313
use crate::ParseResult;
@@ -35,9 +35,11 @@ pub enum FieldExport {
3535
or_greater: bool,
3636
or_less: bool,
3737
exp: bool,
38+
radians_as_degrees: bool,
3839
radians: bool,
3940
degrees: bool,
4041
hide_slider: bool,
42+
suffix: Option<TokenStream>,
4143
},
4244

4345
/// ### GDScript annotations
@@ -255,20 +257,21 @@ impl FieldExport {
255257
}
256258

257259
fn new_range_list(mut parser: ListParser) -> ParseResult<FieldExport> {
258-
const ALLOWED_OPTIONS: [&str; 6] = [
260+
const FLAG_OPTIONS: [&str; 7] = [
259261
"or_greater",
260262
"or_less",
261263
"exp",
262-
"radians",
264+
"radians_as_degrees",
265+
"radians", // godot deprecated this key for 4.2 in favor of radians_as_degrees
263266
"degrees",
264267
"hide_slider",
265268
];
269+
const KV_OPTIONS: [&str; 1] = ["suffix"];
266270

267271
let min = parser.next_expr()?;
268272
let max = parser.next_expr()?;
269-
// If there is a next element and it is not an identifier,
270-
// we take its tokens directly.
271-
let step = if parser.peek().is_some_and(|kv| kv.as_ident().is_err()) {
273+
// If there is a next element and it is a literal, we take its tokens directly.
274+
let step = if parser.peek().is_some_and(|kv| kv.as_literal().is_ok()) {
272275
let value = parser
273276
.next_expr()
274277
.expect("already guaranteed there was a TokenTree to parse");
@@ -277,10 +280,21 @@ impl FieldExport {
277280
quote! { None }
278281
};
279282

280-
let mut options = HashSet::new();
283+
let mut flags = HashSet::<String>::new();
284+
let mut kvs = HashMap::<String, TokenStream>::new();
281285

282-
while let Some(option) = parser.next_allowed_ident(&ALLOWED_OPTIONS[..])? {
283-
options.insert(option.to_string());
286+
loop {
287+
let key_maybe_value =
288+
parser.next_allowed_key_optional_value(&FLAG_OPTIONS, &KV_OPTIONS)?;
289+
match key_maybe_value {
290+
Some((option, None)) => {
291+
flags.insert(option.to_string());
292+
}
293+
Some((option, Some(value))) => {
294+
kvs.insert(option.to_string(), value.expr()?);
295+
}
296+
None => break,
297+
}
284298
}
285299

286300
parser.finish()?;
@@ -289,12 +303,14 @@ impl FieldExport {
289303
min,
290304
max,
291305
step,
292-
or_greater: options.contains("or_greater"),
293-
or_less: options.contains("or_less"),
294-
exp: options.contains("exp"),
295-
radians: options.contains("radians"),
296-
degrees: options.contains("degrees"),
297-
hide_slider: options.contains("hide_slider"),
306+
or_greater: flags.contains("or_greater"),
307+
or_less: flags.contains("or_less"),
308+
exp: flags.contains("exp"),
309+
radians_as_degrees: flags.contains("radians_as_degrees"),
310+
radians: flags.contains("radians"),
311+
degrees: flags.contains("degrees"),
312+
hide_slider: flags.contains("hide_slider"),
313+
suffix: kvs.get("suffix").cloned(),
298314
})
299315
}
300316

@@ -370,12 +386,35 @@ impl FieldExport {
370386
or_greater,
371387
or_less,
372388
exp,
389+
radians_as_degrees,
373390
radians,
374391
degrees,
375392
hide_slider,
376-
} => quote_export_func! {
377-
export_range(#min, #max, #step, #or_greater, #or_less, #exp, #radians, #degrees, #hide_slider)
378-
},
393+
suffix,
394+
} => {
395+
let suffix = if suffix.is_some() {
396+
quote! { Some(#suffix.to_string()) }
397+
} else {
398+
quote! { None }
399+
};
400+
let export_func = quote_export_func! {
401+
export_range(#min, #max, #step, #or_greater, #or_less, #exp, #radians_as_degrees || #radians, #degrees, #hide_slider, #suffix)
402+
}?;
403+
let deprecation_warning = if *radians {
404+
// For some reason, rustfmt formatting like this. Probably a bug.
405+
// See https://github.com/godot-rust/gdext/pull/783#discussion_r1669105958 and
406+
// https://github.com/rust-lang/rustfmt/issues/6233
407+
quote! {
408+
#export_func;
409+
::godot::__deprecated::emit_deprecated_warning!(export_range_radians);
410+
}
411+
} else {
412+
quote! { #export_func }
413+
};
414+
Some(quote! {
415+
#deprecation_warning
416+
})
417+
}
379418

380419
FieldExport::Enum { variants } => {
381420
let variants = variants.iter().map(ValueWithKey::to_tuple_expression);

godot-macros/src/util/kv_parser.rs

+12-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
use crate::ParseResult;
9-
use proc_macro2::{Delimiter, Ident, Spacing, Span, TokenStream, TokenTree};
9+
use proc_macro2::{Delimiter, Ident, Literal, Spacing, Span, TokenStream, TokenTree};
1010
use quote::ToTokens;
1111
use std::collections::HashMap;
1212

@@ -290,6 +290,17 @@ impl KvValue {
290290
other => bail!(other, "expected identifier"),
291291
}
292292
}
293+
294+
pub fn as_literal(&self) -> ParseResult<Literal> {
295+
if self.tokens.len() > 1 {
296+
return bail!(&self.tokens[1], "expected a single literal");
297+
}
298+
299+
match &self.tokens[0] {
300+
TokenTree::Literal(literal) => Ok(literal.clone()),
301+
other => bail!(other, "expected literal"),
302+
}
303+
}
293304
}
294305

295306
struct ParserState<'a> {

godot-macros/src/util/list_parser.rs

+30
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,36 @@ impl ListParser {
198198
}
199199
}
200200

201+
/// Like `next_key_optional_value`, but checks if input flags and keys are in the allowed sets and `Err`s if not.
202+
///
203+
/// If an allowed flag appears as a key or an allowed key as a flag, that will also `Err` with a helpful message.
204+
pub(crate) fn next_allowed_key_optional_value(
205+
&mut self,
206+
allowed_flag_keys: &[&str],
207+
allowed_kv_keys: &[&str],
208+
) -> ParseResult<Option<(Ident, Option<KvValue>)>> {
209+
let allowed_keys = || {
210+
let allowed_flag_keys = allowed_flag_keys.join(",");
211+
let allowed_kv_keys = allowed_kv_keys.join(",");
212+
[allowed_flag_keys, allowed_kv_keys].join(",")
213+
};
214+
match self.next_key_optional_value()? {
215+
Some((key, None)) if !allowed_flag_keys.contains(&key.to_string().as_str()) => {
216+
if allowed_kv_keys.contains(&key.to_string().as_str()) {
217+
return bail!(key, "`{key}` requires a value `{key} = VALUE`");
218+
}
219+
bail!(key, "expected one of \"{}\"", allowed_keys())
220+
}
221+
Some((key, Some(_))) if !allowed_kv_keys.contains(&key.to_string().as_str()) => {
222+
if allowed_flag_keys.contains(&key.to_string().as_str()) {
223+
return bail!(key, "key `{key}` mustn't have a value");
224+
}
225+
bail!(key, "expected one of \"{}\"", allowed_keys())
226+
}
227+
key_maybe_value => Ok(key_maybe_value),
228+
}
229+
}
230+
201231
/// Ensure all values have been consumed.
202232
pub fn finish(&mut self) -> ParseResult<()> {
203233
if let Some(kv) = self.pop_next() {

itest/rust/src/object_tests/property_test.rs

+3-2
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,11 @@ struct CheckAllExports {
239239
#[export]
240240
normal: GString,
241241

242-
#[export(range = (0.0, 10.0, or_greater, or_less, exp, radians, hide_slider))]
242+
// `suffix = "px"` should be in the third slot to test that key-value pairs in that position no longer error.
243+
#[export(range = (0.0, 10.0, suffix = "px", or_greater, or_less, exp, degrees, hide_slider))]
243244
range_exported: f64,
244245

245-
#[export(range = (0.0, 10.0, 0.2, or_greater, or_less, exp, radians, hide_slider))]
246+
#[export(range = (0.0, 10.0, 0.2, or_greater, or_less, exp, radians_as_degrees, hide_slider))]
246247
range_exported_with_step: f64,
247248

248249
#[export(enum = (A = 10, B, C, D = 20))]

0 commit comments

Comments
 (0)