Skip to main content

dear_imgui_rs/
ini_settings.rs

1//! Validated Dear ImGui `.ini` retention configuration.
2//!
3//! Configure retention before loading settings or starting the first frame.
4
5use std::fmt;
6use std::num::NonZeroU16;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12const FIRST_PACKED_YEAR: u16 = 2001;
13const LAST_PACKED_YEAR: u16 = 2127;
14
15/// A Gregorian date that Dear ImGui can round-trip through `ImGuiPackedDate`.
16///
17/// Dear ImGui stores the year in seven bits as an offset from 2000, with zero reserved for an
18/// invalid date. The safe range is therefore 2001 through 2127 inclusive.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct IniSessionDate {
21    year: u16,
22    month: u8,
23    day: u8,
24}
25
26#[cfg(feature = "serde")]
27#[derive(Serialize, Deserialize)]
28#[serde(rename = "IniSessionDate")]
29struct IniSessionDateWire {
30    year: u16,
31    month: u8,
32    day: u8,
33}
34
35#[cfg(feature = "serde")]
36impl Serialize for IniSessionDate {
37    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
38    where
39        S: serde::Serializer,
40    {
41        IniSessionDateWire {
42            year: self.year,
43            month: self.month,
44            day: self.day,
45        }
46        .serialize(serializer)
47    }
48}
49
50#[cfg(feature = "serde")]
51impl<'de> Deserialize<'de> for IniSessionDate {
52    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53    where
54        D: serde::Deserializer<'de>,
55    {
56        let wire = IniSessionDateWire::deserialize(deserializer)?;
57        Self::new(wire.year, wire.month, wire.day).map_err(serde::de::Error::custom)
58    }
59}
60
61impl IniSessionDate {
62    /// Creates a date that is representable by Dear ImGui's packed `.ini` format.
63    pub fn new(year: u16, month: u8, day: u8) -> Result<Self, IniSessionDateError> {
64        Self::from_parts(u32::from(year), u32::from(month), u32::from(day))
65    }
66
67    /// Returns the calendar year.
68    #[inline]
69    pub const fn year(self) -> u16 {
70        self.year
71    }
72
73    /// Returns the calendar month in the range 1 through 12.
74    #[inline]
75    pub const fn month(self) -> u8 {
76        self.month
77    }
78
79    /// Returns the day of month.
80    #[inline]
81    pub const fn day(self) -> u8 {
82        self.day
83    }
84
85    /// Returns the `YYYYMMDD` representation consumed by Dear ImGui.
86    #[inline]
87    pub const fn as_yyyymmdd(self) -> u32 {
88        self.year as u32 * 10_000 + self.month as u32 * 100 + self.day as u32
89    }
90
91    /// Returns the largest safe automatic-discard period for this date.
92    ///
93    /// Dear ImGui subtracts whole months without guarding the packed year against underflow.
94    #[inline]
95    pub const fn max_auto_discard_months(self) -> u16 {
96        (self.year - FIRST_PACKED_YEAR) * 12 + (self.month - 1) as u16
97    }
98
99    fn from_parts(year: u32, month: u32, day: u32) -> Result<Self, IniSessionDateError> {
100        if !(u32::from(FIRST_PACKED_YEAR)..=u32::from(LAST_PACKED_YEAR)).contains(&year) {
101            return Err(IniSessionDateError::YearOutOfRange { year });
102        }
103        if !(1..=12).contains(&month) {
104            return Err(IniSessionDateError::MonthOutOfRange { month });
105        }
106        let days_in_month = days_in_month(year, month);
107        if !(1..=days_in_month).contains(&day) {
108            return Err(IniSessionDateError::DayOutOfRange { year, month, day });
109        }
110
111        Ok(Self {
112            year: year as u16,
113            month: month as u8,
114            day: day as u8,
115        })
116    }
117}
118
119impl TryFrom<u32> for IniSessionDate {
120    type Error = IniSessionDateError;
121
122    fn try_from(value: u32) -> Result<Self, Self::Error> {
123        Self::from_parts(value / 10_000, (value / 100) % 100, value % 100)
124    }
125}
126
127impl From<IniSessionDate> for u32 {
128    #[inline]
129    fn from(value: IniSessionDate) -> Self {
130        value.as_yyyymmdd()
131    }
132}
133
134impl fmt::Display for IniSessionDate {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(
137            formatter,
138            "{:04}-{:02}-{:02}",
139            self.year, self.month, self.day
140        )
141    }
142}
143
144/// Errors returned when constructing an [`IniSessionDate`].
145#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
146pub enum IniSessionDateError {
147    /// The year cannot round-trip through Dear ImGui's packed representation.
148    #[error("year {year} is outside Dear ImGui's supported packed-date range 2001 through 2127")]
149    YearOutOfRange { year: u32 },
150    /// The month is not a Gregorian calendar month.
151    #[error("month {month} is outside the Gregorian range 1 through 12")]
152    MonthOutOfRange { month: u32 },
153    /// The day is invalid for the supplied Gregorian year and month.
154    #[error("day {day} is invalid for {year:04}-{month:02}")]
155    DayOutOfRange { year: u32, month: u32, day: u32 },
156}
157
158/// One coherent `.ini` retention configuration owned by a [`crate::Context`].
159///
160/// `AutoDiscard` always records last-used dates because Dear ImGui needs those dates to decide
161/// which settings to remove. Its month count is bounded again by the session date when applied.
162///
163/// # Example
164///
165/// ```no_run
166/// use std::num::NonZeroU16;
167/// use dear_imgui_rs::{Context, IniSessionDate, IniSettingsRetention};
168///
169/// let mut context = Context::create();
170/// let session_date = IniSessionDate::new(2026, 7, 30).expect("valid date");
171/// context
172///     .set_ini_settings_retention(IniSettingsRetention::AutoDiscard {
173///         session_date,
174///         months: NonZeroU16::new(6).unwrap(),
175///     })
176///     .expect("retention fits the packed date range");
177/// ```
178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
180pub enum IniSettingsRetention {
181    /// Keep every entry. A session date may still be supplied for platform integrations, but no
182    /// last-used date is saved and no automatic cleanup runs.
183    Disabled {
184        /// Optional application session date.
185        session_date: Option<IniSessionDate>,
186    },
187    /// Save last-used dates without automatically removing entries.
188    ///
189    /// The session date may be absent on platforms built without time functions. Keeping this
190    /// state distinct from [`IniSettingsRetention::Disabled`] preserves the configured intent and
191    /// allows the native fields to round-trip without silently disabling date recording.
192    RecordLastUsed {
193        /// The date written into supported `.ini` entries, when the platform provides one.
194        session_date: Option<IniSessionDate>,
195    },
196    /// Save last-used dates and discard entries older than the requested number of months.
197    ///
198    /// On the first settings load after enabling this policy, Dear ImGui also removes every
199    /// supported entry that has no `LastUsed` field. This includes settings written before date
200    /// recording was enabled, regardless of their actual age.
201    AutoDiscard {
202        /// The date used as the upper bound for retention.
203        session_date: IniSessionDate,
204        /// Number of whole months that Dear ImGui retains.
205        months: NonZeroU16,
206    },
207}
208
209impl IniSettingsRetention {
210    /// Creates a disabled retention configuration without a session date.
211    #[inline]
212    pub const fn disabled() -> Self {
213        Self::Disabled { session_date: None }
214    }
215
216    /// Returns the configuration's session date, when one is configured.
217    #[inline]
218    pub const fn session_date(self) -> Option<IniSessionDate> {
219        match self {
220            Self::Disabled { session_date } => session_date,
221            Self::RecordLastUsed { session_date } => session_date,
222            Self::AutoDiscard { session_date, .. } => Some(session_date),
223        }
224    }
225
226    /// Returns the automatic-discard period, when enabled.
227    #[inline]
228    pub const fn auto_discard_months(self) -> Option<NonZeroU16> {
229        match self {
230            Self::AutoDiscard { months, .. } => Some(months),
231            Self::Disabled { .. } | Self::RecordLastUsed { .. } => None,
232        }
233    }
234
235    /// Returns whether native last-used date recording is enabled.
236    ///
237    /// This reports the configured flag. [`IniSettingsRetention::RecordLastUsed`] with no session
238    /// date preserves that flag for clockless platforms, but cannot write a date until the
239    /// platform supplies one.
240    #[inline]
241    pub const fn last_used_date_recording_enabled(self) -> bool {
242        !matches!(self, Self::Disabled { .. })
243    }
244
245    pub(crate) fn validate(self) -> Result<(), IniSettingsRetentionError> {
246        if let Self::AutoDiscard {
247            session_date,
248            months,
249        } = self
250        {
251            let max_months = session_date.max_auto_discard_months();
252            if months.get() > max_months {
253                return Err(IniSettingsRetentionError::RetentionUnderflowsSessionDate {
254                    session_date,
255                    months,
256                    max_months,
257                });
258            }
259        }
260        Ok(())
261    }
262
263    pub(crate) const fn raw_parts(self) -> (i32, bool, i32) {
264        match self {
265            Self::Disabled { session_date } => (
266                match session_date {
267                    Some(date) => date.as_yyyymmdd() as i32,
268                    None => 0,
269                },
270                false,
271                0,
272            ),
273            Self::RecordLastUsed { session_date } => (
274                match session_date {
275                    Some(date) => date.as_yyyymmdd() as i32,
276                    None => 0,
277                },
278                true,
279                0,
280            ),
281            Self::AutoDiscard {
282                session_date,
283                months,
284            } => (session_date.as_yyyymmdd() as i32, true, months.get() as i32),
285        }
286    }
287
288    pub(crate) fn from_raw(
289        session_date: i32,
290        save_last_used_date: bool,
291        auto_discard_months: i32,
292    ) -> Result<Self, IniSettingsRetentionError> {
293        let session_date = match session_date {
294            0 => None,
295            raw if raw > 0 => IniSessionDate::try_from(raw as u32)
296                .map(Some)
297                .map_err(|_| IniSettingsRetentionError::InvalidNativeSessionDate { raw })?,
298            raw => return Err(IniSettingsRetentionError::InvalidNativeSessionDate { raw }),
299        };
300        let months = match auto_discard_months {
301            0 => None,
302            raw if raw > 0 => {
303                NonZeroU16::new(u16::try_from(raw).map_err(|_| {
304                    IniSettingsRetentionError::InvalidNativeAutoDiscardMonths { raw }
305                })?)
306            }
307            raw => return Err(IniSettingsRetentionError::InvalidNativeAutoDiscardMonths { raw }),
308        };
309
310        match (save_last_used_date, session_date, months) {
311            (false, session_date, None) => Ok(Self::Disabled { session_date }),
312            (true, session_date, None) => Ok(Self::RecordLastUsed { session_date }),
313            (true, Some(session_date), Some(months)) => {
314                let retention = Self::AutoDiscard {
315                    session_date,
316                    months,
317                };
318                retention.validate()?;
319                Ok(retention)
320            }
321            (false, _, Some(_)) => Err(IniSettingsRetentionError::InvalidNativeState {
322                reason: "automatic discard is enabled while last-used dates are disabled",
323            }),
324            (true, None, Some(_)) => Err(IniSettingsRetentionError::InvalidNativeState {
325                reason: "automatic discard is enabled without a session date",
326            }),
327        }
328    }
329}
330
331/// Errors returned while reading or applying [`IniSettingsRetention`].
332#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
333pub enum IniSettingsRetentionError {
334    /// The requested period would underflow Dear ImGui's packed date year.
335    #[error(
336        "automatic discard period of {months} months exceeds the {max_months}-month limit for session date {session_date}"
337    )]
338    RetentionUnderflowsSessionDate {
339        /// Date used as the retention upper bound.
340        session_date: IniSessionDate,
341        /// Requested automatic-discard period.
342        months: NonZeroU16,
343        /// Largest supported period for `session_date`.
344        max_months: u16,
345    },
346    /// The session date is immutable after Dear ImGui has started its first frame.
347    #[error("`.ini` retention must be configured before the first Dear ImGui frame")]
348    LockedAfterFirstFrame,
349    /// Automatic cleanup cannot be changed after settings have already been loaded.
350    #[error("`.ini` retention must be configured before loading Dear ImGui settings")]
351    LockedAfterSettingsLoad,
352    /// Native platform state contains a date outside the safe packed range.
353    #[error("native Platform_SessionDate {raw} is not a valid Dear ImGui packed date")]
354    InvalidNativeSessionDate { raw: i32 },
355    /// Native IO state contains an unsupported automatic-discard period.
356    #[error("native ConfigIniSettingsAutoDiscardMonths {raw} is invalid")]
357    InvalidNativeAutoDiscardMonths { raw: i32 },
358    /// Native settings fields encode a combination that cannot be represented safely.
359    #[error("native `.ini` retention state is invalid: {reason}")]
360    InvalidNativeState {
361        /// Explanation of the inconsistent native fields.
362        reason: &'static str,
363    },
364}
365
366fn days_in_month(year: u32, month: u32) -> u32 {
367    match month {
368        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
369        4 | 6 | 9 | 11 => 30,
370        2 if is_leap_year(year) => 29,
371        2 => 28,
372        _ => unreachable!("IniSessionDate validates the month before asking for its day count"),
373    }
374}
375
376const fn is_leap_year(year: u32) -> bool {
377    year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100))
378}
379
380#[cfg(all(test, feature = "serde"))]
381mod serde_tests {
382    use super::*;
383    use serde_test::{Token, assert_tokens};
384
385    #[test]
386    fn session_date_serde_uses_one_stable_wire_shape() {
387        let date = IniSessionDate::new(2024, 2, 29).unwrap();
388        assert_tokens(
389            &date,
390            &[
391                Token::Struct {
392                    name: "IniSessionDate",
393                    len: 3,
394                },
395                Token::Str("year"),
396                Token::U16(2024),
397                Token::Str("month"),
398                Token::U8(2),
399                Token::Str("day"),
400                Token::U8(29),
401                Token::StructEnd,
402            ],
403        );
404    }
405
406    #[test]
407    fn session_date_deserialization_preserves_validation() {
408        let valid = r#"{"year":2024,"month":2,"day":29}"#;
409        assert_eq!(
410            serde_json::from_str::<IniSessionDate>(valid).unwrap(),
411            IniSessionDate::new(2024, 2, 29).unwrap()
412        );
413
414        for invalid in [
415            r#"{"year":2000,"month":1,"day":1}"#,
416            r#"{"year":2128,"month":1,"day":1}"#,
417            r#"{"year":2026,"month":13,"day":1}"#,
418            r#"{"year":2100,"month":2,"day":29}"#,
419        ] {
420            assert!(serde_json::from_str::<IniSessionDate>(invalid).is_err());
421        }
422    }
423
424    #[test]
425    fn retention_deserialization_cannot_bypass_session_date_validation() {
426        for invalid in [
427            r#"{"Disabled":{"session_date":{"year":2000,"month":1,"day":1}}}"#,
428            r#"{"RecordLastUsed":{"session_date":{"year":2128,"month":1,"day":1}}}"#,
429            r#"{"AutoDiscard":{"session_date":{"year":2100,"month":2,"day":29},"months":1}}"#,
430        ] {
431            assert!(serde_json::from_str::<IniSettingsRetention>(invalid).is_err());
432        }
433    }
434}