diff --git a/Cargo.lock b/Cargo.lock index c1d3e1ff4..7abb9a08d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,7 +2780,7 @@ dependencies = [ [[package]] name = "realearn" -version = "2.10.0" +version = "2.11.0" dependencies = [ "approx", "ascii", @@ -2844,6 +2844,7 @@ dependencies = [ "serde_json", "serde_prometheus", "serde_repr", + "serde_with", "serde_yaml", "slog", "slog-stdlog", diff --git a/main/Cargo.toml b/main/Cargo.toml index f3a574cd8..a3b421872 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "realearn" -version = "2.10.0" +version = "2.11.0" authors = ["Benjamin Klum "] edition = "2018" build = "build.rs" @@ -29,6 +29,8 @@ rx-util = { path = "../rx-util" } helgoboss-midi = { version = "0.2", features = ["serde", "serde_repr"] } # In future (when helgoboss-learn has matured), this will become a crates.io dependency helgoboss-learn = { path = "lib/helgoboss-learn", features = ["serde", "serde_repr", "serde_with", "reaper-low"] } +# For being able to (de)serialize using FromStr and Display +serde_with = "1.6.4" c_str_macro = "1.0.2" vst = "0.2.1" rxrust = { git = "https://github.com/rxRust/rxRust", branch = "master" } diff --git a/main/src/application/group_model.rs b/main/src/application/group_model.rs index 8984081ef..ee37b2e61 100644 --- a/main/src/application/group_model.rs +++ b/main/src/application/group_model.rs @@ -1,6 +1,6 @@ use crate::application::{ActivationConditionModel, GroupData}; use crate::base::{prop, Prop}; -use crate::domain::{GroupId, MappingCompartment}; +use crate::domain::{GroupId, MappingCompartment, Tag}; use core::fmt; use rxrust::prelude::*; use std::cell::RefCell; @@ -12,6 +12,7 @@ pub struct GroupModel { compartment: MappingCompartment, id: GroupId, pub name: Prop, + pub tags: Prop>, pub control_is_enabled: Prop, pub feedback_is_enabled: Prop, pub activation_condition_model: ActivationConditionModel, @@ -51,6 +52,7 @@ impl GroupModel { compartment, id: Default::default(), name: Default::default(), + tags: Default::default(), control_is_enabled: prop(true), feedback_is_enabled: prop(true), activation_condition_model: ActivationConditionModel::default(), @@ -84,6 +86,7 @@ impl GroupModel { activation_condition: self .activation_condition_model .create_activation_condition(), + tags: self.tags.get_ref().clone(), } } diff --git a/main/src/application/mapping_model.rs b/main/src/application/mapping_model.rs index b8c4a75b9..36aa8d20f 100644 --- a/main/src/application/mapping_model.rs +++ b/main/src/application/mapping_model.rs @@ -7,7 +7,7 @@ use crate::domain::{ ActivationCondition, CompoundMappingSource, CompoundMappingTarget, ExtendedProcessorContext, ExtendedSourceCharacter, FeedbackSendBehavior, GroupId, MainMapping, MappingCompartment, MappingId, Mode, ProcessorMappingOptions, QualifiedMappingId, RealearnTarget, ReaperTarget, - TargetCharacter, UnresolvedCompoundMappingTarget, + Tag, TargetCharacter, UnresolvedCompoundMappingTarget, }; use helgoboss_learn::{ AbsoluteMode, ControlType, DetailedSourceCharacter, Interval, ModeApplicabilityCheckInput, @@ -24,6 +24,7 @@ pub struct MappingModel { id: MappingId, compartment: MappingCompartment, pub name: Prop, + pub tags: Prop>, pub group_id: Prop, pub control_is_enabled: Prop, pub feedback_is_enabled: Prop, @@ -72,6 +73,7 @@ impl MappingModel { id: MappingId::random(), compartment, name: Default::default(), + tags: Default::default(), group_id: prop(initial_group_id), control_is_enabled: prop(true), feedback_is_enabled: prop(true), @@ -296,11 +298,14 @@ impl MappingModel { feedback_is_enabled: group_data.feedback_is_enabled && self.feedback_is_enabled.get(), feedback_send_behavior: self.feedback_send_behavior.get(), }; + let mut merged_tags = group_data.tags; + merged_tags.extend_from_slice(self.tags.get_ref()); MainMapping::new( self.compartment, id, self.group_id.get(), self.name.get_ref().clone(), + merged_tags, source, mode, self.mode_model.group_interaction.get(), @@ -319,6 +324,7 @@ pub struct GroupData { pub control_is_enabled: bool, pub feedback_is_enabled: bool, pub activation_condition: ActivationCondition, + pub tags: Vec, } impl Default for GroupData { @@ -327,6 +333,7 @@ impl Default for GroupData { control_is_enabled: true, feedback_is_enabled: true, activation_condition: ActivationCondition::Always, + tags: vec![], } } } diff --git a/main/src/application/session.rs b/main/src/application/session.rs index 54b175787..48c3a383a 100644 --- a/main/src/application/session.rs +++ b/main/src/application/session.rs @@ -842,6 +842,18 @@ impl Session { .find(|g| g.borrow().id() == id) } + pub fn find_group_by_id_including_default_group( + &self, + compartment: MappingCompartment, + id: GroupId, + ) -> Option<&SharedGroup> { + if id.is_default() { + Some(self.default_group(compartment)) + } else { + self.find_group_by_id(compartment, id) + } + } + pub fn find_group_by_index_sorted( &self, compartment: MappingCompartment, diff --git a/main/src/domain/main_processor.rs b/main/src/domain/main_processor.rs index 5a50fc2ff..93b906ac5 100644 --- a/main/src/domain/main_processor.rs +++ b/main/src/domain/main_processor.rs @@ -25,6 +25,7 @@ use crate::domain::ui_util::{ format_short_midi_message, log_control_input, log_feedback_output, log_learn_input, log_lifecycle_output, log_target_output, }; +use ascii::{AsciiString, ToAsciiChar}; use helgoboss_midi::RawShortMessage; use reaper_high::{ChangeEvent, Reaper}; use reaper_medium::ReaperNormalizedFxParamValue; @@ -2507,9 +2508,11 @@ impl fmt::Display for InstanceId { impl InstanceId { pub fn random() -> Self { let instance_id = nanoid::nanoid!(8); - let ascii = SmallAsciiString::create_compatible_ascii_string(&instance_id); - let small_ascii = SmallAsciiString::from_ascii_str(&ascii).expect("impossible"); - Self(small_ascii) + let ascii_string: AsciiString = instance_id + .chars() + .filter_map(|c| c.to_ascii_char().ok()) + .collect(); + Self(SmallAsciiString::from_ascii_str_cropping(&ascii_string)) } } diff --git a/main/src/domain/mapping.rs b/main/src/domain/mapping.rs index 74c40a62b..f5dc444d8 100644 --- a/main/src/domain/mapping.rs +++ b/main/src/domain/mapping.rs @@ -3,8 +3,9 @@ use crate::domain::{ ExtendedProcessorContext, FeedbackResolution, GroupId, HitInstructionReturnValue, InstanceFeedbackEvent, MappingActivationEffect, MidiSource, Mode, ParameterArray, ParameterSlice, RealSource, RealTimeReaperTarget, RealearnTarget, ReaperMessage, ReaperSource, - ReaperTarget, TargetCharacter, TrackExclusivity, UnresolvedReaperTarget, VirtualControlElement, - VirtualSource, VirtualSourceValue, VirtualTarget, COMPARTMENT_PARAMETER_COUNT, + ReaperTarget, Tag, TargetCharacter, TrackExclusivity, UnresolvedReaperTarget, + VirtualControlElement, VirtualSource, VirtualSourceValue, VirtualTarget, + COMPARTMENT_PARAMETER_COUNT, }; use derive_more::Display; use enum_iterator::IntoEnumIterator; @@ -126,6 +127,7 @@ impl MappingExtension { pub struct MainMapping { core: MappingCore, name: String, + tags: Vec, /// Is `Some` if the user-provided target data is complete. unresolved_target: Option, /// Is non-empty if the target resolved successfully. @@ -156,6 +158,7 @@ impl MainMapping { id: MappingId, group_id: GroupId, name: String, + tags: Vec, source: CompoundMappingSource, mode: Mode, group_interaction: GroupInteraction, @@ -177,6 +180,7 @@ impl MainMapping { time_of_last_control: None, }, name, + tags, unresolved_target, targets: vec![], activation_condition_1, diff --git a/main/src/domain/mod.rs b/main/src/domain/mod.rs index d6e25efd2..4769375d2 100644 --- a/main/src/domain/mod.rs +++ b/main/src/domain/mod.rs @@ -92,3 +92,9 @@ pub use reaper_source::*; mod device_change_detector; pub use device_change_detector::*; + +mod small_ascii_string; +pub use small_ascii_string::*; + +mod tag; +pub use tag::*; diff --git a/main/src/domain/small_ascii_string.rs b/main/src/domain/small_ascii_string.rs new file mode 100644 index 000000000..aa596fe17 --- /dev/null +++ b/main/src/domain/small_ascii_string.rs @@ -0,0 +1,52 @@ +use ascii::{AsciiStr, AsciiString}; +use core::fmt; + +/// String with a maximum of 16 ASCII characters. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] +pub struct SmallAsciiString { + length: u8, + content: [u8; SmallAsciiString::MAX_LENGTH], +} + +impl SmallAsciiString { + pub const MAX_LENGTH: usize = 16; + + /// Crops the string if necessary. + pub fn from_ascii_str_cropping(ascii_str: &AsciiStr) -> Self { + let short = + AsciiString::from(&ascii_str.as_slice()[..Self::MAX_LENGTH.min(ascii_str.len())]); + Self::from_ascii_str(&short) + } + + /// Returns an error if the given string is too long. + pub fn try_from_ascii_str(ascii_str: &AsciiStr) -> Result { + if ascii_str.len() > SmallAsciiString::MAX_LENGTH { + return Err("too large to be a small ASCII string"); + } + Ok(Self::from_ascii_str(ascii_str)) + } + + /// Panics if the given string is too long. + fn from_ascii_str(ascii_str: &AsciiStr) -> Self { + let mut content = [0u8; SmallAsciiString::MAX_LENGTH]; + content[..ascii_str.len()].copy_from_slice(ascii_str.as_bytes()); + Self { + content, + length: ascii_str.len() as u8, + } + } + + pub fn as_ascii_str(&self) -> &AsciiStr { + AsciiStr::from_ascii(self.as_slice()).unwrap() + } + + pub fn as_slice(&self) -> &[u8] { + &self.content[..(self.length as usize)] + } +} + +impl fmt::Display for SmallAsciiString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_ascii_str().fmt(f) + } +} diff --git a/main/src/domain/tag.rs b/main/src/domain/tag.rs new file mode 100644 index 000000000..5dafc3b81 --- /dev/null +++ b/main/src/domain/tag.rs @@ -0,0 +1,56 @@ +use crate::domain::SmallAsciiString; +use ascii::{AsciiChar, AsciiString, ToAsciiChar}; +use core::fmt; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +/// We reduce the number of possible letters in case we want to use tags in the audio thread in +/// future (and therefore need to avoid allocation). +#[derive( + Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash, SerializeDisplay, DeserializeFromStr, +)] +pub struct Tag(SmallAsciiString); + +impl FromStr for Tag { + type Err = &'static str; + + fn from_str(text: &str) -> Result { + let ascii_string: AsciiString = text + .chars() + // Remove all non-ASCII schars + .filter_map(|c| c.to_ascii_char().ok()) + // Allow only letters, digits and underscore + .filter(|c| c.is_ascii_alphanumeric() || *c == AsciiChar::UnderScore) + // Skip leading digits + .skip_while(|c| c.is_ascii_digit()) + .collect(); + if ascii_string.is_empty() { + return Err("empty tag"); + } + let small_ascii_string = SmallAsciiString::from_ascii_str_cropping(&ascii_string); + Ok(Self(small_ascii_string)) + } +} + +impl Display for Tag { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn parse_tags() { + assert_eq!(Tag::from_str("hey").unwrap().to_string(), "hey"); + assert_eq!(Tag::from_str("hey_test").unwrap().to_string(), "hey_test"); + assert_eq!( + Tag::from_str("1ähey1ätest").unwrap().to_string(), + "hey1test" + ); + assert!(Tag::from_str("1ä").is_err()); + } +} diff --git a/main/src/domain/virtual.rs b/main/src/domain/virtual.rs index 78c5234e2..07d567e2e 100644 --- a/main/src/domain/virtual.rs +++ b/main/src/domain/virtual.rs @@ -1,6 +1,6 @@ use crate::domain::ui_util::{format_as_percentage_without_unit, parse_unit_value_from_percentage}; -use crate::domain::{ExtendedSourceCharacter, TargetCharacter}; -use ascii::{AsciiStr, AsciiString, ToAsciiChar}; +use crate::domain::{ExtendedSourceCharacter, SmallAsciiString, TargetCharacter}; +use ascii::{AsciiString, ToAsciiChar}; use helgoboss_learn::{ AbsoluteValue, ControlType, ControlValue, SourceCharacter, Target, UnitValue, }; @@ -140,67 +140,33 @@ pub enum VirtualControlElementId { impl FromStr for VirtualControlElementId { type Err = &'static str; - fn from_str(s: &str) -> Result { - if let Ok(position) = s.parse::() { + fn from_str(text: &str) -> Result { + if let Ok(position) = text.parse::() { let index = std::cmp::max(0, position - 1) as u32; Ok(Self::Indexed(index)) } else { - let ascii_string = SmallAsciiString::create_compatible_ascii_string(s); - let small_ascii_string = SmallAsciiString::from_ascii_str(&ascii_string)?; + let small_ascii_string = create_control_element_name_lossy(text)?; Ok(Self::Named(small_ascii_string)) } } } -impl Default for VirtualControlElementId { - fn default() -> Self { - Self::Indexed(0) +/// Keeps only alphanumeric and punctuation ASCII characters and crops the string if too long. +fn create_control_element_name_lossy(text: &str) -> Result { + let ascii_string: AsciiString = text + .chars() + .filter_map(|c| c.to_ascii_char().ok()) + .filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_punctuation()) + .collect(); + if ascii_string.is_empty() { + return Err("empty virtual control element name"); } + Ok(SmallAsciiString::from_ascii_str_cropping(&ascii_string)) } -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] -pub struct SmallAsciiString { - length: u8, - content: [u8; SmallAsciiString::MAX_LENGTH], -} - -impl SmallAsciiString { - pub const MAX_LENGTH: usize = 16; - - pub fn create_compatible_ascii_string(text: &str) -> AsciiString { - let fixed_text = text - .chars() - .filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_punctuation()) - .map(|c| c.to_ascii_char().unwrap()); - let ascii_string: AsciiString = fixed_text.collect(); - AsciiString::from(&ascii_string.as_slice()[..Self::MAX_LENGTH.min(ascii_string.len())]) - } - - pub fn from_ascii_str(ascii_str: &AsciiStr) -> Result { - if ascii_str.len() > SmallAsciiString::MAX_LENGTH { - return Err("too large to be a small ASCII string"); - } - let mut content = [0u8; SmallAsciiString::MAX_LENGTH]; - content[..ascii_str.len()].copy_from_slice(ascii_str.as_bytes()); - let res = Self { - content, - length: ascii_str.len() as u8, - }; - Ok(res) - } - - pub fn as_ascii_str(&self) -> &AsciiStr { - AsciiStr::from_ascii(self.as_slice()).unwrap() - } - - pub fn as_slice(&self) -> &[u8] { - &self.content[..(self.length as usize)] - } -} - -impl Display for SmallAsciiString { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.as_ascii_str().fmt(f) +impl Default for VirtualControlElementId { + fn default() -> Self { + Self::Indexed(0) } } diff --git a/main/src/infrastructure/data/group_model_data.rs b/main/src/infrastructure/data/group_model_data.rs index b5d2a5c33..b1893f077 100644 --- a/main/src/infrastructure/data/group_model_data.rs +++ b/main/src/infrastructure/data/group_model_data.rs @@ -1,6 +1,6 @@ use crate::application::GroupModel; use crate::base::default_util::is_default; -use crate::domain::{GroupId, MappingCompartment}; +use crate::domain::{GroupId, MappingCompartment, Tag}; use crate::infrastructure::data::{ActivationConditionData, EnabledData}; use serde::{Deserialize, Serialize}; use std::borrow::BorrowMut; @@ -14,6 +14,8 @@ pub struct GroupModelData { // Because default group name is empty, it won't be serialized. #[serde(default, skip_serializing_if = "is_default")] name: String, + #[serde(default, skip_serializing_if = "is_default")] + pub tags: Vec, #[serde(flatten)] enabled_data: EnabledData, #[serde(flatten)] @@ -25,6 +27,7 @@ impl GroupModelData { GroupModelData { id: model.id(), name: model.name.get_ref().clone(), + tags: model.tags.get_ref().clone(), enabled_data: EnabledData { control_is_enabled: model.control_is_enabled.get(), feedback_is_enabled: model.feedback_is_enabled.get(), @@ -43,6 +46,7 @@ impl GroupModelData { fn apply_to_model(&self, model: &mut GroupModel) { model.name.set_without_notification(self.name.clone()); + model.tags.set_without_notification(self.tags.clone()); model .control_is_enabled .set_without_notification(self.enabled_data.control_is_enabled); diff --git a/main/src/infrastructure/data/mapping_model_data.rs b/main/src/infrastructure/data/mapping_model_data.rs index b26f7f981..428ed0c60 100644 --- a/main/src/infrastructure/data/mapping_model_data.rs +++ b/main/src/infrastructure/data/mapping_model_data.rs @@ -1,7 +1,7 @@ use crate::application::MappingModel; use crate::base::default_util::{bool_true, is_bool_true, is_default}; use crate::domain::{ - ExtendedProcessorContext, FeedbackSendBehavior, GroupId, MappingCompartment, MappingId, + ExtendedProcessorContext, FeedbackSendBehavior, GroupId, MappingCompartment, MappingId, Tag, }; use crate::infrastructure::data::{ ActivationConditionData, EnabledData, MigrationDescriptor, ModeModelData, SourceModelData, @@ -21,6 +21,8 @@ pub struct MappingModelData { #[serde(default, skip_serializing_if = "is_default")] pub name: String, #[serde(default, skip_serializing_if = "is_default")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "is_default")] pub group_id: GroupId, source: SourceModelData, mode: ModeModelData, @@ -44,6 +46,7 @@ impl MappingModelData { MappingModelData { id: Some(model.id()), name: model.name.get_ref().clone(), + tags: model.tags.get_ref().clone(), group_id: model.group_id.get(), source: SourceModelData::from_model(&model.source_model), mode: ModeModelData::from_model(&model.mode_model), @@ -150,6 +153,9 @@ impl MappingModelData { model .name .set_with_optional_notification(self.name.clone(), with_notification); + model + .tags + .set_with_optional_notification(self.tags.clone(), with_notification); model .group_id .set_with_optional_notification(self.group_id, with_notification); diff --git a/main/src/infrastructure/ui/bindings.rs b/main/src/infrastructure/ui/bindings.rs index c9c807156..fb2810b0e 100644 --- a/main/src/infrastructure/ui/bindings.rs +++ b/main/src/infrastructure/ui/bindings.rs @@ -146,6 +146,8 @@ pub mod root { pub const ID_TARGET_LINE_4_COMBO_BOX_2: u32 = 40071; pub const ID_SOURCE_CATEGORY_COMBO_BOX: u32 = 40072; pub const ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX: u32 = 40073; + pub const ID_MAPPING_NAME_EDIT_CONTROL2: u32 = 40073; + pub const ID_MAPPING_TAGS_EDIT_CONTROL: u32 = 40073; pub const ID_SOURCE_MIDI_MESSAGE_TYPE_LABEL_TEXT: u32 = 40074; pub const ID_TARGET_LINE_4_BUTTON: u32 = 40075; pub const ID_TARGET_LINE_2_LABEL_1: u32 = 40076; diff --git a/main/src/infrastructure/ui/group_panel.rs b/main/src/infrastructure/ui/group_panel.rs index ae003dc86..1564e40b4 100644 --- a/main/src/infrastructure/ui/group_panel.rs +++ b/main/src/infrastructure/ui/group_panel.rs @@ -36,6 +36,10 @@ impl GroupPanel { view.mapping_header_panel .invalidate_due_to_changed_prop(ItemProp::Name, initiator); }); + self.when(group.tags.changed_with_initiator(), |view, initiator| { + view.mapping_header_panel + .invalidate_due_to_changed_prop(ItemProp::Tags, initiator); + }); self.when(group.control_is_enabled.changed(), |view, _| { view.mapping_header_panel .invalidate_due_to_changed_prop(ItemProp::ControlEnabled, None); diff --git a/main/src/infrastructure/ui/mapping_header_panel.rs b/main/src/infrastructure/ui/mapping_header_panel.rs index 2f4c0ec59..eac4a8bc2 100644 --- a/main/src/infrastructure/ui/mapping_header_panel.rs +++ b/main/src/infrastructure/ui/mapping_header_panel.rs @@ -11,8 +11,10 @@ use crate::application::{ ActivationType, BankConditionModel, GroupModel, MappingModel, ModifierConditionModel, SharedSession, WeakSession, }; -use crate::domain::{MappingCompartment, COMPARTMENT_PARAMETER_COUNT}; +use crate::domain::{MappingCompartment, Tag, COMPARTMENT_PARAMETER_COUNT}; +use itertools::Itertools; use std::fmt::Debug; +use std::str::FromStr; use swell_ui::{DialogUnits, Point, SharedView, View, ViewContext, Window}; type SharedItem = Rc>; @@ -33,6 +35,8 @@ pub trait Item: Debug { fn supports_activation(&self) -> bool; fn name(&self) -> &str; fn set_name(&mut self, name: String, initiator: u32); + fn tags(&self) -> &[Tag]; + fn set_tags(&mut self, tags: Vec, initiator: u32); fn control_is_enabled(&self) -> bool; fn set_control_is_enabled(&mut self, value: bool); fn feedback_is_enabled(&self) -> bool; @@ -51,6 +55,7 @@ pub trait Item: Debug { pub enum ItemProp { Name, + Tags, ControlEnabled, FeedbackEnabled, ActivationType, @@ -107,6 +112,7 @@ impl MappingHeaderPanel { fn invalidate_controls_internal(&self, item: &dyn Item) { self.invalidate_name_edit_control(item, None); + self.invalidate_tags_edit_control(item, None); self.invalidate_control_enabled_check_box(item); self.invalidate_feedback_enabled_check_box(item); self.invalidate_activation_controls(item); @@ -115,10 +121,10 @@ impl MappingHeaderPanel { fn init_controls(&self) { self.view .require_control(root::ID_MAPPING_CONTROL_ENABLED_CHECK_BOX) - .set_text(format!("{} Control enabled", symbols::arrow_right_symbol())); + .set_text(format!("{} Control", symbols::arrow_right_symbol())); self.view .require_control(root::ID_MAPPING_FEEDBACK_ENABLED_CHECK_BOX) - .set_text(format!("{} Feedback enabled", symbols::arrow_left_symbol())); + .set_text(format!("{} Feedback", symbols::arrow_left_symbol())); self.view .require_control(root::ID_MAPPING_ACTIVATION_TYPE_COMBO_BOX) .fill_combo_box_indexed(ActivationType::into_enum_iter()); @@ -136,6 +142,17 @@ impl MappingHeaderPanel { c.set_enabled(item.supports_name_change()); } + fn invalidate_tags_edit_control(&self, item: &dyn Item, initiator: Option) { + if initiator == Some(root::ID_MAPPING_TAGS_EDIT_CONTROL) { + return; + } + let c = self + .view + .require_control(root::ID_MAPPING_TAGS_EDIT_CONTROL); + let csv: String = item.tags().iter().join(", "); + c.set_text(csv); + } + fn invalidate_control_enabled_check_box(&self, item: &dyn Item) { self.view .require_control(root::ID_MAPPING_CONTROL_ENABLED_CHECK_BOX) @@ -362,6 +379,19 @@ impl MappingHeaderPanel { item.set_name(value, root::ID_MAPPING_NAME_EDIT_CONTROL); } + fn update_tags(&self, item: &mut dyn Item) { + let value = self + .view + .require_control(root::ID_MAPPING_TAGS_EDIT_CONTROL) + .text() + .unwrap_or_else(|_| "".to_string()); + let tags: Vec<_> = value + .split(',') + .filter_map(|item| Tag::from_str(item).ok()) + .collect(); + item.set_tags(tags, root::ID_MAPPING_TAGS_EDIT_CONTROL); + } + fn update_activation_eel_condition(&self, item: &mut dyn Item) { let value = self .view @@ -482,6 +512,7 @@ impl MappingHeaderPanel { use ItemProp::*; match prop { Name => self.invalidate_name_edit_control(item, initiator), + Tags => self.invalidate_tags_edit_control(item, initiator), ControlEnabled => self.invalidate_control_enabled_check_box(item), FeedbackEnabled => self.invalidate_feedback_enabled_check_box(item), ActivationType => self.invalidate_activation_controls(item), @@ -598,6 +629,9 @@ impl View for MappingHeaderPanel { ID_MAPPING_NAME_EDIT_CONTROL => { self.with_mutable_item(Self::update_name); } + ID_MAPPING_TAGS_EDIT_CONTROL => { + self.with_mutable_item(Self::update_tags); + } ID_MAPPING_ACTIVATION_EDIT_CONTROL => { self.with_mutable_item(Self::update_activation_eel_condition); } @@ -640,6 +674,14 @@ impl Item for MappingModel { self.name.set_with_initiator(name, Some(initiator)); } + fn tags(&self) -> &[Tag] { + self.tags.get_ref() + } + + fn set_tags(&mut self, tags: Vec, initiator: u32) { + self.tags.set_with_initiator(tags, Some(initiator)); + } + fn control_is_enabled(&self) -> bool { self.control_is_enabled.get() } @@ -728,6 +770,14 @@ impl Item for GroupModel { self.name.set_with_initiator(name, Some(initiator)); } + fn tags(&self) -> &[Tag] { + self.tags.get_ref() + } + + fn set_tags(&mut self, tags: Vec, initiator: u32) { + self.tags.set_with_initiator(tags, Some(initiator)); + } + fn control_is_enabled(&self) -> bool { self.control_is_enabled.get() } diff --git a/main/src/infrastructure/ui/mapping_panel.rs b/main/src/infrastructure/ui/mapping_panel.rs index 7561a8f3e..40aaaf537 100644 --- a/main/src/infrastructure/ui/mapping_panel.rs +++ b/main/src/infrastructure/ui/mapping_panel.rs @@ -3909,6 +3909,14 @@ impl<'a> ImmutableMappingPanel<'a> { .invalidate_due_to_changed_prop(ItemProp::Name, initiator); }, ); + self.panel.when( + self.mapping.tags.changed_with_initiator(), + |view, initiator| { + view.panel + .mapping_header_panel + .invalidate_due_to_changed_prop(ItemProp::Tags, initiator); + }, + ); self.panel .when(self.mapping.control_is_enabled.changed(), |view, _| { view.panel diff --git a/main/src/infrastructure/ui/mapping_rows_panel.rs b/main/src/infrastructure/ui/mapping_rows_panel.rs index d858da687..bd120a182 100644 --- a/main/src/infrastructure/ui/mapping_rows_panel.rs +++ b/main/src/infrastructure/ui/mapping_rows_panel.rs @@ -4,7 +4,7 @@ use std::rc::{Rc, Weak}; use crate::base::when; use crate::infrastructure::ui::{ bindings::root, get_object_from_clipboard, paste_mappings, util, ClipboardObject, - IndependentPanelManager, MainState, MappingRowPanel, SharedIndependentPanelManager, + IndependentPanelManager, Item, MainState, MappingRowPanel, SharedIndependentPanelManager, SharedMainState, }; use reaper_high::Reaper; @@ -374,7 +374,11 @@ impl MappingRowsPanel { } } let search_expression = main_state.search_expression.get_ref(); - if !search_expression.is_empty() && !search_expression.matches(&mapping.effective_name()) { + if !search_expression.is_empty() + && !search_expression.matches(&mapping.effective_name()) + && !search_expression.matches_any_tag(mapping.tags.get_ref()) + && !search_expression.matches_any_tag_in_group(&mapping, session) + { return false; } true diff --git a/main/src/infrastructure/ui/msvc/Resource.h b/main/src/infrastructure/ui/msvc/Resource.h index 52062aa30..ddeb27f10 100644 --- a/main/src/infrastructure/ui/msvc/Resource.h +++ b/main/src/infrastructure/ui/msvc/Resource.h @@ -144,6 +144,8 @@ #define ID_TARGET_LINE_4_COMBO_BOX_2 40071 #define ID_SOURCE_CATEGORY_COMBO_BOX 40072 #define ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX 40073 +#define ID_MAPPING_NAME_EDIT_CONTROL2 40073 +#define ID_MAPPING_TAGS_EDIT_CONTROL 40073 #define ID_SOURCE_MIDI_MESSAGE_TYPE_LABEL_TEXT 40074 #define ID_TARGET_LINE_4_BUTTON 40075 #define ID_TARGET_LINE_2_LABEL_1 40076 diff --git a/main/src/infrastructure/ui/msvc/msvc.rc b/main/src/infrastructure/ui/msvc/msvc.rc index 6e2d54390..9cb07731d 100644 Binary files a/main/src/infrastructure/ui/msvc/msvc.rc and b/main/src/infrastructure/ui/msvc/msvc.rc differ diff --git a/main/src/infrastructure/ui/realearn.rc_mac_dlg b/main/src/infrastructure/ui/realearn.rc_mac_dlg index 0dd0ccdb9..d84b6f8b5 100644 --- a/main/src/infrastructure/ui/realearn.rc_mac_dlg +++ b/main/src/infrastructure/ui/realearn.rc_mac_dlg @@ -301,11 +301,10 @@ SWELL_DEFINE_DIALOG_RESOURCE_END(ID_MESSAGE_PANEL) #endif SWELL_DEFINE_DIALOG_RESOURCE_BEGIN(ID_SHARED_GROUP_MAPPING_PANEL,SET_ID_SHARED_GROUP_MAPPING_PANEL_STYLE,"",440,40,SET_ID_SHARED_GROUP_MAPPING_PANEL_SCALE) BEGIN -EDITTEXT ID_MAPPING_NAME_EDIT_CONTROL,33,3,220,14,ES_MULTILINE | ES_AUTOHSCROLL -CONTROL "=> Control enabled",ID_MAPPING_CONTROL_ENABLED_CHECK_BOX, -"Button",BS_AUTOCHECKBOX | WS_TABSTOP,263,6,75,8 -CONTROL "<= Feedback enabled",ID_MAPPING_FEEDBACK_ENABLED_CHECK_BOX, -"Button",BS_AUTOCHECKBOX | WS_TABSTOP,347,6,85,8 +EDITTEXT ID_MAPPING_NAME_EDIT_CONTROL,33,3,131,14,ES_MULTILINE | ES_AUTOHSCROLL +CONTROL "=> Control",ID_MAPPING_CONTROL_ENABLED_CHECK_BOX,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,329,6,50,8 +CONTROL "<= Feedback",ID_MAPPING_FEEDBACK_ENABLED_CHECK_BOX, +"Button",BS_AUTOCHECKBOX | WS_TABSTOP,381,6,56,8 COMBOBOX ID_MAPPING_ACTIVATION_TYPE_COMBO_BOX,33,22,102,15,CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_TABSTOP COMBOBOX ID_MAPPING_ACTIVATION_SETTING_1_COMBO_BOX,182,22,90,15,CBS_DROPDOWNLIST | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP CONTROL "",ID_MAPPING_ACTIVATION_SETTING_1_CHECK_BOX,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,276,24,11,8 @@ -317,6 +316,8 @@ LTEXT "Active",ID_MAPPING_ACTIVATION_LABEL,5,24,21,9,NOT WS_GROUP LTEXT "EEL (e.g. y = p1 > 0)",ID_MAPPING_ACTIVATION_EEL_LABEL_TEXT,143,24,70,9,NOT WS_GROUP LTEXT "Modifier 1",ID_MAPPING_ACTIVATION_SETTING_1_LABEL_TEXT,143,24,33,9,NOT WS_GROUP LTEXT "Modifier 2",ID_MAPPING_ACTIVATION_SETTING_2_LABEL_TEXT,292,24,34,9,NOT WS_GROUP +EDITTEXT ID_MAPPING_TAGS_EDIT_CONTROL,194,3,131,14,ES_MULTILINE | ES_AUTOHSCROLL +LTEXT "Tags",30018,172,6,18,9,NOT WS_GROUP END SWELL_DEFINE_DIALOG_RESOURCE_END(ID_SHARED_GROUP_MAPPING_PANEL) diff --git a/main/src/infrastructure/ui/state.rs b/main/src/infrastructure/ui/state.rs index 0cdf9e12a..dd51af3c4 100644 --- a/main/src/infrastructure/ui/state.rs +++ b/main/src/infrastructure/ui/state.rs @@ -1,12 +1,15 @@ use crate::base::{prop, Prop}; -use crate::domain::{CompoundMappingSource, GroupId, MappingCompartment, ReaperTarget}; +use crate::domain::{CompoundMappingSource, GroupId, MappingCompartment, ReaperTarget, Tag}; -use crate::application::MappingModel; +use crate::application::{MappingModel, Session}; +use crate::infrastructure::ui::Item; use enum_map::{enum_map, EnumMap}; use rxrust::prelude::*; +use serde_yaml::Mapping; use std::cell::RefCell; use std::fmt; use std::rc::Rc; +use std::str::FromStr; use wildmatch::WildMatch; pub type SharedMainState = Rc>; @@ -125,7 +128,10 @@ impl MainState { } #[derive(Debug, Clone, PartialEq, Default)] -pub struct SearchExpression(WildMatch); +pub struct SearchExpression { + wild_match: WildMatch, + tag: Option, +} impl SearchExpression { pub fn new(text: &str) -> SearchExpression { @@ -135,11 +141,39 @@ impl SearchExpression { let modified_text = format!("*{}*", text.to_lowercase()); WildMatch::new(&modified_text) }; - Self(wild_match) + fn extract_tag(text: &str) -> Option { + let tag_name = text.strip_prefix('#')?; + tag_name.parse().ok() + } + Self { + wild_match, + tag: extract_tag(text), + } } pub fn matches(&self, text: &str) -> bool { - self.0.matches(&text.to_lowercase()) + self.wild_match.matches(&text.to_lowercase()) + } + + pub fn matches_any_tag_in_group(&self, mapping: &MappingModel, session: &Session) -> bool { + if self.tag.is_none() { + return false; + } + if let Some(group) = session + .find_group_by_id_including_default_group(mapping.compartment(), mapping.group_id.get()) + { + self.matches_any_tag(group.borrow().tags()) + } else { + false + } + } + + pub fn matches_any_tag(&self, tags: &[Tag]) -> bool { + if let Some(tag) = &self.tag { + tags.into_iter().any(|t| t == tag) + } else { + false + } } pub fn is_empty(&self) -> bool { @@ -149,7 +183,7 @@ impl SearchExpression { impl fmt::Display for SearchExpression { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = self.0.to_string(); + let s = self.wild_match.to_string(); let s = s.strip_prefix('*').unwrap_or(&s); let s = s.strip_suffix('*').unwrap_or(s); f.write_str(s)