ltk/widget/color_picker/
mod.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! ColorPicker — RGBA sliders + hex input + preview swatch + a
//! continuous hue strip for picking arbitrary colours.
//!
//! Stateless — the application owns the current [`Color`] and updates
//! it from [`ColorPicker::on_change`]. Four sliders cover R / G / B
//! and (when [`ColorPicker::show_alpha`]) A; a hex input lets the user
//! type or paste `#RRGGBB` / `#RRGGBBAA`; a hue slider with a rainbow
//! track lets the user grab any pure hue at full saturation / value
//! in one drag, and the RGB sliders then fine-tune it.
//!
//! ```rust,no_run
//! # use ltk::{ color_picker, Color, ColorPicker };
//! # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) }
//! # struct App { accent: Color }
//! # impl App { fn _ex( &self ) -> ColorPicker<Msg> {
//! color_picker( self.accent )
//!     .show_alpha( false )
//!     .on_change( Msg::AccentChanged )
//! # }}
//! ```

use std::sync::Arc;

use crate::types::Color;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };

use super::Element;

mod theme;
#[ cfg( test ) ]
mod tests;

/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
/// is true and the colour is not fully opaque). Bytes are clamped to
/// `0..=255`.
pub fn color_to_hex( c: Color, with_alpha: bool ) -> String
{
	let to_byte = | f: f32 | ( f.clamp( 0.0, 1.0 ) * 255.0 ).round() as u8;
	let r = to_byte( c.r );
	let g = to_byte( c.g );
	let b = to_byte( c.b );
	let a = to_byte( c.a );
	if with_alpha && a != 255
	{
		format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a )
	} else {
		format!( "#{:02X}{:02X}{:02X}", r, g, b )
	}
}

/// Parse a hex colour string (`"#RGB"` / `"#RGBA"` / `"#RRGGBB"` /
/// `"#RRGGBBAA"`, with or without the leading `#`, case-insensitive)
/// into a [`Color`]. Returns `None` for malformed input.
pub fn parse_hex( s: &str ) -> Option<Color>
{
	let s = s.trim();
	let s = s.strip_prefix( '#' ).unwrap_or( s );
	let parse_byte = | hi: char, lo: char | -> Option<u8>
	{
		let h = hi.to_digit( 16 )?;
		let l = lo.to_digit( 16 )?;
		Some( ( ( h << 4 ) | l ) as u8 )
	};
	let chars: Vec<char> = s.chars().collect();
	let to_color = | r: u8, g: u8, b: u8, a: u8 | Color::rgba(
		r as f32 / 255.0,
		g as f32 / 255.0,
		b as f32 / 255.0,
		a as f32 / 255.0,
	);
	match chars.len()
	{
		3 =>
		{
			let r = parse_byte( chars[0], chars[0] )?;
			let g = parse_byte( chars[1], chars[1] )?;
			let b = parse_byte( chars[2], chars[2] )?;
			Some( to_color( r, g, b, 255 ) )
		}
		4 =>
		{
			let r = parse_byte( chars[0], chars[0] )?;
			let g = parse_byte( chars[1], chars[1] )?;
			let b = parse_byte( chars[2], chars[2] )?;
			let a = parse_byte( chars[3], chars[3] )?;
			Some( to_color( r, g, b, a ) )
		}
		6 =>
		{
			let r = parse_byte( chars[0], chars[1] )?;
			let g = parse_byte( chars[2], chars[3] )?;
			let b = parse_byte( chars[4], chars[5] )?;
			Some( to_color( r, g, b, 255 ) )
		}
		8 =>
		{
			let r = parse_byte( chars[0], chars[1] )?;
			let g = parse_byte( chars[2], chars[3] )?;
			let b = parse_byte( chars[4], chars[5] )?;
			let a = parse_byte( chars[6], chars[7] )?;
			Some( to_color( r, g, b, a ) )
		}
		_ => None,
	}
}

/// RGBA colour selector with sliders, hex input, preview swatch and
/// a continuous hue strip.
pub struct ColorPicker<Msg: Clone>
{
	pub value:        Color,
	pub on_change:    Option<Arc<dyn Fn( Color ) -> Msg>>,
	/// When `true` the alpha slider is shown and the hex input
	/// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme
	/// colour" flows are opaque.
	pub show_alpha:   bool,
}

impl<Msg: Clone + 'static> ColorPicker<Msg>
{
	pub fn new( value: Color ) -> Self
	{
		Self
		{
			value,
			on_change:  None,
			show_alpha: false,
		}
	}

