forked from 1b-agent/opencadstudio-upstream
Share the colour editor, add an eyedropper, use library code
- ColorEditor: the HSB / RGB / field editor and hex box, now shared by the Select Color panel and the Options background swatches. Those swatches open the panel without tabs or index colours, which removes the iced_aw color_picker feature and the BgPicker messages. - Sliders are iced's built-in slider and vertical_slider over a gradient container; the custom GradientSlider widget is gone. - Nearest index colour comes from opencadcodec's aci_table::nearest_aci. - Eyedropper: a pipette at the right of the mode row hides the panel, captures the window with iced's screenshot task and shows an 11x11 magnifier beside a crosshair; a click picks the pixel, Esc backs out. Not offered on the web build. - The hex box applies six-digit values while typing. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
16 files changed
+1088
-1016
No files matched your search
+1
-1
@@ -27,7 +27,7 @@ url = "2"
|
||||
iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] }
|
||||
iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down", "number_input"] }
|
||||
iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "drop_down", "number_input"] }
|
||||
bytemuck = { version = "1.25", features = ["derive"] }
|
||||
glam = { version = "0.33", features = ["bytemuck"] }
|
||||
rfd = "0.17"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 6.5l3 3"/><path d="M13 5l2.6-2.6a2.1 2.1 0 0 1 3 0l3 3a2.1 2.1 0 0 1 0 3L19 11"/><path d="M13 5l6 6"/><path d="M16 8L6.5 17.5 4 18l.5-2.5L14 6"/><path d="M4 18l-1.5 1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 348 B |
+3
-11
@@ -838,9 +838,6 @@ pub(super) struct OpenCADStudio {
|
||||
pub(crate) sheet_set_pending_layout: Option<(String, String)>,
|
||||
/// The subset / set Import Layout as Sheet adds to, while its file picker is open.
|
||||
pub(crate) sheet_set_import_parent: Option<String>,
|
||||
/// Which viewport background the colour wheel is editing, or `None` when
|
||||
/// it is closed. One slot, because only one wheel can be open at a time.
|
||||
pub(crate) bg_picker: Option<BgTarget>,
|
||||
/// General edge-stack dock layout for the side panels.
|
||||
pub(crate) dock: crate::ui::dock::DockState,
|
||||
/// Which panel is currently floated at full height (hovered, or a pinned
|
||||
@@ -1580,7 +1577,7 @@ pub struct SaveOutcome {
|
||||
refreshed_preview: Option<Option<codec::Preview>>,
|
||||
result: Result<(), crate::io::SaveFailure>,
|
||||
}
|
||||
/// Which viewport background a colour-wheel session is editing.
|
||||
/// Which viewport background the interface colour picker is editing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BgTarget {
|
||||
Model,
|
||||
@@ -1610,6 +1607,8 @@ pub enum ColorPickTarget {
|
||||
LayerState(usize),
|
||||
/// The MText editor's selection (or global) colour.
|
||||
MText,
|
||||
/// An Options viewport background (interface colour, true colour only).
|
||||
Background(BgTarget),
|
||||
}
|
||||
|
||||
/// Table records the clipboard entities depend on, snapshotted from the source
|
||||
@@ -2311,12 +2310,6 @@ pub enum Message {
|
||||
ModelSpaceModeChanged(config::ModelSpaceMode),
|
||||
/// Change Model Space custom background color as hex or empty for default.
|
||||
ModelSpaceBgChanged(String),
|
||||
/// Open the colour wheel on one of the viewport backgrounds.
|
||||
BgPickerOpen(BgTarget),
|
||||
/// Dismiss the wheel, changing nothing.
|
||||
BgPickerCancel,
|
||||
/// Accept the wheel's colour for whichever background it was opened on.
|
||||
BgPickerSubmit(iced::Color),
|
||||
/// Change Paper Space custom sheet background color as hex or empty for default.
|
||||
PaperSpaceBgChanged(String),
|
||||
/// Change Paper Space desk surround background (#RRGGBB).
|
||||
@@ -4283,7 +4276,6 @@ impl OpenCADStudio {
|
||||
sheet_set: Default::default(),
|
||||
sheet_set_pending_layout: None,
|
||||
sheet_set_import_parent: None,
|
||||
bg_picker: None,
|
||||
block_palette: Default::default(),
|
||||
xref_manager: Default::default(),
|
||||
dock: Default::default(),
|
||||
|
||||
+17
-33
@@ -8256,39 +8256,6 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::BgPickerOpen(target) => {
|
||||
self.bg_picker = Some(target);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::BgPickerCancel => {
|
||||
self.bg_picker = None;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::BgPickerSubmit(color) => {
|
||||
// Hand the result to the same handler the typed hex field
|
||||
// uses, so the wheel and the field cannot drift apart on
|
||||
// validation, persistence or the MatchTheme fallback.
|
||||
let hex = crate::app::config::rgb_to_hex([
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
]);
|
||||
match self.bg_picker.take() {
|
||||
Some(crate::app::BgTarget::Model) => {
|
||||
Task::done(Message::ModelSpaceBgChanged(hex))
|
||||
}
|
||||
Some(crate::app::BgTarget::Paper) => {
|
||||
Task::done(Message::PaperSpaceBgChanged(hex))
|
||||
}
|
||||
Some(crate::app::BgTarget::Desk) => {
|
||||
Task::done(Message::DeskSpaceBgChanged(hex))
|
||||
}
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
Message::ModelSpaceBgChanged(hex) => {
|
||||
self.model_bg_input = hex.clone();
|
||||
if hex.trim().is_empty() {
|
||||
@@ -11066,6 +11033,16 @@ impl OpenCADStudio {
|
||||
self.color_picker = None;
|
||||
Task::none()
|
||||
}
|
||||
Some(crate::ui::color_select::Outcome::Screenshot) => {
|
||||
use crate::ui::color_select::{eyedropper::EyedropperMsg, ColorPickerMsg};
|
||||
self.main_window.map_or_else(Task::none, |id| {
|
||||
iced::window::screenshot(id).map(|shot| {
|
||||
Message::ColorPicker(ColorPickerMsg::Eyedropper(
|
||||
EyedropperMsg::Shot(shot),
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
@@ -11075,7 +11052,14 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::ColorWindowPick(color) => {
|
||||
// Interface colours are not drawing colours: keep them out of
|
||||
// the recent list.
|
||||
if !matches!(
|
||||
self.color_picker.as_ref().map(|picker| &picker.target),
|
||||
Some(crate::app::ColorPickTarget::Background(_))
|
||||
) {
|
||||
self.note_recent_color(color);
|
||||
}
|
||||
self.on_color_window_pick(color)
|
||||
}
|
||||
Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value),
|
||||
|
||||
+23
-1
@@ -1136,6 +1136,25 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task<Message> {
|
||||
if let Some(crate::app::ColorPickTarget::Background(target)) = self
|
||||
.color_picker
|
||||
.as_ref()
|
||||
.map(|picker| picker.target.clone())
|
||||
{
|
||||
self.color_picker = None;
|
||||
let Some((r, g, b)) = crate::ui::color_select::math::color_rgb(color) else {
|
||||
return Task::none();
|
||||
};
|
||||
// Hand the result to the same handler the typed hex field uses, so
|
||||
// the picker and the field cannot drift apart on validation,
|
||||
// persistence or the MatchTheme fallback.
|
||||
let hex = crate::app::config::rgb_to_hex([r, g, b]);
|
||||
return Task::done(match target {
|
||||
crate::app::BgTarget::Model => Message::ModelSpaceBgChanged(hex),
|
||||
crate::app::BgTarget::Paper => Message::PaperSpaceBgChanged(hex),
|
||||
crate::app::BgTarget::Desk => Message::DeskSpaceBgChanged(hex),
|
||||
});
|
||||
}
|
||||
if matches!(
|
||||
self.color_picker.as_ref().map(|picker| &picker.target),
|
||||
Some(crate::app::ColorPickTarget::PlotStyle)
|
||||
@@ -1187,7 +1206,10 @@ impl OpenCADStudio {
|
||||
Some(crate::app::ColorPickTarget::LayerState(idx)) => {
|
||||
Some(Message::LayerStateEditorLayerColor(idx, color))
|
||||
}
|
||||
Some(crate::app::ColorPickTarget::PlotStyle) => None,
|
||||
// Handled before this match.
|
||||
Some(
|
||||
crate::app::ColorPickTarget::PlotStyle | crate::app::ColorPickTarget::Background(_),
|
||||
) => None,
|
||||
None => None,
|
||||
};
|
||||
if let Some(m) = edit {
|
||||
|
||||
@@ -2475,6 +2475,11 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
}
|
||||
|
||||
if let Some(picker) = self.color_picker.as_ref() {
|
||||
// While the eyedropper is picking, the panel steps aside so the
|
||||
// whole window can be sampled.
|
||||
if let Some(eyedropper) = picker.eyedropper_view() {
|
||||
return iced::widget::stack![base, eyedropper].into();
|
||||
}
|
||||
crate::ui::modal::modal(
|
||||
base,
|
||||
t!("Select Color"),
|
||||
|
||||
@@ -380,7 +380,6 @@ impl OpenCADStudio {
|
||||
&self.model_bg_input,
|
||||
&self.paper_bg_input,
|
||||
&self.desk_bg_input,
|
||||
self.bg_picker,
|
||||
dirty,
|
||||
close_confirm,
|
||||
flow,
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! The colour editor: HSB or RGB sliders, or a saturation/brightness field
|
||||
//! with a hue strip, plus a hex box. Shared by the CAD Select Color panel and
|
||||
//! the interface colour picker.
|
||||
|
||||
use super::math::{self, Hsb};
|
||||
use super::widgets::ColorArea;
|
||||
use super::ColorPickerMsg;
|
||||
use crate::app::Message;
|
||||
use crate::t;
|
||||
use iced::widget::{
|
||||
button, column, container, row, slider, stack, text, text_input, vertical_slider, Space,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Gradient, Length, Radians, Theme};
|
||||
|
||||
const ROW_HEIGHT: f32 = 26.0;
|
||||
const TRACK: f32 = 18.0;
|
||||
const STRIP_WIDTH: f32 = 22.0;
|
||||
const FIELD_HEIGHT: f32 = 118.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EditorMode {
|
||||
#[default]
|
||||
Hsb,
|
||||
Rgb,
|
||||
Field,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EditorMsg {
|
||||
Mode(EditorMode),
|
||||
Hue(f32),
|
||||
/// Saturation in percent.
|
||||
Saturation(f32),
|
||||
/// Brightness in percent.
|
||||
Brightness(f32),
|
||||
/// Saturation and brightness from the field, both 0–1.
|
||||
Field(f32, f32),
|
||||
Red(u8),
|
||||
Green(u8),
|
||||
Blue(u8),
|
||||
HexInput(String),
|
||||
HexSubmit,
|
||||
}
|
||||
|
||||
pub struct ColorEditor {
|
||||
mode: EditorMode,
|
||||
/// Kept beside `rgb` so hue and saturation survive grays and black.
|
||||
hsb: Hsb,
|
||||
rgb: (u8, u8, u8),
|
||||
hex_draft: Option<String>,
|
||||
}
|
||||
|
||||
impl ColorEditor {
|
||||
pub fn new(rgb: (u8, u8, u8)) -> Self {
|
||||
let mut editor = Self {
|
||||
mode: EditorMode::default(),
|
||||
hsb: Hsb {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
b: 1.0,
|
||||
},
|
||||
rgb,
|
||||
hex_draft: None,
|
||||
};
|
||||
editor.set_rgb(rgb);
|
||||
editor
|
||||
}
|
||||
|
||||
pub fn rgb(&self) -> (u8, u8, u8) {
|
||||
self.rgb
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> EditorMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn set_rgb(&mut self, (r, g, b): (u8, u8, u8)) {
|
||||
let next = math::rgb_to_hsb(r, g, b);
|
||||
// A gray has no hue and black no saturation; keep the previous ones
|
||||
// so the sliders and field don't jump.
|
||||
let gray = next.s == 0.0 || next.b == 0.0;
|
||||
self.hsb = Hsb {
|
||||
h: if gray { self.hsb.h } else { next.h },
|
||||
s: if next.b == 0.0 { self.hsb.s } else { next.s },
|
||||
b: next.b,
|
||||
};
|
||||
self.rgb = (r, g, b);
|
||||
self.hex_draft = None;
|
||||
}
|
||||
|
||||
fn set_hsb(&mut self, hsb: Hsb) {
|
||||
self.hsb = Hsb {
|
||||
h: hsb.h.clamp(0.0, 360.0),
|
||||
s: hsb.s.clamp(0.0, 1.0),
|
||||
b: hsb.b.clamp(0.0, 1.0),
|
||||
};
|
||||
self.rgb = self.hsb.to_rgb();
|
||||
self.hex_draft = None;
|
||||
}
|
||||
|
||||
/// Apply a message; returns whether the colour changed.
|
||||
pub fn update(&mut self, msg: EditorMsg) -> bool {
|
||||
let before = self.rgb;
|
||||
let (r, g, b) = self.rgb;
|
||||
match msg {
|
||||
EditorMsg::Mode(mode) => self.mode = mode,
|
||||
EditorMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }),
|
||||
EditorMsg::Saturation(s) => self.set_hsb(Hsb {
|
||||
s: s / 100.0,
|
||||
..self.hsb
|
||||
}),
|
||||
EditorMsg::Brightness(v) => self.set_hsb(Hsb {
|
||||
b: v / 100.0,
|
||||
..self.hsb
|
||||
}),
|
||||
EditorMsg::Field(s, b) => self.set_hsb(Hsb { s, b, ..self.hsb }),
|
||||
EditorMsg::Red(v) => self.set_rgb((v, g, b)),
|
||||
EditorMsg::Green(v) => self.set_rgb((r, v, b)),
|
||||
EditorMsg::Blue(v) => self.set_rgb((r, g, v)),
|
||||
EditorMsg::HexInput(value) => {
|
||||
// Six digits are unambiguous, so apply them while typing; the
|
||||
// short #RGB form waits for Enter.
|
||||
let complete = value.trim().trim_start_matches('#').len() == 6;
|
||||
if let Some(rgb) = math::parse_hex(&value).filter(|_| complete) {
|
||||
self.set_rgb(rgb);
|
||||
}
|
||||
self.hex_draft = Some(value);
|
||||
}
|
||||
EditorMsg::HexSubmit => {
|
||||
if let Some(rgb) = self.hex_draft.take().and_then(|d| math::parse_hex(&d)) {
|
||||
self.set_rgb(rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rgb != before
|
||||
}
|
||||
|
||||
/// `mode_end` sits at the far right of the mode buttons (the pipette);
|
||||
/// `hex_end` at the far right of the hex row (the nearest index colour).
|
||||
pub fn view<'a>(
|
||||
&'a self,
|
||||
mode_end: Option<Element<'a, Message>>,
|
||||
hex_end: Option<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let Hsb { h, s, b } = self.hsb;
|
||||
let (r, g, bl) = self.rgb;
|
||||
|
||||
let mode_button = |content: Element<'a, Message>, mode: EditorMode| {
|
||||
let active = self.mode == mode;
|
||||
button(container(content).center_y(Length::Fill))
|
||||
.on_press(msg(EditorMsg::Mode(mode)))
|
||||
.height(28)
|
||||
.padding([0, 10])
|
||||
.style(move |theme: &Theme, status| {
|
||||
super::picker::toggle_style(theme, active, status)
|
||||
})
|
||||
};
|
||||
let field_icon = container(Space::new())
|
||||
.width(26)
|
||||
.height(16)
|
||||
.style(|_theme: &Theme| {
|
||||
gradient_box(
|
||||
3.0 * std::f32::consts::FRAC_PI_4,
|
||||
&[
|
||||
(0.0, Color::WHITE),
|
||||
(0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)),
|
||||
(1.0, Color::BLACK),
|
||||
],
|
||||
)
|
||||
});
|
||||
let mut modes = row![
|
||||
mode_button(text("HSB").size(12).into(), EditorMode::Hsb),
|
||||
mode_button(text("RGB").size(12).into(), EditorMode::Rgb),
|
||||
mode_button(field_icon.into(), EditorMode::Field),
|
||||
Space::new().width(Length::Fill),
|
||||
]
|
||||
.spacing(6);
|
||||
if let Some(end) = mode_end {
|
||||
modes = modes.push(end);
|
||||
}
|
||||
|
||||
let editor: Element<'a, Message> = match self.mode {
|
||||
EditorMode::Hsb => column![
|
||||
channel("H", h, 360.0, Some('°'), hue_stops(), EditorMsg::Hue),
|
||||
channel(
|
||||
"S",
|
||||
s * 100.0,
|
||||
100.0,
|
||||
Some('%'),
|
||||
vec![(0.0, hsb_color(h, 0.0, b)), (1.0, hsb_color(h, 1.0, b))],
|
||||
EditorMsg::Saturation,
|
||||
),
|
||||
channel(
|
||||
"B",
|
||||
b * 100.0,
|
||||
100.0,
|
||||
Some('%'),
|
||||
vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))],
|
||||
EditorMsg::Brightness,
|
||||
),
|
||||
]
|
||||
.spacing(10)
|
||||
.into(),
|
||||
EditorMode::Rgb => {
|
||||
let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| {
|
||||
vec![(0.0, rgb_color(lo)), (1.0, rgb_color(hi))]
|
||||
};
|
||||
let byte = |f: fn(u8) -> EditorMsg| move |v: f32| f(v.round() as u8);
|
||||
column![
|
||||
channel(
|
||||
"R",
|
||||
f32::from(r),
|
||||
255.0,
|
||||
None,
|
||||
ends((0, g, bl), (255, g, bl)),
|
||||
byte(EditorMsg::Red)
|
||||
),
|
||||
channel(
|
||||
"G",
|
||||
f32::from(g),
|
||||
255.0,
|
||||
None,
|
||||
ends((r, 0, bl), (r, 255, bl)),
|
||||
byte(EditorMsg::Green)
|
||||
),
|
||||
channel(
|
||||
"B",
|
||||
f32::from(bl),
|
||||
255.0,
|
||||
None,
|
||||
ends((r, g, 0), (r, g, 255)),
|
||||
byte(EditorMsg::Blue)
|
||||
),
|
||||
]
|
||||
.spacing(10)
|
||||
.into()
|
||||
}
|
||||
EditorMode::Field => {
|
||||
let area =
|
||||
ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(self.rgb), s, b, |s, b| {
|
||||
msg(EditorMsg::Field(s, b))
|
||||
})
|
||||
.height(Length::Fixed(FIELD_HEIGHT));
|
||||
// A vertical slider grows upwards; the strip runs red at the top.
|
||||
let strip = stack![
|
||||
container(Space::new())
|
||||
.width(STRIP_WIDTH)
|
||||
.height(FIELD_HEIGHT)
|
||||
.style(|_theme: &Theme| gradient_box(std::f32::consts::PI, &hue_stops())),
|
||||
vertical_slider(0.0..=360.0, 360.0 - h, |v| msg(EditorMsg::Hue(360.0 - v)))
|
||||
.width(STRIP_WIDTH)
|
||||
.height(FIELD_HEIGHT)
|
||||
.style(handle_style),
|
||||
];
|
||||
let value = |letter: &'static str,
|
||||
v: f32,
|
||||
max: f32,
|
||||
unit: char,
|
||||
f: fn(f32) -> EditorMsg| {
|
||||
row![
|
||||
text(letter).size(12).font(iced::Font::MONOSPACE),
|
||||
number_box(v, max, Some(unit), f),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center)
|
||||
};
|
||||
column![
|
||||
row![area, strip].spacing(12),
|
||||
row![
|
||||
value("H", h, 360.0, '°', EditorMsg::Hue),
|
||||
Space::new().width(Length::Fill),
|
||||
value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation),
|
||||
Space::new().width(Length::Fill),
|
||||
value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(10)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
let hex = self
|
||||
.hex_draft
|
||||
.clone()
|
||||
.unwrap_or_else(|| math::hex_string(r, g, bl));
|
||||
let mut hex_row = row![
|
||||
text(t!("Hex")).size(11).style(super::picker::muted_text),
|
||||
text_input("", &hex)
|
||||
.on_input(|value| msg(EditorMsg::HexInput(value)))
|
||||
.on_submit(msg(EditorMsg::HexSubmit))
|
||||
.size(12)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.padding([5, 8])
|
||||
.width(Length::Fixed(92.0)),
|
||||
Space::new().width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center);
|
||||
if let Some(end) = hex_end {
|
||||
hex_row = hex_row.push(end);
|
||||
}
|
||||
|
||||
column![modes, editor, hex_row].spacing(12).into()
|
||||
}
|
||||
}
|
||||
|
||||
fn msg(editor: EditorMsg) -> Message {
|
||||
Message::ColorPicker(ColorPickerMsg::Editor(editor))
|
||||
}
|
||||
|
||||
/// One labelled channel: a gradient slider and its number box.
|
||||
fn channel<'a>(
|
||||
letter: &'static str,
|
||||
value: f32,
|
||||
max: f32,
|
||||
unit: Option<char>,
|
||||
stops: Vec<(f32, Color)>,
|
||||
on_change: impl Fn(f32) -> EditorMsg + Copy + 'static,
|
||||
) -> Element<'a, Message> {
|
||||
// iced's slider splits its own rail at the handle, which would squeeze a
|
||||
// gradient; draw the gradient underneath and keep the rail transparent.
|
||||
let track = container(
|
||||
container(Space::new())
|
||||
.width(Length::Fill)
|
||||
.height(TRACK)
|
||||
.style(move |_theme: &Theme| gradient_box(std::f32::consts::FRAC_PI_2, &stops)),
|
||||
)
|
||||
.height(ROW_HEIGHT)
|
||||
.center_y(ROW_HEIGHT);
|
||||
row![
|
||||
container(text(letter).size(12).font(iced::Font::MONOSPACE))
|
||||
.width(12)
|
||||
.align_x(iced::Center),
|
||||
stack![
|
||||
track,
|
||||
slider(0.0..=max, value, move |v| msg(on_change(v)))
|
||||
.height(ROW_HEIGHT)
|
||||
.shift_step(10.0)
|
||||
.style(handle_style),
|
||||
],
|
||||
number_box(value, max, unit, on_change),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A compact spin box with the unit drawn inside, after the digits.
|
||||
fn number_box(
|
||||
value: f32,
|
||||
max: f32,
|
||||
unit: Option<char>,
|
||||
on_change: impl Fn(f32) -> EditorMsg + Copy + 'static,
|
||||
) -> Element<'static, Message> {
|
||||
let input =
|
||||
iced_aw::widget::NumberInput::new(&(value.round() as u16), 0..=max as u16, move |v| {
|
||||
msg(on_change(f32::from(v)))
|
||||
})
|
||||
.set_size(12.0)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.width(Length::Fixed(68.0))
|
||||
// Padding under 5 would make NumberInput swap its arrows for + / −;
|
||||
// the extra right padding keeps the digits clear of the arrows.
|
||||
.padding(iced::Padding {
|
||||
top: 5.0,
|
||||
right: 18.0,
|
||||
bottom: 5.0,
|
||||
left: 6.0,
|
||||
})
|
||||
.align_x(iced::alignment::Horizontal::Right)
|
||||
.style(
|
||||
|theme: &Theme, _status| iced_aw::style::number_input::Style {
|
||||
button_background: None,
|
||||
icon_color: theme.palette().background.base.text.scale_alpha(0.75),
|
||||
},
|
||||
);
|
||||
match unit {
|
||||
Some(code_point) => input
|
||||
.icon(text_input::Icon {
|
||||
font: iced::Font::MONOSPACE,
|
||||
code_point,
|
||||
size: Some(iced::Pixels(12.0)),
|
||||
spacing: 1.0,
|
||||
side: text_input::Side::Right,
|
||||
})
|
||||
.into(),
|
||||
None => input.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent rail with a white bar handle, for both slider directions.
|
||||
fn handle_style(_theme: &Theme, _status: slider::Status) -> slider::Style {
|
||||
slider::Style {
|
||||
rail: slider::Rail {
|
||||
backgrounds: (Color::TRANSPARENT.into(), Color::TRANSPARENT.into()),
|
||||
width: TRACK,
|
||||
border: Border::default(),
|
||||
},
|
||||
handle: slider::Handle {
|
||||
shape: slider::HandleShape::Rectangle {
|
||||
width: 8,
|
||||
border_radius: 3.0.into(),
|
||||
},
|
||||
background: Color::WHITE.into(),
|
||||
border_width: 1.0,
|
||||
border_color: Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded box filled with a linear gradient. Angles run clockwise from
|
||||
/// "bottom to top": a quarter turn is left to right, a half turn top to bottom.
|
||||
fn gradient_box(angle: f32, stops: &[(f32, Color)]) -> container::Style {
|
||||
let linear = stops.iter().fold(
|
||||
iced::gradient::Linear::new(Radians(angle)),
|
||||
|linear, &(at, color)| linear.add_stop(at, color),
|
||||
);
|
||||
container::Style {
|
||||
background: Some(Background::Gradient(Gradient::Linear(linear))),
|
||||
border: Border {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.35),
|
||||
width: 1.0,
|
||||
radius: 5.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn hue_stops() -> Vec<(f32, Color)> {
|
||||
(0..=6)
|
||||
.map(|step| {
|
||||
let at = step as f32 / 6.0;
|
||||
(at, hsb_color(at * 360.0, 1.0, 1.0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hsb_color(h: f32, s: f32, b: f32) -> Color {
|
||||
rgb_color(math::hsb_to_rgb(h, s, b))
|
||||
}
|
||||
|
||||
pub(super) fn rgb_color((r, g, b): (u8, u8, u8)) -> Color {
|
||||
Color::from_rgb8(r, g, b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sliders_produce_colours() {
|
||||
let mut e = ColorEditor::new((255, 0, 0));
|
||||
assert!(e.update(EditorMsg::Hue(120.0)));
|
||||
assert_eq!(e.rgb(), (0, 255, 0));
|
||||
e.update(EditorMsg::Red(10));
|
||||
assert_eq!(e.rgb(), (10, 255, 0));
|
||||
assert!(!e.update(EditorMsg::Mode(EditorMode::Rgb)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_survives_a_trip_through_gray() {
|
||||
let mut e = ColorEditor::new((0, 0, 255));
|
||||
e.update(EditorMsg::Saturation(0.0));
|
||||
e.update(EditorMsg::Saturation(100.0));
|
||||
assert_eq!(e.rgb(), (0, 0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_applies_six_digits_while_typing() {
|
||||
let mut e = ColorEditor::new((0, 0, 0));
|
||||
assert!(!e.update(EditorMsg::HexInput("#39BF".into())));
|
||||
assert!(e.update(EditorMsg::HexInput("#39BFFC".into())));
|
||||
assert_eq!(e.rgb(), (57, 191, 252));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_hex_waits_for_enter() {
|
||||
let mut e = ColorEditor::new((0, 0, 0));
|
||||
assert!(!e.update(EditorMsg::HexInput("#3bf".into())));
|
||||
assert!(e.update(EditorMsg::HexSubmit));
|
||||
assert_eq!(e.rgb(), (0x33, 0xBB, 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Pick a colour from anywhere inside the application window.
|
||||
//!
|
||||
//! While active, a transparent layer covers the window with a crosshair. The
|
||||
//! first mouse move after the panel is hidden asks iced for a screenshot of
|
||||
//! the window; a magnifier beside the cursor shows the pixels around it from
|
||||
//! that screenshot, and a click picks the centre pixel.
|
||||
|
||||
use super::{math, ColorPickerMsg};
|
||||
use crate::app::Message;
|
||||
use iced::widget::{column, container, mouse_area, pin, row, stack, text, Space};
|
||||
use iced::window::Screenshot;
|
||||
use iced::{Border, Color, Element, Length, Point, Theme};
|
||||
|
||||
/// Pixels shown across the magnifier (odd, so one sits in the centre).
|
||||
const GRID: i32 = 11;
|
||||
const CELL: f32 = 10.0;
|
||||
/// Gap between the cursor and the magnifier.
|
||||
const OFFSET: f32 = 20.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EyedropperMsg {
|
||||
Start,
|
||||
Move(Point),
|
||||
Shot(Screenshot),
|
||||
Pick,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Eyedropper {
|
||||
shot: Option<Screenshot>,
|
||||
requested: bool,
|
||||
cursor: Option<Point>,
|
||||
}
|
||||
|
||||
impl Eyedropper {
|
||||
/// Track the cursor. Returns true when the window screenshot should be
|
||||
/// requested now — the panel is gone from the screen by then.
|
||||
pub fn track(&mut self, cursor: Point) -> bool {
|
||||
self.cursor = Some(cursor);
|
||||
let request = self.shot.is_none() && !self.requested;
|
||||
self.requested = true;
|
||||
request
|
||||
}
|
||||
|
||||
pub fn set_shot(&mut self, shot: Screenshot) {
|
||||
self.shot = Some(shot);
|
||||
}
|
||||
|
||||
/// The pixel under the cursor, once the screenshot has arrived.
|
||||
pub fn picked(&self) -> Option<(u8, u8, u8)> {
|
||||
sample(self.shot.as_ref()?, self.cursor?, 0, 0)
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let eye = |m| Message::ColorPicker(ColorPickerMsg::Eyedropper(m));
|
||||
let catcher = mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
|
||||
.on_move(move |p| eye(EyedropperMsg::Move(p)))
|
||||
.on_press(eye(EyedropperMsg::Pick))
|
||||
.interaction(iced::mouse::Interaction::Crosshair);
|
||||
|
||||
let (Some(shot), Some(cursor)) = (self.shot.as_ref(), self.cursor) else {
|
||||
return catcher.into();
|
||||
};
|
||||
|
||||
let half = GRID / 2;
|
||||
let mut grid = column![];
|
||||
for dy in -half..=half {
|
||||
let mut line = row![];
|
||||
for dx in -half..=half {
|
||||
let fill = sample(shot, cursor, dx, dy).map_or(Color::TRANSPARENT, rgb);
|
||||
let centre = dx == 0 && dy == 0;
|
||||
line = line.push(container(Space::new()).width(CELL).height(CELL).style(
|
||||
move |_theme: &Theme| container::Style {
|
||||
background: Some(fill.into()),
|
||||
border: Border {
|
||||
color: if centre {
|
||||
Color::WHITE
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
},
|
||||
width: if centre { 1.5 } else { 0.0 },
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
grid = grid.push(line);
|
||||
}
|
||||
let (r, g, b) = self.picked().unwrap_or((0, 0, 0));
|
||||
let loupe = container(
|
||||
column![
|
||||
grid,
|
||||
text(math::hex_string(r, g, b))
|
||||
.size(11)
|
||||
.font(iced::Font::MONOSPACE),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(iced::Center),
|
||||
)
|
||||
.padding(4)
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(palette.background.base.color.into()),
|
||||
text_color: Some(palette.background.base.text),
|
||||
border: Border {
|
||||
color: palette.background.strong.color,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the magnifier on screen: flip it to the other side of the
|
||||
// cursor near the right and bottom edges.
|
||||
let size = GRID as f32 * CELL + 8.0;
|
||||
let window_w = shot.size.width as f32 / shot.scale_factor;
|
||||
let window_h = shot.size.height as f32 / shot.scale_factor;
|
||||
let x = if cursor.x + OFFSET + size > window_w {
|
||||
cursor.x - OFFSET - size
|
||||
} else {
|
||||
cursor.x + OFFSET
|
||||
};
|
||||
let y = if cursor.y + OFFSET + size + 20.0 > window_h {
|
||||
cursor.y - OFFSET - size - 20.0
|
||||
} else {
|
||||
cursor.y + OFFSET
|
||||
};
|
||||
stack![catcher, pin(loupe).x(x).y(y)].into()
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb((r, g, b): (u8, u8, u8)) -> Color {
|
||||
Color::from_rgb8(r, g, b)
|
||||
}
|
||||
|
||||
/// The screenshot pixel `dx`, `dy` physical pixels away from a logical point.
|
||||
fn sample(shot: &Screenshot, at: Point, dx: i32, dy: i32) -> Option<(u8, u8, u8)> {
|
||||
let x = (at.x * shot.scale_factor) as i64 + i64::from(dx);
|
||||
let y = (at.y * shot.scale_factor) as i64 + i64::from(dy);
|
||||
let (w, h) = (i64::from(shot.size.width), i64::from(shot.size.height));
|
||||
if !(0..w).contains(&x) || !(0..h).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
let px = shot.rgba.get(i..i + 3)?;
|
||||
Some((px[0], px[1], px[2]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shot() -> Screenshot {
|
||||
// 4×2 physical pixels at scale 2: pixel (x, y) has red = x, green = y.
|
||||
let mut rgba = Vec::new();
|
||||
for y in 0..2u8 {
|
||||
for x in 0..4u8 {
|
||||
rgba.extend([x, y, 9, 255]);
|
||||
}
|
||||
}
|
||||
Screenshot::new(rgba, iced::Size::new(4, 2), 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_physical_pixels_from_logical_points() {
|
||||
let s = shot();
|
||||
assert_eq!(sample(&s, Point::new(1.0, 0.5), 0, 0), Some((2, 1, 9)));
|
||||
assert_eq!(sample(&s, Point::new(1.0, 0.5), 1, -1), Some((3, 0, 9)));
|
||||
assert_eq!(sample(&s, Point::new(1.9, 0.0), 1, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asks_for_one_screenshot_and_picks_after_it_arrives() {
|
||||
let mut e = Eyedropper::default();
|
||||
assert!(e.track(Point::new(0.0, 0.0)));
|
||||
assert!(!e.track(Point::new(1.0, 0.0)));
|
||||
assert_eq!(e.picked(), None);
|
||||
e.set_shot(shot());
|
||||
assert_eq!(e.picked(), Some((2, 0, 9)));
|
||||
}
|
||||
}
|
||||
@@ -73,24 +73,9 @@ pub fn color_rgb(color: AcadColor) -> Option<(u8, u8, u8)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The closest index colour to an RGB value, and whether it is an exact match.
|
||||
pub fn nearest_aci_match(r: u8, g: u8, b: u8) -> (u8, bool) {
|
||||
let mut best = 7;
|
||||
let mut best_distance = u32::MAX;
|
||||
for index in 1..=255 {
|
||||
let Some((ar, ag, ab)) = aci_rgb(index) else {
|
||||
continue;
|
||||
};
|
||||
let dr = i32::from(r) - i32::from(ar);
|
||||
let dg = i32::from(g) - i32::from(ag);
|
||||
let db = i32::from(b) - i32::from(ab);
|
||||
let distance = (dr * dr + dg * dg + db * db) as u32;
|
||||
if distance < best_distance {
|
||||
best = index;
|
||||
best_distance = distance;
|
||||
}
|
||||
}
|
||||
(best, best_distance == 0)
|
||||
/// The closest index colour to an RGB value.
|
||||
pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 {
|
||||
codec::types::aci_table::nearest_aci(r, g, b)
|
||||
}
|
||||
|
||||
/// `#RRGGBB`, upper case.
|
||||
@@ -222,11 +207,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_aci_reports_exact_matches() {
|
||||
assert_eq!(nearest_aci_match(255, 0, 0), (1, true));
|
||||
let (index, exact) = nearest_aci_match(250, 5, 5);
|
||||
assert_eq!(index, 1);
|
||||
assert!(!exact);
|
||||
fn nearest_aci_comes_from_the_codec_table() {
|
||||
assert_eq!(nearest_aci(255, 0, 0), 1);
|
||||
assert_eq!(nearest_aci(250, 5, 5), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
//! properties panel and every style editor so colour selection looks and
|
||||
//! behaves the same everywhere.
|
||||
|
||||
pub mod editor;
|
||||
pub mod eyedropper;
|
||||
pub mod math;
|
||||
pub mod picker;
|
||||
pub mod widgets;
|
||||
|
||||
pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome, TrueColorMode};
|
||||
pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::t;
|
||||
@@ -42,7 +44,7 @@ pub fn color_to_aci_string(c: AcadColor) -> String {
|
||||
|
||||
/// Return the closest CAD Color Index for an RGB colour.
|
||||
pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 {
|
||||
math::nearest_aci_match(r, g, b).0
|
||||
math::nearest_aci(r, g, b)
|
||||
}
|
||||
|
||||
/// Decode an ACI integer string back into an `AcadColor`.
|
||||
|
||||
+343
-599
File diff suppressed because it is too large.
Load diff
@@ -1,319 +0,0 @@
|
||||
//! A slider whose track is a colour gradient, with a white bar handle.
|
||||
//!
|
||||
//! Used for the HSB and RGB channels of the Select Color panel and, upright,
|
||||
//! for the hue strip beside the saturation/brightness field. The value is
|
||||
//! normalised to 0–1; callers map it to their own range.
|
||||
|
||||
use iced::advanced::layout::{self, Layout};
|
||||
use iced::advanced::widget::{self, tree, Widget};
|
||||
use iced::advanced::{mouse, renderer, Shell};
|
||||
use iced::gradient::Linear;
|
||||
use iced::{
|
||||
Background, Border, Color, Element, Event, Gradient, Length, Point, Radians, Rectangle,
|
||||
Renderer, Shadow, Size, Theme, Vector,
|
||||
};
|
||||
|
||||
const TRACK_THICKNESS: f32 = 18.0;
|
||||
const TRACK_RADIUS: f32 = 5.0;
|
||||
const HANDLE_THICKNESS: f32 = 8.0;
|
||||
const HANDLE_RADIUS: f32 = 3.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Orientation {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
pub struct GradientSlider<'a, Message> {
|
||||
value: f32,
|
||||
stops: Vec<(f32, Color)>,
|
||||
on_change: Box<dyn Fn(f32) -> Message + 'a>,
|
||||
orientation: Orientation,
|
||||
/// How far one mouse-wheel notch moves the value (0–1 units).
|
||||
wheel_step: f32,
|
||||
width: Length,
|
||||
height: Length,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
impl<'a, Message> GradientSlider<'a, Message> {
|
||||
/// `value` is 0–1. `stops` are (offset 0–1, colour) pairs, at most eight.
|
||||
pub fn new(
|
||||
value: f32,
|
||||
stops: Vec<(f32, Color)>,
|
||||
on_change: impl Fn(f32) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
value: value.clamp(0.0, 1.0),
|
||||
stops,
|
||||
on_change: Box::new(on_change),
|
||||
orientation: Orientation::Horizontal,
|
||||
wheel_step: 0.01,
|
||||
width: Length::Fill,
|
||||
height: Length::Fixed(26.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vertical(mut self) -> Self {
|
||||
self.orientation = Orientation::Vertical;
|
||||
self.width = Length::Fixed(22.0);
|
||||
self.height = Length::Fill;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wheel_step(mut self, step: f32) -> Self {
|
||||
self.wheel_step = step;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The span the handle centre travels along, in the slider's main axis.
|
||||
fn travel(&self, bounds: Rectangle) -> (f32, f32) {
|
||||
let inset = HANDLE_THICKNESS / 2.0;
|
||||
match self.orientation {
|
||||
Orientation::Horizontal => (bounds.x + inset, bounds.x + bounds.width - inset),
|
||||
Orientation::Vertical => (bounds.y + inset, bounds.y + bounds.height - inset),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_at(&self, bounds: Rectangle, cursor: Point) -> f32 {
|
||||
let (start, end) = self.travel(bounds);
|
||||
let along = match self.orientation {
|
||||
Orientation::Horizontal => cursor.x,
|
||||
Orientation::Vertical => cursor.y,
|
||||
};
|
||||
if end <= start {
|
||||
return 0.0;
|
||||
}
|
||||
((along - start) / (end - start)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn track_bounds(&self, bounds: Rectangle) -> Rectangle {
|
||||
match self.orientation {
|
||||
Orientation::Horizontal => {
|
||||
let thickness = TRACK_THICKNESS.min(bounds.height);
|
||||
Rectangle {
|
||||
y: bounds.y + (bounds.height - thickness) / 2.0,
|
||||
height: thickness,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
Orientation::Vertical => bounds,
|
||||
}
|
||||
}
|
||||
|
||||
fn gradient(&self) -> Background {
|
||||
// Gradient angles run clockwise from "bottom to top": a quarter turn
|
||||
// is left to right, a half turn top to bottom.
|
||||
let angle = match self.orientation {
|
||||
Orientation::Horizontal => std::f32::consts::FRAC_PI_2,
|
||||
Orientation::Vertical => std::f32::consts::PI,
|
||||
};
|
||||
let linear = self
|
||||
.stops
|
||||
.iter()
|
||||
.fold(Linear::new(Radians(angle)), |linear, &(offset, color)| {
|
||||
linear.add_stop(offset, color)
|
||||
});
|
||||
Background::Gradient(Gradient::Linear(linear))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message> Widget<Message, Theme, Renderer> for GradientSlider<'_, Message> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(self.width, self.height)
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
_tree: &mut widget::Tree,
|
||||
_renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::atomic(limits, self.width, self.height)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let bounds = layout.bounds();
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let Some(position) = cursor.position_over(bounds) {
|
||||
state.dragging = true;
|
||||
shell.publish((self.on_change)(self.value_at(bounds, position)));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(position) = cursor.position() {
|
||||
shell.publish((self.on_change)(self.value_at(bounds, position)));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
|
||||
state.dragging = false;
|
||||
shell.capture_event();
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
|
||||
let lines = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } => *y,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => y.signum(),
|
||||
};
|
||||
if lines != 0.0 {
|
||||
// Scrolling up raises the value on a horizontal slider and
|
||||
// moves the handle up on an upright one.
|
||||
let direction = match self.orientation {
|
||||
Orientation::Horizontal => lines.signum(),
|
||||
Orientation::Vertical => -lines.signum(),
|
||||
};
|
||||
let next = (self.value + direction * self.wheel_step).clamp(0.0, 1.0);
|
||||
shell.publish((self.on_change)(next));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
if tree.state.downcast_ref::<State>().dragging {
|
||||
mouse::Interaction::Grabbing
|
||||
} else if cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
_theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
use iced::advanced::Renderer as _;
|
||||
|
||||
let bounds = layout.bounds();
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: self.track_bounds(bounds),
|
||||
border: Border {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.35),
|
||||
width: 1.0,
|
||||
radius: TRACK_RADIUS.into(),
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
self.gradient(),
|
||||
);
|
||||
|
||||
let (start, end) = self.travel(bounds);
|
||||
let centre = start + (end - start) * self.value;
|
||||
let handle = match self.orientation {
|
||||
Orientation::Horizontal => Rectangle {
|
||||
x: centre - HANDLE_THICKNESS / 2.0,
|
||||
y: bounds.y,
|
||||
width: HANDLE_THICKNESS,
|
||||
height: bounds.height,
|
||||
},
|
||||
Orientation::Vertical => Rectangle {
|
||||
x: bounds.x - 3.0,
|
||||
y: centre - HANDLE_THICKNESS / 2.0,
|
||||
width: bounds.width + 6.0,
|
||||
height: HANDLE_THICKNESS,
|
||||
},
|
||||
};
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: handle,
|
||||
border: Border {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
width: 1.0,
|
||||
radius: HANDLE_RADIUS.into(),
|
||||
},
|
||||
shadow: Shadow {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.55),
|
||||
offset: Vector::new(0.0, 1.0),
|
||||
blur_radius: 4.0,
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message: 'a> From<GradientSlider<'a, Message>> for Element<'a, Message> {
|
||||
fn from(slider: GradientSlider<'a, Message>) -> Self {
|
||||
Element::new(slider)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slider() -> GradientSlider<'static, f32> {
|
||||
GradientSlider::new(0.5, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], |v| v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_maps_to_value_inside_handle_travel() {
|
||||
let bounds = Rectangle::new(Point::ORIGIN, Size::new(108.0, 26.0));
|
||||
let s = slider();
|
||||
assert_eq!(s.value_at(bounds, Point::new(4.0, 10.0)), 0.0);
|
||||
assert_eq!(s.value_at(bounds, Point::new(104.0, 10.0)), 1.0);
|
||||
assert!((s.value_at(bounds, Point::new(54.0, 10.0)) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(s.value_at(bounds, Point::new(-50.0, 10.0)), 0.0);
|
||||
assert_eq!(s.value_at(bounds, Point::new(500.0, 10.0)), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_slider_reads_the_y_axis() {
|
||||
let bounds = Rectangle::new(Point::ORIGIN, Size::new(22.0, 108.0));
|
||||
let s = slider().vertical();
|
||||
assert_eq!(s.value_at(bounds, Point::new(11.0, 4.0)), 0.0);
|
||||
assert_eq!(s.value_at(bounds, Point::new(11.0, 104.0)), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Widgets the Select Color panel needs that iced does not provide.
|
||||
|
||||
mod color_area;
|
||||
mod gradient_slider;
|
||||
|
||||
pub use color_area::ColorArea;
|
||||
pub use gradient_slider::GradientSlider;
|
||||
@@ -62,6 +62,7 @@ static LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg");
|
||||
// All are black-on-transparent; recolour them at the call site with [`tinted`].
|
||||
pub static CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg");
|
||||
pub static CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg");
|
||||
pub static EYEDROPPER: &[u8] = include_bytes!("../../assets/icons/ui/eyedropper.svg");
|
||||
pub static PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg");
|
||||
pub static REFRESH: &[u8] = include_bytes!("../../assets/icons/ui/refresh.svg");
|
||||
pub static SWAP: &[u8] = include_bytes!("../../assets/icons/ui/swap.svg");
|
||||
|
||||
+15
-24
@@ -157,22 +157,18 @@ impl<T> fmt::Display for Labelled<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A background swatch that opens the colour wheel when clicked.
|
||||
/// A background swatch that opens the colour picker when clicked.
|
||||
///
|
||||
/// The wheel sits alongside the hex field rather than replacing it: typing
|
||||
/// The picker sits alongside the hex field rather than replacing it: typing
|
||||
/// `#1E1E1E` stays the fastest way to reproduce an exact colour, while the
|
||||
/// wheel is for choosing one by eye.
|
||||
fn bg_swatch_picker<'a>(
|
||||
target: crate::app::BgTarget,
|
||||
rgb: [u8; 3],
|
||||
open: Option<crate::app::BgTarget>,
|
||||
) -> Element<'a, Message> {
|
||||
let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]);
|
||||
/// picker is for choosing one by eye.
|
||||
fn bg_swatch_picker<'a>(target: crate::app::BgTarget, rgb: [u8; 3]) -> Element<'a, Message> {
|
||||
let [r, g, b] = rgb;
|
||||
let swatch = container(Space::new())
|
||||
.width(28)
|
||||
.height(22)
|
||||
.style(move |theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(colour)),
|
||||
background: Some(Background::Color(iced::Color::from_rgb8(r, g, b))),
|
||||
border: Border {
|
||||
color: theme.palette().background.strong.color,
|
||||
width: 1.0,
|
||||
@@ -180,17 +176,13 @@ fn bg_swatch_picker<'a>(
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let underlay = button(swatch)
|
||||
.on_press(Message::BgPickerOpen(target))
|
||||
button(swatch)
|
||||
.on_press(Message::OpenColorWindow(
|
||||
crate::app::ColorPickTarget::Background(target),
|
||||
codec::types::Color::Rgb { r, g, b },
|
||||
))
|
||||
.padding(0)
|
||||
.style(button::text);
|
||||
iced_aw::ColorPicker::new(
|
||||
open == Some(target),
|
||||
colour,
|
||||
underlay,
|
||||
Message::BgPickerCancel,
|
||||
Message::BgPickerSubmit,
|
||||
)
|
||||
.style(button::text)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -227,7 +219,6 @@ pub fn view_window<'a>(
|
||||
model_bg_input: &'a str,
|
||||
paper_bg_input: &'a str,
|
||||
desk_bg_input: &'a str,
|
||||
bg_picker: Option<crate::app::BgTarget>,
|
||||
dirty: bool,
|
||||
close_confirm: bool,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
@@ -742,7 +733,7 @@ pub fn view_window<'a>(
|
||||
display = display.push(Space::new().height(10)).push(
|
||||
row![
|
||||
text(crate::t!("Model background")).size(12).width(140),
|
||||
bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb, bg_picker),
|
||||
bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb),
|
||||
text_input("#RRGGBB", model_bg_input)
|
||||
.on_input(Message::ModelSpaceBgChanged)
|
||||
.width(150),
|
||||
@@ -755,7 +746,7 @@ pub fn view_window<'a>(
|
||||
display = display.push(Space::new().height(10)).push(
|
||||
row![
|
||||
text(crate::t!("Paper background")).size(12).width(140),
|
||||
bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb, bg_picker),
|
||||
bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb),
|
||||
text_input("#RRGGBB", paper_bg_input)
|
||||
.on_input(Message::PaperSpaceBgChanged)
|
||||
.width(150),
|
||||
@@ -767,7 +758,7 @@ pub fn view_window<'a>(
|
||||
display = display.push(Space::new().height(10)).push(
|
||||
row![
|
||||
text(crate::t!("Desk surround")).size(12).width(140),
|
||||
bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb, bg_picker),
|
||||
bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb),
|
||||
text_input("#RRGGBB", desk_bg_input)
|
||||
.on_input(Message::DeskSpaceBgChanged)
|
||||
.width(150),
|
||||
|
||||
Reference in new issue
Block a user