Files
amane-config/src/settings.rs
T
server2 5d322b940d Glass designs as settings: solid, frosted, liquid on every surface
appearance.surface.design picks the look (chips in the config window); each
design's opacity, rim and light are settings. surface::shell now draws the
island, idle pill, workspace and tray pills, tray menu, control centre and
popup, so a design applies everywhere. On see-through glass faces fade with
.opacity(). Shell text settings can list their allowed words (chips).
2026-10-15 03:05:06 +02:00

729 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Command;
use amane::{
Center, Color, Column, Cursor, End, Full, Key, Padding, Rectangle, Row, ScrollArea, Service, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, Window, children, close_window, open_window, window_size,
};
use crate::config::{self, Kind, Opt};
use crate::hypr::{self, Hypr, HyprOption};
use crate::parse::{HyprKind, Value};
use crate::theme::{self, Colors};
/*
* the config window: every shell setting (config.rs) and every Hyprland option (hypr.rs)
* on pages in a sidebar, with a search over all of them. a normal window, opened from the
* control centre's Settings button or `amane ipc call config open|toggle`
*/
const NAME: &str = "magic-island-settings";
const TITLE: &str = "Magic Island settings";
// the window's own layout; its text sizes come from appearance.text like the rest
const SIDEBAR_W: f32 = 240.0;
const PAD: f32 = 20.0;
const CONTROL_W: f32 = 320.0;
const FIELD_H: f32 = 30.0;
const SLIDER_W: f32 = 200.0;
const NUMBER_W: f32 = 104.0;
const RESET_W: f32 = 56.0;
const SEARCH: &str = "settings-search";
pub struct Ui {
// a shell page ("island") or a Hyprland one ("hyprland:decoration")
page: String,
query: String,
message: Option<(String, bool)>,
hover: Option<String>,
}
impl Service for Ui {
fn new() -> Self {
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None }
}
fn listen() {}
}
pub fn open() {
open_window(NAME, view);
hypr::load();
}
// whether the window is open: amane can't say, Hyprland lists it by title
fn is_open() -> bool {
Command::new("hyprctl").arg("clients").output().is_ok_and(|done| String::from_utf8_lossy(&done.stdout).contains(&format!("title: {TITLE}\n")))
}
/*
* `amane ipc call config ...`: open, close, toggle, page <id>, search <words>,
* get <path>, set <path> <value>, reset <path>, status. paths as in config.toml;
* hyprland.<option> ones go to Hyprland
*/
pub fn command(arguments: &[String]) -> String {
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
match words.as_slice() {
["open"] => open(),
["close"] => close_window(NAME),
["toggle"] => {
if is_open() {
close_window(NAME);
} else {
open();
}
}
["page", page] => {
go_to(page);
open();
}
["search", rest @ ..] => {
Ui::write().query = rest.join(" ");
open();
}
["get", path] => {
return match config::option(path) {
Some(option) => config::shown(&config::current(option)),
None => match path.strip_prefix("hyprland.") {
Some(key) => Hypr::read().options.iter().find(|option| option.key() == key).map_or_else(|| String::from("not loaded; open the window first"), |option| option.current.clone()),
None => format!("no setting {path}"),
},
};
}
["set", path, value @ ..] => return set_text(path, &value.join(" ")).map_or_else(|why| why, |()| String::from("ok")),
["reset", path] => {
return match path.strip_prefix("hyprland.") {
Some(key) => {
hypr::reset(key);
String::from("ok")
}
None => config::reset(path).map_or_else(|why| why, |()| String::from("ok")),
};
}
["status"] => {
let ui = Ui::read();
return format!("open={} page={} errors={} search={}", is_open(), ui.page, config::errors().len(), ui.query);
}
_ => return String::from("usage: config open|close|toggle|page <id>|search <words>|get <path>|set <path> <value>|reset <path>|status"),
}
String::from("ok")
}
fn go_to(page: &str) {
let mut ui = Ui::write();
ui.page = page.to_string();
ui.query.clear();
ui.message = None;
if page.starts_with("hyprland:") {
hypr::load();
}
}
// a typed value for a setting; Hyprland's are checked here when the list is known, then applied by hypr.rs
fn set_text(path: &str, text: &str) -> Result<(), String> {
if let Some(key) = path.strip_prefix("hyprland.") {
let kind = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.kind);
if let Some(kind) = kind {
crate::parse::hypr_lua(kind, text).map_err(|why| format!("{key} {why}"))?;
}
hypr::set(key, text);
return Ok(());
}
let option = config::option(path).ok_or(format!("no setting {path}"))?;
config::from_text(option, text).and_then(|value| config::set(path, value))
}
fn say(result: Result<(), String>, done: String) {
Ui::write().message = Some(match result {
Ok(()) => (done, false),
Err(why) => (why, true),
});
}
pub fn view() -> Window {
theme::begin();
let colors = theme::now();
let ui = Ui::read();
let hypr = Hypr::read();
let (width, height) = match window_size() {
(0.0, _) | (_, 0.0) => (1100.0, 760.0),
size => size,
};
let main_w = (width - SIDEBAR_W).max(400.0);
let row_w = main_w - 2.0 * PAD - 12.0;
let query = ui.query.trim().to_lowercase();
// the rows to show: a search goes through every page
let mut sections: Vec<Box<dyn Widget>> = Vec::new();
let shell_page = !ui.page.starts_with("hyprland:");
let category = ui.page.strip_prefix("hyprland:").unwrap_or("");
let title = if !query.is_empty() {
format!("Search: {}", ui.query.trim())
} else if shell_page {
config::title(&ui.page)
} else {
format!("Hyprland · {}", config::title(category))
};
let mut notes: Vec<(String, bool)> = Vec::new();
if shell_page || !query.is_empty() {
notes.extend(config::errors().into_iter().map(|error| (format!("config.toml {error}"), true)));
}
if (!shell_page || !query.is_empty()) && hypr.loaded && !hypr.required && !config::overrides().is_empty() {
notes.push((String::from("Hyprland options set here apply now, but a Hyprland reload drops them: add require(\"amane\") as the last line of ~/.config/hypr/hyprland.lua"), false));
}
for note in notes {
sections.push(Box::new(banner(&note.0, note.1, row_w)));
}
// shell settings, by group
let mut group = "";
let mut page = "";
for option in config::OPTIONS {
let shown = if query.is_empty() { shell_page && option.module() == ui.page } else { option.path.contains(&query) || option.help.to_lowercase().contains(&query) };
if !shown {
continue;
}
if option.group() != group || option.module() != page {
let heading = if query.is_empty() { config::title(option.group()) } else { format!("{} › {}", config::title(option.module()), config::title(option.group())) };
sections.push(Box::new(heading_row(&heading, row_w)));
group = option.group();
page = option.module();
}
sections.push(Box::new(shell_row(option, row_w, &colors)));
}
// Hyprland's, by group
if !shell_page || !query.is_empty() {
if !hypr.loaded {
sections.push(Box::new(Text::new("Reading Hyprland's options…").size(config::appearance::text::body()).color(colors.dim)));
}
let mut group: Option<(&str, &str)> = None;
for option in &hypr.options {
let shown = if query.is_empty() { option.category() == category } else { option.name.contains(&query) || option.help.to_lowercase().contains(&query) };
if !shown {
continue;
}
let here = (option.category(), option.group());
if group != Some(here) {
let named = if option.group().is_empty() { String::new() } else { config::title(option.group()) };
let heading = match (query.is_empty(), named.is_empty()) {
(true, true) => String::from("General"),
(true, false) => named,
(false, true) => format!("Hyprland › {}", config::title(option.category())),
(false, false) => format!("Hyprland › {} › {named}", config::title(option.category())),
};
sections.push(Box::new(heading_row(&heading, row_w)));
group = Some(here);
}
sections.push(Box::new(hypr_row(option, row_w, &colors)));
}
}
if sections.is_empty() {
sections.push(Box::new(Text::new("Nothing matches.").size(config::appearance::text::body()).color(colors.dim)));
}
let message = if shell_page { ui.message.clone() } else { hypr.message.clone().or(ui.message.clone()) };
let (words, wrong) = message.unwrap_or_else(|| (format!("Saved to {} as you change things; Enter applies a typed value", config::file().display()), false));
let footer = Text::new(words).size(config::appearance::text::small()).color(if wrong { config::appearance::overlays::low_battery() } else { colors.dim }).elide();
let main = Rectangle::new()
.width(main_w)
.height(height)
.padding(Padding { top: PAD, right: PAD, bottom: 12.0, left: PAD })
.child(Column::new(children![
Text::new(title).size(config::appearance::text::title()).weight(Weight::Bold).color(colors.text),
ScrollArea::new("settings-rows", Column::new(sections).gap(6.0)).width(main_w - 2.0 * PAD).height(height - PAD - 12.0 - 40.0 - 24.0),
Rectangle::new().width(main_w - 2.0 * PAD).height(20.0).align_child(Start, Center).child(footer),
]).gap(10.0));
Window::new()
.title(TITLE)
.size(1100.0, 760.0)
.resizable(true)
// Escape closes it, also from a field (a typed value only applies on Enter)
.on_key(|key| {
if key == Key::Escape {
close_window(NAME);
}
})
.child(
Rectangle::new()
.width(width)
.height(height)
.fill(colors.surface_low)
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
)
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
for module in config::modules() {
entries.push(Box::new(entry(module.to_string(), config::title(module), ui, colors)));
}
entries.push(Box::new(caption("HYPRLAND", colors)));
let mut categories: Vec<&str> = Vec::new();
for option in &hypr.options {
if !categories.contains(&option.category()) {
categories.push(option.category());
}
}
if categories.is_empty() {
entries.push(Box::new(Text::new(if hypr.loaded { "Hyprland didn't answer" } else { "Reading…" }).size(config::appearance::text::small()).color(colors.dim)));
}
for category in categories {
entries.push(Box::new(entry(format!("hyprland:{category}"), config::title(category), ui, colors)));
}
synced(SEARCH, &ui.query);
let search = Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(FIELD_H)
.radius(10.0)
.fill(config::appearance::overlays::chip())
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
Ui::write().query = text;
}));
Rectangle::new()
.width(SIDEBAR_W)
.height(height)
.fill(colors.surface)
.padding(12.0)
.child(Column::new(children![search, ScrollArea::new("settings-pages", Column::new(entries).gap(2.0)).width(SIDEBAR_W - 24.0).height(height - 24.0 - FIELD_H - 12.0)]).gap(12.0))
}
fn caption(text: &str, colors: &Colors) -> Rectangle {
Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(30.0)
.padding(Padding { top: 12.0, right: 0.0, bottom: 0.0, left: 10.0 })
.child(Text::new(text).size(config::appearance::text::tiny()).weight(Weight::Bold).color(colors.dim))
}
fn entry(id: String, title: String, ui: &Ui, colors: &Colors) -> Rectangle {
let chosen = ui.query.is_empty() && ui.page == id;
let lit = ui.hover.as_deref() == Some(id.as_str());
let fill = if chosen { colors.accent } else if lit { config::appearance::overlays::chip_hover() } else { Color::TRANSPARENT };
let (hovered, clicked) = (id.clone(), id);
Rectangle::new()
.width(SIDEBAR_W - 24.0)
.height(30.0)
.radius(8.0)
.fill(fill)
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_hover(move |inside| {
let mut ui = Ui::write();
if inside {
ui.hover = Some(hovered.clone());
} else if ui.hover.as_deref() == Some(hovered.as_str()) {
ui.hover = None;
}
})
.on_click(move |_| {
SYNCED.with_borrow_mut(|synced| synced.remove(SEARCH));
go_to(&clicked);
})
.child(Text::new(title).size(config::appearance::text::body()).color(if chosen { colors.on_accent } else { colors.text }).elide())
}
fn heading_row(text: &str, width: f32) -> Rectangle {
Rectangle::new()
.width(width)
.height(34.0)
.padding(Padding { top: 14.0, right: 0.0, bottom: 0.0, left: 4.0 })
.child(Text::new(text.to_uppercase()).size(config::appearance::text::small()).weight(Weight::Bold).color(theme::now().accent))
}
fn banner(text: &str, wrong: bool, width: f32) -> Rectangle {
let colors = theme::now();
let words = Text::new(text).size(config::appearance::text::small()).color(colors.text).wrap();
let height = words.height_in(width - 24.0) + 16.0;
Rectangle::new()
.width(width)
.height(height)
.radius(10.0)
.fill(if wrong { faded(config::appearance::overlays::low_battery(), 60) } else { config::appearance::overlays::chip() })
.padding(Padding { top: 8.0, right: 12.0, bottom: 8.0, left: 12.0 })
.child(words)
}
fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
/*
* one setting: its label and key, the help under them, the control on the right, and
* Reset while it differs from its default
*/
fn row(label: String, key: String, help: String, control: Box<dyn Widget>, reset: Option<Box<dyn Fn()>>, width: f32) -> Rectangle {
let colors = theme::now();
let text_w = width - CONTROL_W - RESET_W - 2.0 * 14.0 - 24.0;
let help = Text::new(help).size(config::appearance::text::small()).color(colors.dim).wrap();
let help_h = help.height_in(text_w);
let label_h = config::appearance::text::body() * 1.5;
let height = (label_h + 2.0 + help_h).max(FIELD_H) + 20.0;
let reset: Box<dyn Widget> = match reset {
Some(reset) => Box::new(
Rectangle::new()
.width(RESET_W)
.height(24.0)
.radius(Full)
.fill(config::appearance::overlays::chip())
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| reset())
.child(Text::new("Reset").size(config::appearance::text::tiny()).color(colors.text)),
),
None => Box::new(Rectangle::new().width(RESET_W).height(24.0)),
};
Rectangle::new()
.width(width)
.height(height)
.radius(12.0)
.fill(config::appearance::overlays::chip())
.padding(Padding { top: 10.0, right: 14.0, bottom: 10.0, left: 14.0 })
.child(
Row::new(children![
Column::new(children![
Row::new(children![
Text::new(label).size(config::appearance::text::body()).weight(Weight::Bold).color(colors.text),
Text::new(key).size(config::appearance::text::tiny()).color(colors.dim).elide(),
])
.gap(10.0)
.align(Center)
.width(text_w)
.height(label_h),
Rectangle::new().width(text_w).height(help_h).child(help),
])
.gap(2.0),
Row::new(vec![control, reset]).gap(12.0).align(Center).justify(End).width(CONTROL_W + RESET_W + 12.0),
])
.justify(SpaceBetween)
.align(Center)
.width(width - 28.0),
)
}
fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
let value = config::current(option);
let control: Box<dyn Widget> = match (option.kind, &value) {
(Kind::Bool, Value::Bool(on)) => {
let on = *on;
Box::new(switch(on, move || say(config::set(option.path, Value::Bool(!on)), format!("{} = {}", option.path, !on))))
}
(Kind::Text, Value::Text(current)) if !option.choices.is_empty() => Box::new(chips(
option
.choices
.iter()
.map(|&word| (String::from(word), current == word, Box::new(move || say(config::set(option.path, Value::Text(String::from(word))), format!("{} = {word}", option.path))) as Box<dyn Fn()>))
.collect(),
colors,
)),
(Kind::Int | Kind::Float, _) if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == Kind::Int;
let number = value.number().unwrap_or(min);
Box::new(
Row::new(children![
slider((number - min) / (max - min), move |fraction| {
let picked = stepped(min + fraction * (max - min), min, max, whole);
let value = if whole { Value::Int(picked as i64) } else { Value::Float(picked) };
say(config::set(option.path, value), format!("{} = {}", option.path, config::shown(&if whole { Value::Int(picked as i64) } else { Value::Float(picked) })));
}),
field(option.path, &config::shown(&value), NUMBER_W, None, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))),
])
.gap(12.0)
.align(Center),
)
}
_ => {
let swatch = (option.kind == Kind::Color).then(|| value.text().and_then(config::color)).flatten();
Box::new(field(option.path, &config::shown(&value), CONTROL_W, swatch, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))))
}
};
let reset: Option<Box<dyn Fn()>> = (!config::is_default(option)).then(|| Box::new(move || say(config::reset(option.path), format!("{} is back to its default", option.path))) as Box<dyn Fn()>);
let mut help = option.help.to_string();
if !config::is_default(option) {
help.push_str(&format!(" · default {}", config::shown(&(option.default)())));
}
row(config::label(option.name()), option.path.to_string(), help, control, reset, width)
}
fn hypr_row(option: &HyprOption, width: f32, colors: &Colors) -> Rectangle {
let name = option.name;
let path = option.path();
let overridden = config::override_of(&path).is_some();
let control: Box<dyn Widget> = match option.kind {
HyprKind::Bool => {
let on = option.current == "true";
Box::new(switch(on, move || hypr::set(name, if on { "false" } else { "true" })))
}
HyprKind::Int if !option.choices.is_empty() => Box::new(choices(option, colors)),
HyprKind::Int | HyprKind::Float if option.range.is_some() => {
let (min, max) = option.range.unwrap_or((0.0, 1.0));
let whole = option.kind == HyprKind::Int;
let number: f64 = option.current.parse().unwrap_or(min);
Box::new(
Row::new(children![
slider((number - min) / (max - min), move |fraction| {
let picked = stepped(min + fraction * (max - min), min, max, whole);
hypr::set(name, &if whole { (picked as i64).to_string() } else { picked.to_string() });
}),
field(name, &option.current, NUMBER_W, None, move |text| hypr::set(name, &text)),
])
.gap(12.0)
.align(Center),
)
}
_ => {
let swatch = (option.kind == HyprKind::Gradient).then(|| first_color(&option.current)).flatten();
Box::new(field(name, &option.current, CONTROL_W, swatch, move |text| hypr::set(name, &text)))
}
};
let reset: Option<Box<dyn Fn()>> = overridden.then(|| Box::new(move || hypr::reset(name)) as Box<dyn Fn()>);
let mut help = option.help.clone();
if !option.default.is_empty() {
help.push_str(&format!(" · default {}", option.default));
}
if overridden {
help.push_str(" · set here");
} else if option.in_config {
help.push_str(" · set in your Hyprland config");
}
let label = option.label().replace("col.", "colour_").replace('.', "_");
row(config::label(&label), path, help, control, reset, width)
}
// "rgba(80d9ffff) ..." as its first colour, for the swatch
fn first_color(gradient: &str) -> Option<Color> {
let word = gradient.split_whitespace().next()?;
config::color(word.trim_start_matches("rgba(").trim_end_matches(')'))
}
// a value from a slider: whole numbers, or steps of about a hundredth of the range
fn stepped(value: f64, min: f64, max: f64, whole: bool) -> f64 {
let value = value.clamp(min, max);
if whole {
return value.round();
}
let step = 10f64.powf(((max - min).log10()).floor() - 2.0);
((value / step).round() * step * 1e6).round() / 1e6
}
fn switch(on: bool, toggle: impl Fn() + 'static) -> Rectangle {
let colors = theme::now();
Rectangle::new()
.width(46.0)
.height(26.0)
.radius(Full)
.fill(if on { colors.accent } else { config::appearance::overlays::track() })
.padding(4.0)
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| toggle())
.child(Rectangle::new().width(18.0).height(18.0).radius(Full).fill(if on { colors.on_accent } else { colors.dim }).translate(if on { 20.0 } else { 0.0 }, 0.0))
}
fn slider(fraction: f64, pick: impl Fn(f64) + Clone + 'static) -> Rectangle {
let colors = theme::now();
let fraction = fraction.clamp(0.0, 1.0) as f32;
let knob = 16.0;
let pick_drag = pick.clone();
Rectangle::new()
.width(SLIDER_W)
.height(FIELD_H)
.align_child(Start, Center)
.cursor(Cursor::Pointer)
.on_click(|_| {})
.on_drag(move |point| pick_drag(f64::from(((point.x - knob / 2.0) / (SLIDER_W - knob)).clamp(0.0, 1.0))))
.on_scroll(move |scroll| pick(f64::from((fraction + if scroll.y < 0.0 { 0.01 } else { -0.01 }).clamp(0.0, 1.0))))
.child(Stack::new(children![
Rectangle::new().width(SLIDER_W).height(6.0).radius(Full).fill(config::appearance::overlays::track()).translate(0.0, (knob - 6.0) / 2.0),
Rectangle::new().width(knob / 2.0 + fraction * (SLIDER_W - knob)).height(6.0).radius(Full).fill(colors.accent).translate(0.0, (knob - 6.0) / 2.0),
Rectangle::new().width(knob).height(knob).radius(Full).fill(colors.flame).translate(fraction * (SLIDER_W - knob), 0.0),
]).width(SLIDER_W).height(knob))
}
// a Hyprland number with named values: one chip per name
// a Hyprland option's named values as chips
fn choices(option: &HyprOption, colors: &Colors) -> Row {
let name = option.name;
chips(
option
.choices
.iter()
.map(|(label, value)| {
let value = value.to_string();
(label.replace('_', " "), option.current == value, Box::new(move || hypr::set(name, &value)) as Box<dyn Fn()>)
})
.collect(),
colors,
)
}
// one choice: its label, whether it's the current one, what a click does
type Choice = (String, bool, Box<dyn Fn()>);
fn chips(items: Vec<Choice>, colors: &Colors) -> Row {
let chips: Vec<Box<dyn Widget>> = items
.into_iter()
.map(|(label, chosen, pick)| {
let letters = label.chars().count() as f32;
Box::new(
Rectangle::new()
.width(letters * config::appearance::text::small() * config::appearance::font::letter_width() + 18.0)
.height(26.0)
.radius(Full)
.fill(if chosen { colors.accent } else { config::appearance::overlays::chip() })
.align_child(Center, Center)
.cursor(Cursor::Pointer)
.on_click(move |_| pick())
.child(Text::new(label).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
) as Box<dyn Widget>
})
.collect();
Row::new(chips).gap(6.0).align(Center).justify(End).width(CONTROL_W)
}
// the text inputs' texts as last set from their values, so typing isn't overwritten until a value changes
thread_local! {
static SYNCED: RefCell<HashMap<&'static str, String>> = RefCell::new(HashMap::new());
}
fn synced(id: &'static str, text: &str) {
SYNCED.with_borrow_mut(|synced| {
if synced.get(id).map(String::as_str) != Some(text) {
TextInput::set_text(id, text);
synced.insert(id, text.to_string());
}
});
}
fn field(id: &'static str, text: &str, width: f32, swatch: Option<Color>, submit: impl Fn(String) + 'static) -> Row {
let colors = theme::now();
synced(id, text);
let swatch_w = if swatch.is_some() { FIELD_H + 8.0 } else { 0.0 };
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
if let Some(color) = swatch {
parts.push(Box::new(Rectangle::new().width(FIELD_H).height(FIELD_H).radius(8.0).fill(color).border(1.0, colors.outline)));
}
parts.push(Box::new(
Rectangle::new()
.width(width - swatch_w)
.height(FIELD_H)
.radius(8.0)
.fill(config::appearance::overlays::chip())
.border(1.0, colors.outline)
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Cursor::Text)
.child(TextInput::new(id).size(config::appearance::text::body()).color(colors.text).on_submit(submit)),
));
Row::new(parts).gap(8.0).align(Center)
}