	pub fn on_change( mut self, f: impl Fn( Color ) -> Msg + 'static ) -> Self
	{
		self.on_change = Some( Arc::new( f ) );
		self
	}

	pub fn show_alpha( mut self, on: bool ) -> Self
	{
		self.show_alpha = on;
		self
	}

	/// Build the `Element` tree representing this color picker.
	pub fn build( self ) -> Element<Msg>
	{
		use super::{ container, text, text_edit };
		use super::slider::slider;

		let value      = self.value;
		let on_chg     = self.on_change.clone();
		let show_alpha = self.show_alpha;

		// Preview swatch.
		let swatch: Element<Msg> = container::<Msg>( spacer() )
			.background( value )
			.border( theme::divider(), 1.0 )
			.radius( 12.0 )
			.padding( 0.0 )
			.into();
		let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push(
			container::<Msg>( swatch )
				.padding( 0.0 )
				.radius( 12.0 ),
		);
		// We size the swatch via an explicit container holding the
		// preview rect — the parent row will negotiate the space.
		let _ = theme::SWATCH_SZ;

		// Hex input — the `on_change` parses the typed string and
		// only fires the picker's callback on a successful parse, so
		// in-progress typing does not blank the preview every key.
		let hex_value = color_to_hex( value, show_alpha );
		let mut hex_edit = text_edit::<Msg>( "#RRGGBB", hex_value );
		if let Some( ref cb ) = on_chg
		{
			let cb = cb.clone();
			hex_edit = hex_edit.on_change( move |s|
			{
				match parse_hex( &s )
				{
					Some( c ) => cb( c ),
					None      => cb( value ),  // hold the previous value
				}
			} );
		}
		preview_row = preview_row.push( hex_edit );

		// One slider per channel. Each slider's on_change rebuilds
		// the colour by replacing only its channel — the others
		// snapshot at view-build time, which is correct because
		// every change goes through `on_change` and re-renders.
		let chan_slider = | label: &str, current: f32, build_color: Arc<dyn Fn( f32 ) -> Color> | -> Element<Msg>
		{
			let mut s = slider::<Msg>( current );
			if let Some( ref cb ) = on_chg
			{
				let cb_outer = cb.clone();
				s = s.on_change( move |v|
				{
					let c = build_color( v );
					cb_outer( c )
				} );
			}
			column::<Msg>().spacing( 4.0 )
				.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
				.push( s )
				.into()
		};

		let r_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( v, value.g, value.b, value.a ) );
		let g_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, v, value.b, value.a ) );
		let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
		let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );

		let mut sliders = column::<Msg>().spacing( theme::SPACING )
			.push( chan_slider( "R", value.r, r_build ) )
			.push( chan_slider( "G", value.g, g_build ) )
			.push( chan_slider( "B", value.b, b_build ) );
		if show_alpha
		{
			sliders = sliders.push( chan_slider( "A", value.a, a_build ) );
		}

		// Hue strip: a slider whose track is a multi-stop rainbow
		// gradient. Position 0 maps to red, position 1 to a hue
		// just *short* of the full wheel (see `HUE_RANGE` below)
		// so that dragging to the right edge does not snap the
		// thumb back to the left on the next render. Moving the
		// thumb fires `on_change` with the picked hue at full
		// saturation / value, preserving the current alpha — RGB
		// sliders fine-tune brightness afterwards.
		//
		// `HUE_RANGE = 359.0` instead of 360 closes the
		// round-trip: at position 1.0 we pick hue 359°, the colour
		// is almost-red (one degree off pure red), `rgb_to_hue`
		// returns ~359°, and `position = 359 / 359 = 1.0` lands
		// back where the user was. Mapping to 360° instead would
		// snap to hue 0 (pure red, same as position 0) and the
		// slider thumb would teleport to the left — the wheel
		// closes on itself, but a linear slider cannot represent
		// both endpoints. The 1° colour gap between the two ends
		// is imperceptible.
		//
		// Software backend: `fill_paint_rect` falls back to the
		// gradient's first stop (a flat red), so the slider still
		// works functionally; only the rainbow visual is missing
		// off the GLES path. Acceptable trade-off given the
		// software path is the fallback for compositors without GL.
		const HUE_RANGE: f32 = 359.0;
		let current_hue = rgb_to_hue( value.r, value.g, value.b );
		let hue_alpha   = value.a;
		let mut hue_slider = slider::<Msg>( ( current_hue / HUE_RANGE ).clamp( 0.0, 1.0 ) )
			.track_paint( rainbow_gradient() );
		if let Some( ref cb ) = on_chg
		{
			let cb = cb.clone();
			hue_slider = hue_slider.on_change( move |v|
			{
				let ( r, g, b ) = hue_to_rgb( v.clamp( 0.0, 1.0 ) * HUE_RANGE );
				cb( Color::rgba( r, g, b, hue_alpha ) )
			} );
		}
		let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
			.push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
			.push( hue_slider )
			.into();

		let body = column::<Msg>().spacing( theme::SPACING * 2.0 )
			.push( preview_row )
			.push( sliders )
			.push( hue_row );

		container::<Msg>( body )
			.background( theme::surface_alt() )
			.padding( theme::PADDING )
			.radius( theme::RADIUS )
			.into()
	}
}

