kas_theme/
colors.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License in the LICENSE-APACHE file or at:
4//     https://www.apache.org/licenses/LICENSE-2.0
5
6//! Colour schemes
7
8use kas::draw::color::{Rgba, Rgba8Srgb};
9use kas::event::EventState;
10use kas::theme::Background;
11use kas::WidgetId;
12use std::str::FromStr;
13
14const MULT_DEPRESS: f32 = 0.75;
15const MULT_HIGHLIGHT: f32 = 1.25;
16const MIN_HIGHLIGHT: f32 = 0.2;
17
18bitflags::bitflags! {
19    /// Input and highlighting state of a widget
20    ///
21    /// This is mostly an implementation detail used to control the appearance
22    /// of theme-drawn elements.
23    #[derive(Default)]
24    pub struct InputState: u8 {
25        /// Disabled widgets are not responsive to input and usually drawn in grey.
26        ///
27        /// All other states should be ignored when disabled.
28        const DISABLED = 1 << 0;
29        /// "Hover" is true if the mouse is over this element
30        const HOVER = 1 << 2;
31        /// Elements such as buttons, handles and menu entries may be depressed
32        /// (visually pushed) by a click or touch event or an accelerator key.
33        /// This is often visualised by a darker colour and/or by offsetting
34        /// graphics. The `hover` state should be ignored when depressed.
35        const DEPRESS = 1 << 3;
36        /// Keyboard navigation of UIs moves a "focus" from widget to widget.
37        const NAV_FOCUS = 1 << 4;
38        /// "Character focus" implies this widget is ready to receive text input
39        /// (e.g. typing into an input field).
40        const CHAR_FOCUS = 1 << 5;
41        /// "Selection focus" allows things such as text to be selected. Selection
42        /// focus implies that the widget also has character focus.
43        const SEL_FOCUS = 1 << 6;
44    }
45}
46
47impl InputState {
48    /// Construct, setting all components
49    pub fn new_all(ev: &EventState, id: &WidgetId) -> Self {
50        let mut state = Self::new_except_depress(ev, id);
51        if ev.is_depressed(id) {
52            state |= InputState::DEPRESS;
53        }
54        state
55    }
56
57    /// Construct, setting all but depress status
58    pub fn new_except_depress(ev: &EventState, id: &WidgetId) -> Self {
59        let (char_focus, sel_focus) = ev.has_char_focus(id);
60        let mut state = InputState::empty();
61        if ev.is_disabled(id) {
62            state |= InputState::DISABLED;
63        }
64        if ev.is_hovered(id) {
65            state |= InputState::HOVER;
66        }
67        if ev.has_nav_focus(id) {
68            state |= InputState::NAV_FOCUS;
69        }
70        if char_focus {
71            state |= InputState::CHAR_FOCUS;
72        }
73        if sel_focus {
74            state |= InputState::SEL_FOCUS;
75        }
76        state
77    }
78
79    /// Construct, setting all components, also setting hover from `id2`
80    pub fn new2(ev: &EventState, id: &WidgetId, id2: &WidgetId) -> Self {
81        let mut state = Self::new_all(ev, id);
82        if ev.is_hovered(id2) {
83            state |= InputState::HOVER;
84        }
85        state
86    }
87
88    /// Extract `DISABLED` bit
89    #[inline]
90    pub fn disabled(self) -> bool {
91        self.contains(InputState::DISABLED)
92    }
93
94    /// Extract `HOVER` bit
95    #[inline]
96    pub fn hover(self) -> bool {
97        self.contains(InputState::HOVER)
98    }
99
100    /// Extract `DEPRESS` bit
101    #[inline]
102    pub fn depress(self) -> bool {
103        self.contains(InputState::DEPRESS)
104    }
105
106    /// Extract `NAV_FOCUS` bit
107    #[inline]
108    pub fn nav_focus(self) -> bool {
109        self.contains(InputState::NAV_FOCUS)
110    }
111
112    /// Extract `CHAR_FOCUS` bit
113    #[inline]
114    pub fn char_focus(self) -> bool {
115        self.contains(InputState::CHAR_FOCUS)
116    }
117
118    /// Extract `SEL_FOCUS` bit
119    #[inline]
120    pub fn sel_focus(self) -> bool {
121        self.contains(InputState::SEL_FOCUS)
122    }
123}
124
125/// Provides standard theme colours
126#[allow(clippy::derive_partial_eq_without_eq)]
127#[derive(Clone, Debug, PartialEq)]
128#[cfg_attr(feature = "config", derive(serde::Serialize, serde::Deserialize))]
129pub struct Colors<C> {
130    /// True if this is a dark theme
131    pub is_dark: bool,
132    /// Background colour
133    pub background: C,
134    /// Colour for frames (not always used)
135    pub frame: C,
136    /// Background colour of `EditBox`
137    pub edit_bg: C,
138    /// Background colour of `EditBox` (disabled state)
139    pub edit_bg_disabled: C,
140    /// Background colour of `EditBox` (error state)
141    pub edit_bg_error: C,
142    /// Theme accent
143    ///
144    /// This should be a bold colour, used for small details.
145    pub accent: C,
146    /// Soft version of accent
147    ///
148    /// A softer version of the accent colour, used for block elements in some themes.
149    pub accent_soft: C,
150    /// Highlight colour for keyboard navigation
151    ///
152    /// This may be the same as `accent`. It should contrast well with
153    /// `accent_soft`. Themes should use `nav_focus` over `accent` where a
154    /// strong contrast is required.
155    pub nav_focus: C,
156    /// Normal text colour (over background)
157    pub text: C,
158    /// Opposing text colour (e.g. white if `text` is black)
159    pub text_invert: C,
160    /// Disabled text colour
161    pub text_disabled: C,
162    /// Selected text background colour
163    ///
164    /// This may be the same as `accent_soft`.
165    pub text_sel_bg: C,
166}
167
168/// [`Colors`] parameterised for reading and writing using sRGB
169pub type ColorsSrgb = Colors<Rgba8Srgb>;
170
171/// [`Colors`] parameterised for graphics usage
172pub type ColorsLinear = Colors<Rgba>;
173
174impl From<ColorsSrgb> for ColorsLinear {
175    fn from(col: ColorsSrgb) -> Self {
176        Colors {
177            is_dark: col.is_dark,
178            background: col.background.into(),
179            frame: col.frame.into(),
180            accent: col.accent.into(),
181            accent_soft: col.accent_soft.into(),
182            nav_focus: col.nav_focus.into(),
183            edit_bg: col.edit_bg.into(),
184            edit_bg_disabled: col.edit_bg_disabled.into(),
185            edit_bg_error: col.edit_bg_error.into(),
186            text: col.text.into(),
187            text_invert: col.text_invert.into(),
188            text_disabled: col.text_disabled.into(),
189            text_sel_bg: col.text_sel_bg.into(),
190        }
191    }
192}
193
194impl From<ColorsLinear> for ColorsSrgb {
195    fn from(col: ColorsLinear) -> Self {
196        Colors {
197            is_dark: col.is_dark,
198            background: col.background.into(),
199            frame: col.frame.into(),
200            accent: col.accent.into(),
201            accent_soft: col.accent_soft.into(),
202            nav_focus: col.nav_focus.into(),
203            edit_bg: col.edit_bg.into(),
204            edit_bg_disabled: col.edit_bg_disabled.into(),
205            edit_bg_error: col.edit_bg_error.into(),
206            text: col.text.into(),
207            text_invert: col.text_invert.into(),
208            text_disabled: col.text_disabled.into(),
209            text_sel_bg: col.text_sel_bg.into(),
210        }
211    }
212}
213
214impl Default for ColorsLinear {
215    #[cfg(feature = "dark-light")]
216    fn default() -> Self {
217        match dark_light::detect() {
218            dark_light::Mode::Dark => ColorsSrgb::dark().into(),
219            dark_light::Mode::Light => ColorsSrgb::light().into(),
220        }
221    }
222
223    #[cfg(not(feature = "dark-light"))]
224    #[inline]
225    fn default() -> Self {
226        ColorsSrgb::default().into()
227    }
228}
229
230impl Default for ColorsSrgb {
231    #[inline]
232    fn default() -> Self {
233        ColorsSrgb::light()
234    }
235}
236
237impl ColorsSrgb {
238    /// Default "light" scheme
239    pub fn light() -> Self {
240        Colors {
241            is_dark: false,
242            background: Rgba8Srgb::from_str("#FAFAFA").unwrap(),
243            frame: Rgba8Srgb::from_str("#BCBCBC").unwrap(),
244            accent: Rgba8Srgb::from_str("#8347f2").unwrap(),
245            accent_soft: Rgba8Srgb::from_str("#B38DF9").unwrap(),
246            nav_focus: Rgba8Srgb::from_str("#7E3FF2").unwrap(),
247            edit_bg: Rgba8Srgb::from_str("#FAFAFA").unwrap(),
248            edit_bg_disabled: Rgba8Srgb::from_str("#DCDCDC").unwrap(),
249            edit_bg_error: Rgba8Srgb::from_str("#FFBCBC").unwrap(),
250            text: Rgba8Srgb::from_str("#000000").unwrap(),
251            text_invert: Rgba8Srgb::from_str("#FFFFFF").unwrap(),
252            text_disabled: Rgba8Srgb::from_str("#AAAAAA").unwrap(),
253            text_sel_bg: Rgba8Srgb::from_str("#A172FA").unwrap(),
254        }
255    }
256
257    /// Dark scheme
258    pub fn dark() -> Self {
259        Colors {
260            is_dark: true,
261            background: Rgba8Srgb::from_str("#404040").unwrap(),
262            frame: Rgba8Srgb::from_str("#AAAAAA").unwrap(),
263            accent: Rgba8Srgb::from_str("#F74C00").unwrap(),
264            accent_soft: Rgba8Srgb::from_str("#E77346").unwrap(),
265            nav_focus: Rgba8Srgb::from_str("#D03E00").unwrap(),
266            edit_bg: Rgba8Srgb::from_str("#303030").unwrap(),
267            edit_bg_disabled: Rgba8Srgb::from_str("#606060").unwrap(),
268            edit_bg_error: Rgba8Srgb::from_str("#a06868").unwrap(),
269            text: Rgba8Srgb::from_str("#FFFFFF").unwrap(),
270            text_invert: Rgba8Srgb::from_str("#000000").unwrap(),
271            text_disabled: Rgba8Srgb::from_str("#CBCBCB").unwrap(),
272            text_sel_bg: Rgba8Srgb::from_str("#E77346").unwrap(),
273        }
274    }
275
276    /// Blue scheme
277    pub fn blue() -> Self {
278        Colors {
279            is_dark: false,
280            background: Rgba8Srgb::from_str("#FFFFFF").unwrap(),
281            frame: Rgba8Srgb::from_str("#DADADA").unwrap(),
282            accent: Rgba8Srgb::from_str("#3fafd7").unwrap(),
283            accent_soft: Rgba8Srgb::from_str("#7CDAFF").unwrap(),
284            nav_focus: Rgba8Srgb::from_str("#3B697A").unwrap(),
285            edit_bg: Rgba8Srgb::from_str("#FFFFFF").unwrap(),
286            edit_bg_disabled: Rgba8Srgb::from_str("#DCDCDC").unwrap(),
287            edit_bg_error: Rgba8Srgb::from_str("#FFBCBC").unwrap(),
288            text: Rgba8Srgb::from_str("#000000").unwrap(),
289            text_invert: Rgba8Srgb::from_str("#FFFFFF").unwrap(),
290            text_disabled: Rgba8Srgb::from_str("#AAAAAA").unwrap(),
291            text_sel_bg: Rgba8Srgb::from_str("#6CC0E1").unwrap(),
292        }
293    }
294}
295
296impl ColorsLinear {
297    /// Adjust a colour depending on state
298    pub fn adjust_for_state(col: Rgba, state: InputState) -> Rgba {
299        if state.disabled() {
300            col.average()
301        } else if state.depress() {
302            col.multiply(MULT_DEPRESS)
303        } else if state.hover() || state.char_focus() {
304            col.multiply(MULT_HIGHLIGHT).max(MIN_HIGHLIGHT)
305        } else {
306            col
307        }
308    }
309
310    /// Extract from [`Background`]
311    pub fn from_bg(&self, bg: Background, state: InputState, force_accent: bool) -> Rgba {
312        let use_accent = force_accent || state.depress() || state.nav_focus();
313        let col = match bg {
314            _ if state.disabled() => self.edit_bg_disabled,
315            Background::Default if use_accent => self.accent_soft,
316            Background::Default => self.background,
317            Background::Error => self.edit_bg_error,
318            Background::Rgb(rgb) => rgb.into(),
319        };
320        Self::adjust_for_state(col, state)
321    }
322
323    /// Get colour of a text area, depending on state
324    pub fn from_edit_bg(&self, bg: Background, state: InputState) -> Rgba {
325        let mut col = match bg {
326            _ if state.disabled() => self.edit_bg_disabled,
327            Background::Default => self.edit_bg,
328            Background::Error => self.edit_bg_error,
329            Background::Rgb(rgb) => rgb.into(),
330        };
331        if state.depress() {
332            col = col.multiply(MULT_DEPRESS);
333        }
334        col
335    }
336
337    /// Get colour for navigation highlight region, if any
338    pub fn nav_region(&self, state: InputState) -> Option<Rgba> {
339        if state.nav_focus() && !state.disabled() {
340            Some(self.nav_focus)
341        } else {
342            None
343        }
344    }
345
346    /// Get accent colour, adjusted for state
347    #[inline]
348    pub fn accent_state(&self, state: InputState) -> Rgba {
349        Self::adjust_for_state(self.accent, state)
350    }
351
352    /// Get soft accent colour, adjusted for state
353    #[inline]
354    pub fn accent_soft_state(&self, state: InputState) -> Rgba {
355        Self::adjust_for_state(self.accent_soft, state)
356    }
357
358    /// Get colour for a check box mark, depending on state
359    #[inline]
360    pub fn check_mark_state(&self, state: InputState) -> Rgba {
361        Self::adjust_for_state(self.accent, state)
362    }
363
364    /// Get background highlight colour of a menu entry, if any
365    pub fn menu_entry(&self, state: InputState) -> Option<Rgba> {
366        if state.depress() || state.nav_focus() {
367            Some(self.accent_soft.multiply(MULT_DEPRESS))
368        } else {
369            None
370        }
371    }
372
373    /// Get appropriate text colour over the given background
374    pub fn text_over(&self, bg: Rgba) -> Rgba {
375        let bg_sum = bg.sum();
376        if (bg_sum - self.text_invert.sum()).abs() > (bg_sum - self.text.sum()).abs() {
377            self.text_invert
378        } else {
379            self.text
380        }
381    }
382}