mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Build radio widget entries from choice type metadata so variants display their icons (#4408)
* Build radio widget entries from choice type metadata so variants display their icons * Route the node properties enum radio through the shared choice type entry builder
This commit is contained in:
committed by
Dennis Kobert
parent
ed82c52d8e
commit
d19c299f40
@@ -1,2 +1,2 @@
|
|||||||
https://github.com/Keavon/graphite-branded-assets/archive/0d004aa61e6b48d316e8e5db6d59ccc4788f192d.tar.gz
|
https://github.com/Keavon/graphite-branded-assets/archive/6687dc6d3d8552948458d00d35e2bee3a40ab66b.tar.gz
|
||||||
772d64518be43c99977ba56f69e574531c56e83d2df2f42ab066f77f74b0dd1f
|
7663b4a35f377a5d3363430a725a1d5f634fbdc56adca5e43167786b8fe1b253
|
||||||
|
|||||||
@@ -623,14 +623,7 @@ impl ColorPickerMessageHandler {
|
|||||||
|
|
||||||
// Gradient spread (only present when the picker is in gradient mode)
|
// Gradient spread (only present when the picker is in gradient mode)
|
||||||
if self.gradient.is_some() {
|
if self.gradient.is_some() {
|
||||||
let entries = [GradientSpread::Pad, GradientSpread::Reflect, GradientSpread::Repeat]
|
let entries = RadioEntryData::list_from_choice_type(|gradient_spread| ColorPickerMessage::SetGradientSpread { gradient_spread }.into());
|
||||||
.into_iter()
|
|
||||||
.map(|gradient_spread| {
|
|
||||||
RadioEntryData::new(format!("{gradient_spread:?}"))
|
|
||||||
.label(gradient_spread.to_string())
|
|
||||||
.on_update(move |_| ColorPickerMessage::SetGradientSpread { gradient_spread }.into())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
groups.push(LayoutGroup::row(vec![
|
groups.push(LayoutGroup::row(vec![
|
||||||
TextLabel::new("Ends").tooltip_label("Gradient Spread").tooltip_description(ENDS_DESCRIPTION).widget_instance(),
|
TextLabel::new("Ends").tooltip_label("Gradient Spread").tooltip_description(ENDS_DESCRIPTION).widget_instance(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::messages::frontend::IconName;
|
use crate::messages::frontend::IconName;
|
||||||
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
|
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
|
||||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||||
|
use crate::messages::message::Message;
|
||||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
|
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
|
||||||
use derivative::*;
|
use derivative::*;
|
||||||
use graphene_std::Color;
|
use graphene_std::Color;
|
||||||
@@ -344,6 +345,30 @@ pub struct RadioEntryData {
|
|||||||
pub on_commit: WidgetCallback<()>,
|
pub on_commit: WidgetCallback<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RadioEntryData {
|
||||||
|
/// One entry per variant of a choice type enum, shown as the variant's icon when it has one and its text label otherwise.
|
||||||
|
pub fn list_from_choice_type<E>(to_message: impl Fn(E) -> Message + Clone + Send + Sync + 'static) -> Vec<Self>
|
||||||
|
where
|
||||||
|
E: graphene_std::choice_type::ChoiceTypeStatic + 'static,
|
||||||
|
{
|
||||||
|
E::list()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| section.iter())
|
||||||
|
.map(|(variant, metadata)| {
|
||||||
|
let to_message = to_message.clone();
|
||||||
|
let variant = *variant;
|
||||||
|
|
||||||
|
let entry = RadioEntryData::new(metadata.name)
|
||||||
|
.tooltip_label(metadata.label)
|
||||||
|
.tooltip_description(metadata.description.unwrap_or_default())
|
||||||
|
.on_update(move |_| to_message(variant));
|
||||||
|
|
||||||
|
if let Some(icon) = metadata.icon { entry.icon(icon) } else { entry.label(metadata.label) }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
|
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder)]
|
||||||
#[derivative(Debug, PartialEq, Default)]
|
#[derivative(Debug, PartialEq, Default)]
|
||||||
|
|||||||
@@ -2649,7 +2649,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|&gradient_form| {
|
.map(|&gradient_form| {
|
||||||
RadioEntryData::new(format!("{:?}", gradient_form))
|
RadioEntryData::new(format!("{:?}", gradient_form))
|
||||||
.label(format!("{:?}", gradient_form))
|
.label(gradient_form.to_string())
|
||||||
.on_update(update_value(move |_| TaggedValue::GradientForm(gradient_form), node_id, GradientFormInput))
|
.on_update(update_value(move |_| TaggedValue::GradientForm(gradient_form), node_id, GradientFormInput))
|
||||||
.on_commit(commit_value)
|
.on_commit(commit_value)
|
||||||
})
|
})
|
||||||
@@ -2951,20 +2951,18 @@ pub mod choice {
|
|||||||
U: Fn(&E) -> Message + 'static + Send + Sync,
|
U: Fn(&E) -> Message + 'static + Send + Sync,
|
||||||
C: Fn(&()) -> Message + 'static + Send + Sync,
|
C: Fn(&()) -> Message + 'static + Send + Sync,
|
||||||
{
|
{
|
||||||
let items = E::list()
|
// The entry builder clones one callback across all variants, so each factory yields a single shared handle
|
||||||
.iter()
|
let updater = std::sync::Arc::new(updater_factory());
|
||||||
.flat_map(|section| section.iter())
|
let committer = std::sync::Arc::new(committer_factory());
|
||||||
.map(|(item, var_meta)| {
|
|
||||||
let updater = updater_factory();
|
let items = RadioEntryData::list_from_choice_type(move |variant: E| updater(&variant))
|
||||||
let committer = committer_factory();
|
.into_iter()
|
||||||
let entry = RadioEntryData::new(var_meta.name)
|
.map(|entry| {
|
||||||
.on_update(move |_| updater(item))
|
let committer = committer.clone();
|
||||||
.on_commit(committer)
|
entry.on_commit(move |value| committer(value))
|
||||||
.tooltip_label(var_meta.label)
|
|
||||||
.tooltip_description(var_meta.description.unwrap_or_default());
|
|
||||||
if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) }
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
RadioInput::new(items).selected_index(Some(current.as_u32())).disabled(self.disabled).widget_instance()
|
RadioInput::new(items).selected_index(Some(current.as_u32())).disabled(self.disabled).widget_instance()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,19 +123,9 @@ where
|
|||||||
E: ChoiceTypeStatic + 'static,
|
E: ChoiceTypeStatic + 'static,
|
||||||
F: Fn(E) -> Message + 'static + Send + Sync + Clone,
|
F: Fn(E) -> Message + 'static + Send + Sync + Clone,
|
||||||
{
|
{
|
||||||
let entries = E::list()
|
let entries = RadioEntryData::list_from_choice_type(to_message)
|
||||||
.iter()
|
.into_iter()
|
||||||
.flat_map(|section| section.iter())
|
.map(|entry| entry.on_commit(|_| DocumentMessage::StartTransaction.into()))
|
||||||
.map(|(value, meta)| {
|
|
||||||
let to_message = to_message.clone();
|
|
||||||
let value = *value;
|
|
||||||
let entry = RadioEntryData::new(meta.name)
|
|
||||||
.tooltip_label(meta.label)
|
|
||||||
.tooltip_description(meta.description.unwrap_or_default())
|
|
||||||
.on_update(move |_| to_message(value))
|
|
||||||
.on_commit(|_| DocumentMessage::StartTransaction.into());
|
|
||||||
if let Some(icon) = meta.icon { entry.icon(icon) } else { entry.label(meta.label) }
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
vec![
|
vec![
|
||||||
TextLabel::new(label_text).table_align(true).widget_instance(),
|
TextLabel::new(label_text).table_align(true).widget_instance(),
|
||||||
|
|||||||
@@ -231,22 +231,13 @@ impl LayoutHolder for GradientTool {
|
|||||||
fn layout(&self) -> Layout {
|
fn layout(&self) -> Layout {
|
||||||
let mut widgets: Vec<WidgetInstance> = Vec::new();
|
let mut widgets: Vec<WidgetInstance> = Vec::new();
|
||||||
|
|
||||||
let gradient_form = RadioInput::new(vec![
|
let gradient_form_entries = RadioEntryData::list_from_choice_type(|gradient_form| {
|
||||||
RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| {
|
GradientToolMessage::UpdateOptions {
|
||||||
GradientToolMessage::UpdateOptions {
|
options: GradientOptionsUpdate::Form(gradient_form),
|
||||||
options: GradientOptionsUpdate::Form(GradientForm::Linear),
|
}
|
||||||
}
|
.into()
|
||||||
.into()
|
});
|
||||||
}),
|
let gradient_form = RadioInput::new(gradient_form_entries).selected_index(Some(self.options.gradient_form as u32)).widget_instance();
|
||||||
RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| {
|
|
||||||
GradientToolMessage::UpdateOptions {
|
|
||||||
options: GradientOptionsUpdate::Form(GradientForm::Radial),
|
|
||||||
}
|
|
||||||
.into()
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.selected_index(Some((self.options.gradient_form == GradientForm::Radial) as u32))
|
|
||||||
.widget_instance();
|
|
||||||
|
|
||||||
// Display priority: the selected layer's stops, then any user-customized tool default, then the working colors
|
// Display priority: the selected layer's stops, then any user-customized tool default, then the working colors
|
||||||
let stops_value = self.data.current_gradient_stops.clone().or_else(|| self.data.default_gradient_stops.clone()).unwrap_or_else(|| {
|
let stops_value = self.data.current_gradient_stops.clone().or_else(|| self.data.default_gradient_stops.clone()).unwrap_or_else(|| {
|
||||||
|
|||||||
@@ -217,26 +217,12 @@ fn create_shape_option_widget(shape_type: ShapeType) -> WidgetInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_arc_type_widget(arc_type: ArcType) -> WidgetInstance {
|
fn create_arc_type_widget(arc_type: ArcType) -> WidgetInstance {
|
||||||
let entries = vec![
|
let entries = RadioEntryData::list_from_choice_type(|arc_type| {
|
||||||
RadioEntryData::new("Open").label("Open").on_update(move |_| {
|
ShapeToolMessage::UpdateOptions {
|
||||||
ShapeToolMessage::UpdateOptions {
|
options: ShapeOptionsUpdate::ArcType(arc_type),
|
||||||
options: ShapeOptionsUpdate::ArcType(ArcType::Open),
|
}
|
||||||
}
|
.into()
|
||||||
.into()
|
});
|
||||||
}),
|
|
||||||
RadioEntryData::new("Closed").label("Closed").on_update(move |_| {
|
|
||||||
ShapeToolMessage::UpdateOptions {
|
|
||||||
options: ShapeOptionsUpdate::ArcType(ArcType::Closed),
|
|
||||||
}
|
|
||||||
.into()
|
|
||||||
}),
|
|
||||||
RadioEntryData::new("Pie").label("Pie").on_update(move |_| {
|
|
||||||
ShapeToolMessage::UpdateOptions {
|
|
||||||
options: ShapeOptionsUpdate::ArcType(ArcType::PieSlice),
|
|
||||||
}
|
|
||||||
.into()
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
RadioInput::new(entries).selected_index(Some(arc_type as u32)).widget_instance()
|
RadioInput::new(entries).selected_index(Some(arc_type as u32)).widget_instance()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,20 +293,12 @@ fn create_spiral_type_widget(spiral_type: SpiralType) -> WidgetInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_grid_type_widget(grid_type: GridType) -> WidgetInstance {
|
fn create_grid_type_widget(grid_type: GridType) -> WidgetInstance {
|
||||||
let entries = vec![
|
let entries = RadioEntryData::list_from_choice_type(|grid_type| {
|
||||||
RadioEntryData::new("Rectangular").label("Rectangular").on_update(move |_| {
|
ShapeToolMessage::UpdateOptions {
|
||||||
ShapeToolMessage::UpdateOptions {
|
options: ShapeOptionsUpdate::GridType(grid_type),
|
||||||
options: ShapeOptionsUpdate::GridType(GridType::Rectangular),
|
}
|
||||||
}
|
.into()
|
||||||
.into()
|
});
|
||||||
}),
|
|
||||||
RadioEntryData::new("Isometric").label("Isometric").on_update(move |_| {
|
|
||||||
ShapeToolMessage::UpdateOptions {
|
|
||||||
options: ShapeOptionsUpdate::GridType(GridType::Isometric),
|
|
||||||
}
|
|
||||||
.into()
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
RadioInput::new(entries).selected_index(Some(grid_type as u32)).widget_instance()
|
RadioInput::new(entries).selected_index(Some(grid_type as u32)).widget_instance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use crate::messages::tool::utility_types::ToolRefreshOptions;
|
|||||||
use graph_craft::application_io::resource::ResourceId;
|
use graph_craft::application_io::resource::ResourceId;
|
||||||
use graph_craft::document::value::TaggedValue;
|
use graph_craft::document::value::TaggedValue;
|
||||||
use graph_craft::document::{NodeId, NodeInput};
|
use graph_craft::document::{NodeId, NodeInput};
|
||||||
use graphene_std::choice_type::ChoiceTypeStatic;
|
|
||||||
use graphene_std::color::SRGBA8;
|
use graphene_std::color::SRGBA8;
|
||||||
use graphene_std::renderer::Quad;
|
use graphene_std::renderer::Quad;
|
||||||
use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping};
|
use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping};
|
||||||
@@ -214,23 +213,12 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &D
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.widget_instance();
|
.widget_instance();
|
||||||
let align_entries: Vec<_> = TextAlign::list()
|
let align_entries = RadioEntryData::list_from_choice_type(|align| {
|
||||||
.iter()
|
TextToolMessage::UpdateOptions {
|
||||||
.flat_map(|section| section.iter())
|
options: TextOptionsUpdate::Align(align),
|
||||||
.map(|(item, var_meta)| {
|
}
|
||||||
let align = *item;
|
.into()
|
||||||
let entry = RadioEntryData::new(var_meta.name)
|
});
|
||||||
.tooltip_label(var_meta.label)
|
|
||||||
.tooltip_description(var_meta.description.unwrap_or_default())
|
|
||||||
.on_update(move |_| {
|
|
||||||
TextToolMessage::UpdateOptions {
|
|
||||||
options: TextOptionsUpdate::Align(align),
|
|
||||||
}
|
|
||||||
.into()
|
|
||||||
});
|
|
||||||
if let Some(icon) = var_meta.icon { entry.icon(icon) } else { entry.label(var_meta.label) }
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let align = RadioInput::new(align_entries).selected_index(Some(tool.options.align as u32)).widget_instance();
|
let align = RadioInput::new(align_entries).selected_index(Some(tool.options.align as u32)).widget_instance();
|
||||||
vec![
|
vec![
|
||||||
font,
|
font,
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import Empty12px from "/../branding/assets/icon-12px-solid/empty-12px.svg";
|
|||||||
import Failure from "/../branding/assets/icon-12px-solid/failure.svg";
|
import Failure from "/../branding/assets/icon-12px-solid/failure.svg";
|
||||||
import FullscreenEnter from "/../branding/assets/icon-12px-solid/fullscreen-enter.svg";
|
import FullscreenEnter from "/../branding/assets/icon-12px-solid/fullscreen-enter.svg";
|
||||||
import FullscreenExit from "/../branding/assets/icon-12px-solid/fullscreen-exit.svg";
|
import FullscreenExit from "/../branding/assets/icon-12px-solid/fullscreen-exit.svg";
|
||||||
|
import GradientSpreadClear from "/../branding/assets/icon-12px-solid/gradient-spread-clear.svg";
|
||||||
|
import GradientSpreadPad from "/../branding/assets/icon-12px-solid/gradient-spread-pad.svg";
|
||||||
|
import GradientSpreadReflect from "/../branding/assets/icon-12px-solid/gradient-spread-reflect.svg";
|
||||||
|
import GradientSpreadRepeat from "/../branding/assets/icon-12px-solid/gradient-spread-repeat.svg";
|
||||||
import GridDotted from "/../branding/assets/icon-12px-solid/grid-dotted.svg";
|
import GridDotted from "/../branding/assets/icon-12px-solid/grid-dotted.svg";
|
||||||
import Grid from "/../branding/assets/icon-12px-solid/grid.svg";
|
import Grid from "/../branding/assets/icon-12px-solid/grid.svg";
|
||||||
import Info from "/../branding/assets/icon-12px-solid/info.svg";
|
import Info from "/../branding/assets/icon-12px-solid/info.svg";
|
||||||
@@ -67,6 +71,10 @@ const SOLID_12PX = {
|
|||||||
Failure: { svg: Failure, size: 12 },
|
Failure: { svg: Failure, size: 12 },
|
||||||
FullscreenEnter: { svg: FullscreenEnter, size: 12 },
|
FullscreenEnter: { svg: FullscreenEnter, size: 12 },
|
||||||
FullscreenExit: { svg: FullscreenExit, size: 12 },
|
FullscreenExit: { svg: FullscreenExit, size: 12 },
|
||||||
|
GradientSpreadClear: { svg: GradientSpreadClear, size: 12 },
|
||||||
|
GradientSpreadPad: { svg: GradientSpreadPad, size: 12 },
|
||||||
|
GradientSpreadReflect: { svg: GradientSpreadReflect, size: 12 },
|
||||||
|
GradientSpreadRepeat: { svg: GradientSpreadRepeat, size: 12 },
|
||||||
Grid: { svg: Grid, size: 12 },
|
Grid: { svg: Grid, size: 12 },
|
||||||
GridDotted: { svg: GridDotted, size: 12 },
|
GridDotted: { svg: GridDotted, size: 12 },
|
||||||
Info: { svg: Info, size: 12 },
|
Info: { svg: Info, size: 12 },
|
||||||
|
|||||||
@@ -89,7 +89,13 @@ export function destroyTooltipStore() {
|
|||||||
|
|
||||||
// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip
|
// Listen for mouse movements onto tooltip-bearing HTML elements to track the future target of a tooltip
|
||||||
function onMouseOver(e: MouseEvent) {
|
function onMouseOver(e: MouseEvent) {
|
||||||
const element = (e.target instanceof Element && e.target.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]")) || undefined;
|
const target = (e.target instanceof Element && e.target) || undefined;
|
||||||
|
let element = target?.closest("[data-tooltip-label], [data-tooltip-description], [data-tooltip-shortcut]") || undefined;
|
||||||
|
|
||||||
|
// A floating menu renders within the DOM of the widget that spawned it, so a match beyond the menu's own content
|
||||||
|
// is the spawner's tooltip rather than one belonging to whatever the cursor is actually over
|
||||||
|
const floatingMenuContent = target?.closest("[data-floating-menu-content]");
|
||||||
|
if (element && floatingMenuContent && !floatingMenuContent.contains(element)) element = undefined;
|
||||||
|
|
||||||
update((state) => {
|
update((state) => {
|
||||||
state.visible = false;
|
state.visible = false;
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ use glam::{DAffine2, DVec2};
|
|||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
#[widget(Radio)]
|
#[widget(Radio)]
|
||||||
pub enum GradientForm {
|
pub enum GradientForm {
|
||||||
|
/// Transitions the colors along a straight line.
|
||||||
#[default]
|
#[default]
|
||||||
Linear,
|
Linear,
|
||||||
|
/// Transitions the colors outward from a center point.
|
||||||
Radial,
|
Radial,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,10 +790,13 @@ impl Gradient {
|
|||||||
pub enum GradientSpread {
|
pub enum GradientSpread {
|
||||||
/// Extends the end colors outward.
|
/// Extends the end colors outward.
|
||||||
#[default]
|
#[default]
|
||||||
|
#[icon("GradientSpreadPad")]
|
||||||
Pad,
|
Pad,
|
||||||
/// Loops the gradient by mirroring back-and-forth.
|
/// Loops the gradient by mirroring back-and-forth.
|
||||||
|
#[icon("GradientSpreadReflect")]
|
||||||
Reflect,
|
Reflect,
|
||||||
/// Loops the gradient as copies of itself.
|
/// Loops the gradient as copies of itself.
|
||||||
|
#[icon("GradientSpreadRepeat")]
|
||||||
Repeat,
|
Repeat,
|
||||||
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
|
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,9 +155,12 @@ pub enum GridType {
|
|||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
#[widget(Radio)]
|
#[widget(Radio)]
|
||||||
pub enum ArcType {
|
pub enum ArcType {
|
||||||
|
/// Leaves the two ends of the arc unconnected.
|
||||||
#[default]
|
#[default]
|
||||||
Open = 0,
|
Open = 0,
|
||||||
|
/// Connects the two ends of the arc with a straight line.
|
||||||
Closed,
|
Closed,
|
||||||
|
/// Connects the two ends of the arc to its center, forming a wedge.
|
||||||
PieSlice,
|
PieSlice,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user