Allow toggling smooth/sharp angle from the path tool options bar (#1415)

* menu in option

* smoothing controls work

* fixed type error

* fix flipping behavior

* silence warning

* consolidate selection state

* update positions options

* blinking logic fixed, smoothing logic implemented

* fixed arbitrary looping when flipping from sharp to smooth

* remove warning

* Tidying up

* refactor manipulator smoothing code, remove bitflags, rename

* Make the point smooth/sharp support mixed better

* Code review tweaks

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
mobile-bungalow 2023-09-11 17:36:08 -07:00 committed by GitHub
parent 88bdf9580f
commit 9667e5173b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 326 additions and 93 deletions

View File

@ -2,7 +2,7 @@ use crate::consts::DRAG_THRESHOLD;
use crate::messages::portfolio::document::node_graph::VectorDataModification;
use crate::messages::prelude::*;
use bezier_rs::{Bezier, TValue};
use bezier_rs::{Bezier, ManipulatorGroup, TValue};
use document_legacy::document::Document;
use document_legacy::LayerId;
use graphene_core::uuid::ManipulatorGroupId;
@ -10,10 +10,18 @@ use graphene_core::vector::{ManipulatorPointId, SelectedType, VectorData};
use glam::DVec2;
#[derive(Debug, PartialEq, Copy, Clone)]
pub enum ManipulatorAngle {
Smooth,
Sharp,
Mixed,
}
#[derive(Clone, Debug, Default)]
pub struct SelectedLayerState {
selected_points: HashSet<ManipulatorPointId>,
}
impl SelectedLayerState {
pub fn is_selected(&self, point: ManipulatorPointId) -> bool {
self.selected_points.contains(&point)
@ -31,6 +39,7 @@ impl SelectedLayerState {
self.selected_points.len()
}
}
pub type SelectedShapeState = HashMap<Vec<LayerId>, SelectedLayerState>;
#[derive(Debug, Default)]
pub struct ShapeState {
@ -42,6 +51,7 @@ pub struct SelectedPointsInfo<'a> {
pub points: Vec<ManipulatorPointInfo<'a>>,
pub offset: DVec2,
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub struct ManipulatorPointInfo<'a> {
pub shape_layer_path: &'a [LayerId],
@ -146,7 +156,7 @@ impl ShapeState {
}
/// A mutable iterator of all the manipulators, regardless of selection.
pub fn manipulator_groups<'a>(&'a self, document: &'a Document) -> impl Iterator<Item = &'a bezier_rs::ManipulatorGroup<ManipulatorGroupId>> {
pub fn manipulator_groups<'a>(&'a self, document: &'a Document) -> impl Iterator<Item = &'a ManipulatorGroup<ManipulatorGroupId>> {
self.iter(document).flat_map(|shape| shape.manipulator_groups())
}
@ -201,6 +211,166 @@ impl ShapeState {
Some(())
}
// Iterates over the selected manipulator groups, returning whether they have mixed, sharp, or smooth angles.
// If there are no points selected this function returns mixed.
pub fn selected_manipulator_angles(&self, document: &Document) -> ManipulatorAngle {
// This iterator contains a bool indicating whether or not every selected point has a smooth manipulator angle.
let mut point_smoothness_status = self
.selected_shape_state
.iter()
.filter_map(|(layer_id, selection_state)| {
let layer = document.layer(layer_id).ok()?;
let vector_data = layer.as_vector_data()?;
Some((vector_data, selection_state))
})
.flat_map(|(vector_data, selection_state)| selection_state.selected_points.iter().map(|selected_point| vector_data.mirror_angle.contains(&selected_point.group)));
let Some(first_is_smooth) = point_smoothness_status.next() else { return ManipulatorAngle::Mixed };
if point_smoothness_status.any(|point| first_is_smooth != point) {
return ManipulatorAngle::Mixed;
}
match first_is_smooth {
false => ManipulatorAngle::Sharp,
true => ManipulatorAngle::Smooth,
}
}
pub fn smooth_manipulator_group(&self, subpath: &bezier_rs::Subpath<ManipulatorGroupId>, index: usize, responses: &mut VecDeque<Message>, layer_path: &[u64]) {
let manipulator_groups = subpath.manipulator_groups();
let manipulator = manipulator_groups[index];
// Grab the next and previous manipulator groups by simply looking at the next / previous index
let mut previous_position = index.checked_sub(1).and_then(|index| manipulator_groups.get(index));
let mut next_position = manipulator_groups.get(index + 1);
// Wrapping around closed path
if subpath.closed() {
previous_position = previous_position.or_else(|| manipulator_groups.last());
next_position = next_position.or_else(|| manipulator_groups.first());
}
let anchor_position = manipulator.anchor;
// To find the length of the new tangent we just take the distance to the anchor and divide by 3 (pretty arbitrary)
let length_previous = previous_position.map(|group| (group.anchor - anchor_position).length() / 3.);
let length_next = next_position.map(|group| (group.anchor - anchor_position).length() / 3.);
// Use the position relative to the anchor
let previous_angle = previous_position.map(|group| (group.anchor - anchor_position)).map(|pos| pos.y.atan2(pos.x));
let next_angle = next_position.map(|group| (group.anchor - anchor_position)).map(|pos| pos.y.atan2(pos.x));
// The direction of the handles is either the perpendicular vector to the sum of the anchors' positions or just the anchor's position (if only one)
let handle_direction = match (previous_angle, next_angle) {
(Some(previous), Some(next)) => (previous + next) / 2. + core::f64::consts::FRAC_PI_2,
(None, Some(val)) => core::f64::consts::PI + val,
(Some(val), None) => val,
(None, None) => return,
};
// Mirror the angle but not the distance
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorHandleMirroring {
id: manipulator.id,
mirror_angle: true,
},
});
let (sin, cos) = handle_direction.sin_cos();
let mut handle_vector = DVec2::new(cos, sin);
// Flip the vector if it is not facing towards the same direction as the anchor
if previous_position.filter(|&group| (group.anchor - anchor_position).normalize().dot(handle_vector) < 0.).is_some()
|| next_position.filter(|&group| (group.anchor - anchor_position).normalize().dot(handle_vector) > 0.).is_some()
{
handle_vector = -handle_vector;
}
// Push both in and out handles into the correct position
if let Some(in_handle) = length_previous.map(|length| anchor_position + handle_vector * length) {
let point = ManipulatorPointId::new(manipulator.id, SelectedType::InHandle);
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point, position: in_handle },
});
}
if let Some(out_handle) = length_next.map(|length| anchor_position - handle_vector * length) {
let point = ManipulatorPointId::new(manipulator.id, SelectedType::OutHandle);
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point, position: out_handle },
});
}
}
/// Smooths the set of selected control points, assuming that the selected set is homogeneously sharp.
pub fn smooth_selected_groups(&self, responses: &mut VecDeque<Message>, document: &Document) -> Option<()> {
let mut skip_set = HashSet::new();
for (layer_id, layer_state) in self.selected_shape_state.iter() {
let layer = document.layer(layer_id).ok()?;
let vector_data = layer.as_vector_data()?;
for point in layer_state.selected_points.iter() {
if skip_set.contains(&point.group) {
continue;
};
skip_set.insert(point.group);
let anchor_selected = layer_state.selected_points.contains(&ManipulatorPointId {
group: point.group,
manipulator_type: SelectedType::Anchor,
});
let out_selected = layer_state.selected_points.contains(&ManipulatorPointId {
group: point.group,
manipulator_type: SelectedType::OutHandle,
});
let in_selected = layer_state.selected_points.contains(&ManipulatorPointId {
group: point.group,
manipulator_type: SelectedType::InHandle,
});
let group = vector_data.manipulator_from_id(point.group)?;
match (anchor_selected, out_selected, in_selected) {
(_, true, false) => {
let out_handle = ManipulatorPointId::new(point.group, SelectedType::OutHandle);
if let Some(position) = group.out_handle {
responses.add(GraphOperationMessage::Vector {
layer: layer_id.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point: out_handle, position },
});
}
}
(_, false, true) => {
let in_handle = ManipulatorPointId::new(point.group, SelectedType::InHandle);
if let Some(position) = group.in_handle {
responses.add(GraphOperationMessage::Vector {
layer: layer_id.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point: in_handle, position },
});
}
}
(_, _, _) => {
let found = vector_data.subpaths.iter().find_map(|subpath| {
let group_slice = subpath.manipulator_groups();
let index = group_slice.iter().position(|manipulator| manipulator.id == group.id)?;
// TODO: try subpath closed? wrapping
Some((subpath, index))
});
if let Some((subpath, index)) = found {
self.smooth_manipulator_group(subpath, index, responses, layer_id);
}
}
}
}
}
Some(())
}
/// Move the selected points by dragging the mouse.
pub fn move_selected_points(&self, document: &Document, delta: DVec2, mirror_distance: bool, responses: &mut VecDeque<Message>) {
for (layer_path, state) in &self.selected_shape_state {
@ -444,6 +614,18 @@ impl ShapeState {
}
}
/// Toggle if the handles should mirror angle across the anchor position.
pub fn set_handle_mirroring_on_selected(&self, mirror_angle: bool, responses: &mut VecDeque<Message>) {
for (layer, state) in &self.selected_shape_state {
for point in &state.selected_points {
responses.add(GraphOperationMessage::Vector {
layer: layer.to_vec(),
modification: VectorDataModification::SetManipulatorHandleMirroring { id: point.group, mirror_angle },
});
}
}
}
/// Iterate over the shapes.
pub fn iter<'a>(&'a self, document: &'a Document) -> impl Iterator<Item = &'a VectorData> + 'a {
self.selected_shape_state
@ -544,7 +726,7 @@ impl ShapeState {
responses.add(out_handle);
// Insert a new manipulator group between the existing ones
let manipulator_group = bezier_rs::ManipulatorGroup::new(first.end(), first.handle_end(), second.handle_start());
let manipulator_group = ManipulatorGroup::new(first.end(), first.handle_end(), second.handle_start());
let insert = GraphOperationMessage::Vector {
layer: layer_path.clone(),
modification: VectorDataModification::AddManipulatorGroup { manipulator_group, after_id: start },
@ -598,78 +780,28 @@ impl ShapeState {
(None, None) => true,
};
let manipulator_groups = subpath.manipulator_groups();
let (in_handle, out_handle) = if already_sharp {
let is_closed = subpath.closed();
// Grab the next and previous manipulator groups by simply looking at the next / previous index
let mut previous_position = index.checked_sub(1).and_then(|index| manipulator_groups.get(index)).map(|group| group.anchor);
let mut next_position = manipulator_groups.get(index + 1).map(|group| group.anchor);
// Wrapping around closed path
if is_closed {
previous_position = previous_position.or_else(|| manipulator_groups.last().map(|group| group.anchor));
next_position = next_position.or_else(|| manipulator_groups.first().map(|group| group.anchor));
}
// To find the length of the new tangent we just take the distance to the anchor and divide by 3 (pretty arbitrary)
let length_previous = previous_position.map(|point| (point - anchor_position).length() / 3.);
let length_next = next_position.map(|point| (point - anchor_position).length() / 3.);
// Use the position relative to the anchor
let previous_angle = previous_position.map(|point| (point - anchor_position)).map(|pos| pos.y.atan2(pos.x));
let next_angle = next_position.map(|point| (point - anchor_position)).map(|pos| pos.y.atan2(pos.x));
// The direction of the handles is either the perpendicular vector to the sum of the anchors' positions or just the anchor's position (if only one)
let handle_direction = match (previous_angle, next_angle) {
(Some(previous), Some(next)) => (previous + next) / 2. + core::f64::consts::FRAC_PI_2,
(None, Some(val)) => core::f64::consts::PI + val,
(Some(val), None) => val,
(None, None) => return None,
};
// Mirror the angle but not the distance
if already_sharp {
self.smooth_manipulator_group(subpath, index, responses, layer_path);
} else {
let point = ManipulatorPointId::new(manipulator.id, SelectedType::InHandle);
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point, position: anchor_position },
});
let point = ManipulatorPointId::new(manipulator.id, SelectedType::OutHandle);
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point, position: anchor_position },
});
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorHandleMirroring {
id: manipulator.id,
mirror_angle: true,
mirror_angle: false,
},
});
let (sin, cos) = handle_direction.sin_cos();
let mut handle_vector = DVec2::new(cos, sin);
// Flip the vector if it is not facing towards the same direction as the anchor
if previous_position.filter(|&pos| (pos - anchor_position).normalize().dot(handle_vector) < 0.).is_some()
|| next_position.filter(|&pos| (pos - anchor_position).normalize().dot(handle_vector) > 0.).is_some()
{
handle_vector = -handle_vector;
}
(
length_previous.map(|length| anchor_position + handle_vector * length),
length_next.map(|length| anchor_position - handle_vector * length),
)
} else {
(Some(anchor_position), Some(anchor_position))
};
// Push both in and out handles into the correct position
if let Some(in_handle) = in_handle {
let point = ManipulatorPointId::new(manipulator.id, SelectedType::InHandle);
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point, position: in_handle },
});
}
if let Some(out_handle) = out_handle {
let point = ManipulatorPointId::new(manipulator.id, SelectedType::OutHandle);
responses.add(GraphOperationMessage::Vector {
layer: layer_path.to_vec(),
modification: VectorDataModification::SetManipulatorPosition { point, position: out_handle },
});
}
Some(true)
};
for layer_path in self.selected_shape_state.keys() {

View File

@ -6,7 +6,7 @@ use crate::messages::input_mapper::utility_types::input_keyboard::{Key, MouseMot
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::overlay_renderer::OverlayRenderer;
use crate::messages::tool::common_functionality::shape_editor::{ManipulatorPointInfo, OpposingHandleLengths, ShapeState};
use crate::messages::tool::common_functionality::shape_editor::{ManipulatorAngle, ManipulatorPointInfo, OpposingHandleLengths, ShapeState};
use crate::messages::tool::common_functionality::snapping::SnapManager;
use crate::messages::tool::common_functionality::transformation_cage::{add_bounding_box, transform_from_box};
use crate::messages::tool::utility_types::{EventToMessageMap, Fsm, HintData, HintGroup, HintInfo, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
@ -49,6 +49,8 @@ pub enum PathToolMessage {
add_to_selection: Key,
},
InsertPoint,
ManipulatorAngleMakeSharp,
ManipulatorAngleMakeSmooth,
NudgeSelectedPoints {
delta_x: f64,
delta_y: f64,
@ -81,9 +83,15 @@ impl ToolMetadata for PathTool {
impl LayoutHolder for PathTool {
fn layout(&self) -> Layout {
let coordinates = self.tool_data.single_selected_point.as_ref().map(|point| point.coordinates);
let coordinates = self.tool_data.selection_status.as_one().as_ref().map(|point| point.coordinates);
let (x, y) = coordinates.map(|point| (Some(point.x), Some(point.y))).unwrap_or((None, None));
let selection_status = &self.tool_data.selection_status;
let manipulator_angle = selection_status
.as_multiple()
.map(|multiple| multiple.manipulator_angle)
.or_else(|| selection_status.as_one().map(|point| point.manipulator_angle));
let x_location = NumberInput::new(x)
.unit(" px")
.label("X")
@ -110,10 +118,26 @@ impl LayoutHolder for PathTool {
})
.widget_holder();
let seperator = Separator::new(SeparatorType::Related).widget_holder();
let related_seperator = Separator::new(SeparatorType::Related).widget_holder();
let unrelated_seperator = Separator::new(SeparatorType::Unrelated).widget_holder();
let manipulator_angle_options = vec![
RadioEntryData::new("Smooth").on_update(|_| PathToolMessage::ManipulatorAngleMakeSmooth.into()),
RadioEntryData::new("Sharp").on_update(|_| PathToolMessage::ManipulatorAngleMakeSharp.into()),
];
let manipulator_angle_index = manipulator_angle.and_then(|angle| match angle {
ManipulatorAngle::Smooth => Some(0),
ManipulatorAngle::Sharp => Some(1),
ManipulatorAngle::Mixed => None,
});
let manipulator_angle_radio = RadioInput::new(manipulator_angle_options)
.disabled(self.tool_data.selection_status.is_none())
.selected_index(manipulator_angle_index)
.widget_holder();
Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row {
widgets: vec![x_location, seperator, y_location],
widgets: vec![x_location, related_seperator, y_location, unrelated_seperator, manipulator_angle_radio],
}]))
}
}
@ -188,7 +212,7 @@ struct PathToolData {
alt_debounce: bool,
opposing_handle_lengths: Option<OpposingHandleLengths>,
drag_box_overlay_layer: Option<Vec<LayerId>>,
single_selected_point: Option<SingleSelectedPoint>,
selection_status: SelectionStatus,
}
impl PathToolData {
@ -392,6 +416,8 @@ impl Fsm for PathToolFsmState {
}
.into(),
));
responses.add(PathToolMessage::SelectedPointUpdated);
PathToolFsmState::Ready
}
@ -412,6 +438,8 @@ impl Fsm for PathToolFsmState {
}
.into(),
));
responses.add(PathToolMessage::SelectedPointUpdated);
PathToolFsmState::Ready
}
(_, PathToolMessage::DragStop { shift_mirror_distance }) => {
@ -432,6 +460,7 @@ impl Fsm for PathToolFsmState {
}
}
responses.add(PathToolMessage::SelectedPointUpdated);
tool_data.snap_manager.cleanup(responses);
PathToolFsmState::Ready
}
@ -454,6 +483,7 @@ impl Fsm for PathToolFsmState {
shape_editor.split(&document.document_legacy, input.mouse.position, SELECTION_TOLERANCE, responses);
}
responses.add(PathToolMessage::SelectedPointUpdated);
self
}
(_, PathToolMessage::Abort) => {
@ -480,22 +510,34 @@ impl Fsm for PathToolFsmState {
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectedPointXChanged { new_x }) => {
if let Some(SingleSelectedPoint { coordinates, id, ref layer_path }) = tool_data.single_selected_point {
if let Some(&SingleSelectedPoint { coordinates, id, ref layer_path, .. }) = tool_data.selection_status.as_one() {
shape_editor.reposition_control_point(&id, responses, &document.document_legacy, DVec2::new(new_x, coordinates.y), layer_path);
}
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectedPointYChanged { new_y }) => {
if let Some(SingleSelectedPoint { coordinates, id, ref layer_path }) = tool_data.single_selected_point {
if let Some(&SingleSelectedPoint { coordinates, id, ref layer_path, .. }) = tool_data.selection_status.as_one() {
shape_editor.reposition_control_point(&id, responses, &document.document_legacy, DVec2::new(coordinates.x, new_y), layer_path);
}
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectedPointUpdated) => {
let new_point = get_single_selected_point(&document.document_legacy, shape_editor);
tool_data.single_selected_point = new_point;
tool_data.selection_status = get_selection_status(&document.document_legacy, shape_editor);
self
}
(_, PathToolMessage::ManipulatorAngleMakeSmooth) => {
responses.add(DocumentMessage::StartTransaction);
shape_editor.set_handle_mirroring_on_selected(true, responses);
shape_editor.smooth_selected_groups(responses, &document.document_legacy);
responses.add(DocumentMessage::CommitTransaction);
PathToolFsmState::Ready
}
(_, PathToolMessage::ManipulatorAngleMakeSharp) => {
responses.add(DocumentMessage::StartTransaction);
shape_editor.set_handle_mirroring_on_selected(false, responses);
responses.add(DocumentMessage::CommitTransaction);
PathToolFsmState::Ready
}
(_, _) => PathToolFsmState::Ready,
}
} else {
@ -531,31 +573,85 @@ impl Fsm for PathToolFsmState {
}
}
#[derive(Debug, PartialEq, Default)]
enum SelectionStatus {
#[default]
None,
One(SingleSelectedPoint),
Multiple(MultipleSelectedPoints),
}
impl SelectionStatus {
fn as_one(&self) -> Option<&SingleSelectedPoint> {
match self {
SelectionStatus::One(one) => Some(one),
_ => None,
}
}
fn as_multiple(&self) -> Option<&MultipleSelectedPoints> {
match self {
SelectionStatus::Multiple(multiple) => Some(multiple),
_ => None,
}
}
fn is_none(&self) -> bool {
self == &SelectionStatus::None
}
}
#[derive(Debug, PartialEq)]
struct MultipleSelectedPoints {
manipulator_angle: ManipulatorAngle,
}
#[derive(Debug, PartialEq)]
struct SingleSelectedPoint {
coordinates: DVec2,
id: ManipulatorPointId,
layer_path: Vec<u64>,
manipulator_angle: ManipulatorAngle,
}
// If there is one and only one selected control point this function yields all the information needed to manipulate it.
fn get_single_selected_point(document: &Document, shape_state: &mut ShapeState) -> Option<SingleSelectedPoint> {
// If there is one selected and only one manipulator group this yields the selected control point,
// if only one handle is selected it will yield that handle, otherwise it will yield the group's anchor.
fn get_selection_status(document: &Document, shape_state: &mut ShapeState) -> SelectionStatus {
// Check to see if only one manipulator group is selected
let selection_layers: Vec<_> = shape_state.selected_shape_state.iter().take(2).map(|(k, v)| (k, v.selected_points_count())).collect();
let [(layer, 1)] = selection_layers[..] else {
return None;
};
let layer_data = document.layer(layer).ok()?;
let vector_data = layer_data.as_vector_data()?;
let [point] = shape_state.selected_points().take(2).collect::<Vec<_>>()[..] else {
return None;
if let [(layer, 1)] = selection_layers[..] {
let Some(layer_data) = document.layer(layer).ok() else { return SelectionStatus::None };
let Some(vector_data) = layer_data.as_vector_data() else { return SelectionStatus::None };
let Some(point) = shape_state.selected_points().next() else {
return SelectionStatus::None;
};
let Some(group) = vector_data.manipulator_from_id(point.group) else {
return SelectionStatus::None;
};
let Some(local_position) = point.manipulator_type.get_position(group) else {
return SelectionStatus::None;
};
let manipulator_angle = if vector_data.mirror_angle.contains(&point.group) {
ManipulatorAngle::Smooth
} else {
ManipulatorAngle::Sharp
};
return SelectionStatus::One(SingleSelectedPoint {
coordinates: layer_data.transform.transform_point2(local_position) + layer_data.pivot,
layer_path: layer.clone(),
id: *point,
manipulator_angle,
});
};
// Get the first selected point and transform it to document space.
let group = vector_data.manipulator_from_id(point.group)?;
let local_position = point.manipulator_type.get_position(group)?;
Some(SingleSelectedPoint {
coordinates: layer_data.transform.transform_point2(local_position) + layer_data.pivot,
layer_path: layer.clone(),
id: *point,
})
if !selection_layers.is_empty() {
return SelectionStatus::Multiple(MultipleSelectedPoints {
manipulator_angle: shape_state.selected_manipulator_angles(document),
});
}
SelectionStatus::None
}

View File

@ -27,6 +27,7 @@
{#each entries as entry, index (index)}
<button
class:active={index === selectedIndex}
class:mixed={selectedIndex === undefined}
class:disabled
class:sharp-right-corners={index === entries.length - 1 && sharpRightCorners}
on:click={() => handleEntryClick(entry)}
@ -57,6 +58,10 @@
align-items: center;
justify-content: center;
&.mixed {
background: var(--color-4-dimgray);
}
&:hover {
background: var(--color-6-lowergray);
color: var(--color-f-white);