/// Build the rainbow [`Paint::Linear`] used as the hue strip's track.
/// Seven stops across the wheel with a final repeat of red so the
/// gradient closes cleanly. CSS angle convention: `90deg` sweeps
/// left-to-right, matching the slider's value axis.
fn rainbow_gradient() -> Paint
{
	let stop = | pos: f32, c: Color | ColorStop { position: pos, color: c };
	Paint::Linear( LinearGradient
	{
		angle_deg: 90.0,
		stops: vec!
		[
			stop( 0.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ),  // red
			stop( 1.0 / 6.0, Color::rgba( 1.0, 1.0, 0.0, 1.0 ) ),  // yellow
			stop( 2.0 / 6.0, Color::rgba( 0.0, 1.0, 0.0, 1.0 ) ),  // green
			stop( 3.0 / 6.0, Color::rgba( 0.0, 1.0, 1.0, 1.0 ) ),  // cyan
			stop( 4.0 / 6.0, Color::rgba( 0.0, 0.0, 1.0, 1.0 ) ),  // blue
			stop( 5.0 / 6.0, Color::rgba( 1.0, 0.0, 1.0, 1.0 ) ),  // magenta
			stop( 1.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ),  // red (closes the wheel)
		],
		space: GradientSpace::Srgb,
	} )
}

/// Compute the HSV hue (in degrees, `0..360`) of an RGB triple. Returns
/// `0.0` for greys (where hue is undefined) so the slider reads at a
/// stable position when the user dials saturation down to zero via the
/// RGB sliders.
pub fn rgb_to_hue( r: f32, g: f32, b: f32 ) -> f32
{
	let max   = r.max( g ).max( b );
	let min   = r.min( g ).min( b );
	let delta = max - min;
	if delta <= f32::EPSILON { return 0.0; }
	let h = if max == r
	{
		60.0 * ( ( ( g - b ) / delta ).rem_euclid( 6.0 ) )
	}
	else if max == g
	{
		60.0 * ( ( b - r ) / delta + 2.0 )
	}
	else
	{
		60.0 * ( ( r - g ) / delta + 4.0 )
	};
	if h < 0.0 { h + 360.0 } else { h }
}

/// Build an RGB triple at full saturation and value from a hue in
/// degrees (`0..360`). Returns each channel in `0.0..=1.0`.
pub fn hue_to_rgb( hue_deg: f32 ) -> ( f32, f32, f32 )
{
	let h = hue_deg.rem_euclid( 360.0 ) / 60.0;
	let c = 1.0_f32;
	let x = c * ( 1.0 - ( ( h.rem_euclid( 2.0 ) ) - 1.0 ).abs() );
	let ( r, g, b ) = match h as u32
	{
		0 => ( c, x, 0.0 ),
		1 => ( x, c, 0.0 ),
		2 => ( 0.0, c, x ),
		3 => ( 0.0, x, c ),
		4 => ( x, 0.0, c ),
		_ => ( c, 0.0, x ),
	};
	( r, g, b )
}

impl<Msg: Clone + 'static> From<ColorPicker<Msg>> for Element<Msg>
{
	fn from( c: ColorPicker<Msg> ) -> Self { c.build() }
}

/// Create a [`ColorPicker`] starting from the given colour.
///
/// ```rust,no_run
/// # use ltk::{ color_picker, Color, ColorPicker };
/// # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) }
/// # struct App { accent: Color }
/// # impl App { fn _ex( &self ) -> ColorPicker<Msg> {
/// color_picker( self.accent )
///     .show_alpha( true )
///     .on_change( Msg::AccentChanged )
/// # }}
/// ```
pub fn color_picker<Msg: Clone + 'static>( value: Color ) -> ColorPicker<Msg>
{
	ColorPicker::new( value )
}