diff --git a/deploy/images/SearchDropdownExampleOpen.png b/deploy/images/SearchDropdownExampleOpen.png new file mode 100644 index 00000000..e671afc6 Binary files /dev/null and b/deploy/images/SearchDropdownExampleOpen.png differ diff --git a/deploy/images/SearchDropdownExampleSearching.png b/deploy/images/SearchDropdownExampleSearching.png new file mode 100644 index 00000000..d6f70136 Binary files /dev/null and b/deploy/images/SearchDropdownExampleSearching.png differ diff --git a/deploy/widget_icons/SearchDropdown.png b/deploy/widget_icons/SearchDropdown.png new file mode 100644 index 00000000..9ee04e0a Binary files /dev/null and b/deploy/widget_icons/SearchDropdown.png differ diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/JMJb_dCLyH7X_iHp2DLoMYNEdokd.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/JMJb_dCLyH7X_iHp2DLoMYNEdokd.xml index 99772b42..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/JMJb_dCLyH7X_iHp2DLoMYNEdokd.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/JMJb_dCLyH7X_iHp2DLoMYNEdokd.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/J_q8ERiDV2uofqHvpCu2mo8uB9Ad.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/J_q8ERiDV2uofqHvpCu2mo8uB9Ad.xml index 99772b42..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/J_q8ERiDV2uofqHvpCu2mo8uB9Ad.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/J_q8ERiDV2uofqHvpCu2mo8uB9Ad.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/S9wGd_pDd1D_9FNVqjfXodz45sod.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/S9wGd_pDd1D_9FNVqjfXodz45sod.xml index 99772b42..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/S9wGd_pDd1D_9FNVqjfXodz45sod.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/S9wGd_pDd1D_9FNVqjfXodz45sod.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/V0mMK9LtsBu9P5LKljXbNE-C5qwd.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/V0mMK9LtsBu9P5LKljXbNE-C5qwd.xml index 99772b42..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/V0mMK9LtsBu9P5LKljXbNE-C5qwd.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/V0mMK9LtsBu9P5LKljXbNE-C5qwd.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/YjX3mbPy2HN8LR96rq4Fzb372VUd.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/YjX3mbPy2HN8LR96rq4Fzb372VUd.xml index 99772b42..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/YjX3mbPy2HN8LR96rq4Fzb372VUd.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/YjX3mbPy2HN8LR96rq4Fzb372VUd.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/fgSmFlkgej181VWOoxvdiZJ1K9gd.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/fgSmFlkgej181VWOoxvdiZJ1K9gd.xml index 7a6326b9..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/fgSmFlkgej181VWOoxvdiZJ1K9gd.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/fgSmFlkgej181VWOoxvdiZJ1K9gd.xml @@ -1,6 +1,6 @@ - + - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/qR28PGzPsksFVChmMp9R0Zbo0uYd.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/qR28PGzPsksFVChmMp9R0Zbo0uYd.xml index 99772b42..378b6137 100644 --- a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/qR28PGzPsksFVChmMp9R0Zbo0uYd.xml +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/qR28PGzPsksFVChmMp9R0Zbo0uYd.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/xTX_9QKVz_sRcYaTywWZA4aZm4kd.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/xTX_9QKVz_sRcYaTywWZA4aZm4kd.xml new file mode 100644 index 00000000..378b6137 --- /dev/null +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/xTX_9QKVz_sRcYaTywWZA4aZm4kd.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/xTX_9QKVz_sRcYaTywWZA4aZm4kp.xml b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/xTX_9QKVz_sRcYaTywWZA4aZm4kp.xml new file mode 100644 index 00000000..fb20dac4 --- /dev/null +++ b/resources/project/Xc4-tOl6vkwzCeVKSWRahPrXJiQ/xTX_9QKVz_sRcYaTywWZA4aZm4kp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/elO4D6tOV7Lp-Jfo7ptgr2NlB30/mBLWFebZLKEYjmQwRCNBG-dcvOEd.xml b/resources/project/elO4D6tOV7Lp-Jfo7ptgr2NlB30/mBLWFebZLKEYjmQwRCNBG-dcvOEd.xml new file mode 100644 index 00000000..99772b42 --- /dev/null +++ b/resources/project/elO4D6tOV7Lp-Jfo7ptgr2NlB30/mBLWFebZLKEYjmQwRCNBG-dcvOEd.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/resources/project/elO4D6tOV7Lp-Jfo7ptgr2NlB30/mBLWFebZLKEYjmQwRCNBG-dcvOEp.xml b/resources/project/elO4D6tOV7Lp-Jfo7ptgr2NlB30/mBLWFebZLKEYjmQwRCNBG-dcvOEp.xml new file mode 100644 index 00000000..fb20dac4 --- /dev/null +++ b/resources/project/elO4D6tOV7Lp-Jfo7ptgr2NlB30/mBLWFebZLKEYjmQwRCNBG-dcvOEp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/f1CasPtPWEKkULO6lQ_WTjau2Nw/thSBeYSSaW6FYs_WYLbsJvWC9Acd.xml b/resources/project/f1CasPtPWEKkULO6lQ_WTjau2Nw/thSBeYSSaW6FYs_WYLbsJvWC9Acd.xml new file mode 100644 index 00000000..99772b42 --- /dev/null +++ b/resources/project/f1CasPtPWEKkULO6lQ_WTjau2Nw/thSBeYSSaW6FYs_WYLbsJvWC9Acd.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/resources/project/f1CasPtPWEKkULO6lQ_WTjau2Nw/thSBeYSSaW6FYs_WYLbsJvWC9Acp.xml b/resources/project/f1CasPtPWEKkULO6lQ_WTjau2Nw/thSBeYSSaW6FYs_WYLbsJvWC9Acp.xml new file mode 100644 index 00000000..8222afbe --- /dev/null +++ b/resources/project/f1CasPtPWEKkULO6lQ_WTjau2Nw/thSBeYSSaW6FYs_WYLbsJvWC9Acp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qPaYXYIWInHoCm9ctG8-uEqdxlk/Kc8NyOxBHteHzaAs2Svjccxs8jsd.xml b/resources/project/qPaYXYIWInHoCm9ctG8-uEqdxlk/Kc8NyOxBHteHzaAs2Svjccxs8jsd.xml new file mode 100644 index 00000000..a75f7a81 --- /dev/null +++ b/resources/project/qPaYXYIWInHoCm9ctG8-uEqdxlk/Kc8NyOxBHteHzaAs2Svjccxs8jsd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qPaYXYIWInHoCm9ctG8-uEqdxlk/Kc8NyOxBHteHzaAs2Svjccxs8jsp.xml b/resources/project/qPaYXYIWInHoCm9ctG8-uEqdxlk/Kc8NyOxBHteHzaAs2Svjccxs8jsp.xml new file mode 100644 index 00000000..f1a9071d --- /dev/null +++ b/resources/project/qPaYXYIWInHoCm9ctG8-uEqdxlk/Kc8NyOxBHteHzaAs2Svjccxs8jsp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/_08KYHmkQrwI4wltgg7WZLZLxR4d.xml b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/_08KYHmkQrwI4wltgg7WZLZLxR4d.xml new file mode 100644 index 00000000..a75f7a81 --- /dev/null +++ b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/_08KYHmkQrwI4wltgg7WZLZLxR4d.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/_08KYHmkQrwI4wltgg7WZLZLxR4p.xml b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/_08KYHmkQrwI4wltgg7WZLZLxR4p.xml new file mode 100644 index 00000000..08a2d7b2 --- /dev/null +++ b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/_08KYHmkQrwI4wltgg7WZLZLxR4p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/rVbGKXEYIRCxCGbb4rKJ4OqWDVod.xml b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/rVbGKXEYIRCxCGbb4rKJ4OqWDVod.xml new file mode 100644 index 00000000..4356a6ae --- /dev/null +++ b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/rVbGKXEYIRCxCGbb4rKJ4OqWDVod.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/rVbGKXEYIRCxCGbb4rKJ4OqWDVop.xml b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/rVbGKXEYIRCxCGbb4rKJ4OqWDVop.xml new file mode 100644 index 00000000..c2b58c46 --- /dev/null +++ b/resources/project/sSpxFEkGAHXZFJ64I8GyrijBl1s/rVbGKXEYIRCxCGbb4rKJ4OqWDVop.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/test/+wt/+test/BaseTest.m b/test/+wt/+test/BaseTest.m index 292fa722..4567a861 100644 --- a/test/+wt/+test/BaseTest.m +++ b/test/+wt/+test/BaseTest.m @@ -191,13 +191,34 @@ function verifyPropertyValue(testCase, component, property, expValue) expValue end - import matlab.unittest.constraints.Eventually - import matlab.unittest.constraints.IsEqualTo + import matlab.unittest.constraints.* % Verify values + if isStringScalar(expValue) + + constraint = IsEqualTo(expValue,... + "Using", StringComparator); + + elseif islogical(expValue) + + constraint = IsEqualTo(expValue,... + "Using", LogicalComparator); + + elseif isnumeric(expValue) + + constraint = IsEqualTo(expValue,... + "Using", NumericComparator); + + else + + constraint = IsEqualTo(expValue); + + end + + % Perform the verification testCase.verifyThat(... @()get(component, property),... - Eventually(IsEqualTo(expValue), "WithTimeoutOf", 5)); + Eventually(constraint, "WithTimeoutOf", 5)); end %function diff --git a/test/+wt/+test/BaseWidgetTest.m b/test/+wt/+test/BaseWidgetTest.m index c56c6008..ecb5707e 100644 --- a/test/+wt/+test/BaseWidgetTest.m +++ b/test/+wt/+test/BaseWidgetTest.m @@ -114,6 +114,10 @@ function verifySetProperty(testCase,propName,newValue,expValue) % Verify new property value actualValue = testCase.Widget.(propName); testCase.verifyEquality(actualValue, expValue); + + % Tried to use this one, but the above works better because + % strings can equate to enumeration strings, etc. + % testCase.verifyPropertyValue(testCase.Widget, propName, expValue); end %function @@ -134,7 +138,7 @@ function verifySetPropertyError(testCase,propName,newValue,errorId) end %function - function verifyTypeAction(testCase,control,newValue,propName,expValue) + function verifyTypeAction(testCase,component,newValue,propName,expValue) % If no expected new value after set was given, assume it % matches the newValue provided as usual @@ -143,12 +147,16 @@ function verifyTypeAction(testCase,control,newValue,propName,expValue) end % Type the new value into the control - testCase.type(control, newValue); + testCase.type(component, newValue); % Verify new property value actualValue = testCase.Widget.(propName); testCase.verifyEquality(actualValue, expValue); - + + % Tried to use this one, but the above works better because + % strings can equate to enumeration strings, etc. + % testCase.verifyPropertyValue(component, propName, expValue); + end %function diff --git a/test/+wt/+test/SearchDropDown.m b/test/+wt/+test/SearchDropDown.m new file mode 100644 index 00000000..3120f1ae --- /dev/null +++ b/test/+wt/+test/SearchDropDown.m @@ -0,0 +1,129 @@ +classdef SearchDropDown < wt.test.BaseWidgetTest + % Implements a unit test for a widget or component + + % Copyright 2025 The MathWorks Inc. + + %% Properties + properties + ItemNames = [ + "Voltage A" + "Voltage B" + "Current A" + "Current B" + "Power A" + "Power B" + ]; + ItemData = [ + 11 + 12 + 21 + 22 + 31 + 32 + ]; + end + + + %% Class Setup + methods (TestClassSetup) + + function createFigure(testCase) + + % Start with superclass + testCase.createFigure@wt.test.BaseWidgetTest(); + + % Adjust grid size + testCase.Figure.Position(3:4) = [1200 700]; + testCase.Grid.RowHeight = repmat({'fit'},1,4); + testCase.Grid.ColumnWidth = repmat({'1x'},1,6); + + end %function + + end %methods + + + %% Test Method Setup + methods (TestMethodSetup) + + function setup(testCase) + + fcn = @()wt.SearchDropDown(testCase.Grid); + testCase.Widget = verifyWarningFree(testCase,fcn); + drawnow + + % Set the initial items + testCase.verifySetProperty("Items", testCase.ItemNames); + + % Verify empty default + expValue = ""; + testCase.verifyPropertyValue(testCase.Widget, "Value", expValue) + testCase.verifyPropertyValue(testCase.Widget.EditField, "Value", char(expValue)) + + % Set callback + testCase.Widget.ValueChangedFcn = @(s,e)onCallbackTriggered(testCase,e); + + end %function + + end %methods + + + %% Unit Test + methods (Test) + + function testValueProperty(testCase) + + % Get the component + comp = testCase.Widget; + + % Set a value from the list + expValue = testCase.ItemNames(3); + testCase.Widget.Value = expValue; + testCase.verifyPropertyValue(comp, "Value", expValue) + testCase.verifyPropertyValue(comp.EditField, "Value", char(expValue)) + + % Clear the value + expValue = ""; + testCase.Widget.Value = expValue; + testCase.verifyPropertyValue(comp, "Value", expValue) + testCase.verifyPropertyValue(comp.EditField, "Value", char(expValue)) + + % Verify number of callbacks so far + testCase.verifyCallbackCount(0) + + end %function + + + + function testInteractivePartialSearch(testCase) + + % Get the component + comp = testCase.Widget; + + % Simulate typing to filter the dropdown items + testCase.type(comp.EditField, "urren") + testCase.verifyPropertyValue(comp.ListBox, "Items", cellstr(testCase.ItemNames(3:4)')); + + % Verify number of callbacks so far + testCase.verifyCallbackCount(1) + + % Ensure the listbox is showing + % It might not be due to the nature of the type command + comp.SearchPanel.Visible = true; + + % Simulate selecting the second item from the filtered list + testCase.choose(comp.ListBox, 2); + + testCase.verifyPropertyValue(comp, "Value", testCase.ItemNames(4)); + testCase.verifyPropertyValue(comp.EditField, "Value", char(testCase.ItemNames(4))); + + % Verify number of callbacks so far + testCase.verifyCallbackCount(2) + + % Verify the list is no longer visible + testCase.verifyNotVisible(comp.SearchPanel) + + end %function + + end %methods (Test) + +end %classdef \ No newline at end of file diff --git a/widgets/+wt/SearchDropDown.m b/widgets/+wt/SearchDropDown.m new file mode 100644 index 00000000..0add4a62 --- /dev/null +++ b/widgets/+wt/SearchDropDown.m @@ -0,0 +1,893 @@ +classdef SearchDropDown < wt.abstract.BaseWidget &... + wt.mixin.Enableable & ... + wt.mixin.FieldColorable & ... + wt.mixin.FontStyled & ... + wt.mixin.Tooltipable + % Searchable text field with drop down list + + % Copyright 2025 The MathWorks Inc. + + % This is a prototype widget that is like an editable dropdown, but it + % enables you to search/filter better as you type. It combines an edit + % field with a listbox below. Space beneath is required for it to work. + + % Known Issues + % 1. If you are typing in edit field and hit down arrow, it should go + % into the list below, but instead it completes the edit. But if you + % tab or enter to finish editing, it should complete the edit. + + + %% Events + events (HasCallbackProperty, NotifyAccess = protected) + + % Triggered on value changed, has companion callback + ValueChanged + + end %events + + + %% Public properties + properties (AbortSet) + + % Placeholder + Placeholder (1,1) string = "" + + % List of items to choose from + Items (:,1) string + + % Confirmed value, selected by the user + Value (1,1) string + + end %properties + + + %% Read-only properties + properties (SetAccess = protected, UsedInUpdate = false) + + % Current typed value, displayed in the edit field + EditingValue (1,1) string + + end %properties + + + + %% Internal Properties + properties (Hidden) + + Debug (1,1) logical = false + + end %properties + + + properties (Dependent, Hidden, SetAccess = immutable) + + % Indicates search panel exists + SearchExists (1,1) logical + + % Indicates search is open + SearchOpen (1,1) logical + + end %properties + + + properties (Transient, NonCopyable, Hidden, ... + SetAccess = protected, UsedInUpdate = false) + + % Edit field + EditField matlab.ui.control.EditField + + % Search panel for drop down + SearchPanel matlab.ui.container.Panel + + % Grid for drop down + SearchGrid matlab.ui.container.GridLayout + + % List selection + ListBox matlab.ui.control.ListBox + + % Event listeners + LocationChangedListener event.listener + FigureCurrentObjectListener event.proplistener + FigureActivatedListener event.listener + FigureDeactivatedListener event.listener + WindowKeyPressListener event.listener + + ListButtonDownListener event.listener + WindowMouseDownListener event.listener + WindowMouseReleaseListener event.listener + + + LastClick (1,1) string + + % Figure ancestor + Figure matlab.ui.Figure + + end %properties + + % Accessors + methods + function tf = get.SearchOpen(obj) + tf = obj.SearchExists && obj.SearchPanel.Visible; + end + function tf = get.SearchExists(obj) + tf = isscalar(obj.SearchPanel) && isvalid(obj.SearchPanel); + end + end + + + %% Protected methods + methods (Access = protected) + + function setup(obj) + + % Call superclass method + obj.setup@wt.abstract.BaseWidget() + + % Set default size + obj.Position(3:4) = [100 30]; + + % Configure Main Grid + % obj.Grid.Padding = 2; + + % Create the edit control for user input + obj.EditField = uieditfield(obj.Grid, 'text'); + obj.EditField.ValueChangingFcn = @(~,evt)onEditValueChanging(obj,evt); + obj.EditField.ValueChangedFcn = @(~,evt)onEditValueChanged(obj,evt); + + % Create the search pannel (hidden) + obj.createSearchPanel() + + % Attach figure + obj.Figure = ancestor(obj,'figure'); + + % Configure listeners + obj.LocationChangedListener = listener(obj.EditField, ... + "LocationChanged", @(~,evt)onLocationChanged(obj,evt)); + + % Listen to figure change + obj.createFigureListeners(); + + % Update the internal component lists + % obj.BackgroundColorableComponents = obj.Grid; + + end %function + + + function update(obj) + + if obj.Debug + disp('update'); + end + + % Update the contents + obj.EditField.Placeholder = obj.Placeholder; + obj.EditField.Value = obj.Value; + % obj.EditField.Value = obj.EditingValue; + + % Update filtered list + % obj.updateFilteredList() + + % Close the search panel + % (without giving focus to the edit field) + obj.closeSearchPanel(false); + + end %function + + + function createSearchPanel(obj) + + if obj.Debug + disp('createSearchPanel'); + end + + % Create the search panel content + obj.SearchPanel = uipanel("Parent",[]); + + % Create the grid layout for the search panel + obj.SearchGrid = uigridlayout(obj.SearchPanel); + obj.SearchGrid.RowHeight = {'1x'}; + obj.SearchGrid.ColumnWidth = {'1x'}; + obj.SearchGrid.Padding = 0; + + % Create the list box for displaying search results + obj.ListBox = uilistbox(obj.SearchGrid); + obj.ListBox.ValueChangedFcn = @(~,evt)onListValueChanged(obj,evt); + + + % Check for clicks + if isMATLABReleaseOlderThan("R2022b") + + else + % R2022b and later can use this + obj.ListBox.ClickedFcn = @(~,evt)onListClicked(obj,evt); + end + + % Listen for button down on list + obj.ListButtonDownListener = listener(obj.ListBox, ... + "ButtonDown", @(~,evt)onListButtonDown(obj,evt)); + + % Later: Add more controls for case sensitive, etc. + + % Update the internal component lists + obj.FontStyledComponents = [obj.EditField, obj.ListBox]; + obj.FieldColorableComponents = [obj.EditField, obj.ListBox]; + obj.EnableableComponents = [obj.EditField]; + obj.TooltipableComponents = [obj.EditField]; + obj.BackgroundColorableComponents = [obj.Grid, obj.SearchPanel, obj.SearchGrid]; + + end %function + + + function openSearchPanel(obj) + + if obj.Debug + disp('openSearchPanel'); + end + + % Has figure changed? + currentFigure = ancestor(obj,'figure'); + if ~isequal(currentFigure, obj.Figure) + obj.onLocationChanged(); + end + + % Does the search panel exist? + if ~obj.SearchExists + obj.createSearchPanel(); + end + + % Show the complete list initially + items = obj.Items; + obj.ListBox.Items = items; + + % What to select as default in list? + if isempty(items) + % Empty list selection + obj.ListBox.Value = {}; + else + % Default to first item + obj.ListBox.Value = items(1); + end + idxInit = find(matches(items, obj.Value), 1); + if isscalar(idxInit) + % Default to matching item + obj.ListBox.Value = items(idxInit); + else + % Empty list selection + obj.ListBox.Value = {}; + end + + % Show the search panel + obj.SearchPanel.Parent = obj.Figure; + + % Update position + obj.updateSearchPanelPosition() + + % Toggle visible + obj.SearchPanel.Visible = 'on'; + + % Focus back on edit field + % disp(" openSearchPanel: Focus EditField"); + % focus(obj.EditField) + + end %function + + + function updateSearchPanelPosition(obj) + + if obj.Debug + disp('updateSearchPanelPosition'); + end + + % if obj.SearchOpen + if obj.SearchExists + + % Calculate position + edPos = getpixelposition(obj.EditField, true); + maxHeight = 300; + x = edPos(1); + w = edPos(3); + h = edPos(2) - 10; + h = min(h, maxHeight); + y = edPos(2) - h; + searchPosition = [x y w h]; + + % Set position + obj.SearchPanel.Position = searchPosition; + + end + + end %function + + + function closeSearchPanel(obj, focusEdit) + + arguments + obj + focusEdit (1,1) logical = false; + end + + if obj.Debug + disp('closeSearchPanel'); + end + + % Is the search panel open? + if obj.SearchOpen + + % Hide the search panel + obj.SearchPanel.Visible = 'off'; + + end %if + + % Is it in focus? If so, focus to the edit field instead + if obj.SearchExists + + if obj.Debug + disp(' closeSearchPanel: check current focus object'); + end + + curObj = obj.Figure.CurrentObject; + + searchObjs = [ + obj.SearchGrid + obj.SearchPanel + obj.ListBox + ]; + + if isscalar(curObj) && any(curObj == searchObjs) && focusEdit + if obj.Debug + disp(" closeSearchPanel: Focus EditField"); + end + focus(obj.EditField) + end + + end + + % curObjNew = obj.Figure.CurrentObject; + + end %function + + + function updateFilteredList(obj) + % display the filtered items in the listbox + + if obj.Debug + disp('updateFilteredList'); + end + + if obj.SearchExists + + allItems = obj.Items; + isMatch = contains(allItems, obj.EditingValue, 'IgnoreCase', true); + items = allItems(isMatch); + + % Update the list contents + obj.ListBox.Items = items; + + % What to select as default in list? + % if isempty(items) + % % Empty list selection + % obj.ListBox.Value = {}; + % elseif isempty(obj.ListBox.Value) + % % Default to first item + % obj.ListBox.Value = items(1); + % else + % % Leave selection as-is + % end + + end %if + + end %function + + + function onListValueChanged(obj,evt) + % Triggered after an item in the list has been selected + + if obj.Debug + fprintf("onListValueChanged EventValue: %s ListboxValue: %s LastClick: %s\n", evt.Value, string(obj.ListBox.Value), obj.LastClick); + end + + newValue = evt.Value; + obj.EditingValue = newValue; + + switch obj.LastClick + + case "ListBox" + + % Clear state + obj.LastClick = ""; + + if isMATLABReleaseOlderThan("R2022b") + + % Accept the value and close the + obj.acceptEditValue(); + + % Close the search panel + % and give focus to the edit field + obj.closeSearchPanel(true); + + end + + end %switch + + end %function + + + function onListClicked(obj,evt) + % Triggered after an item in the list has been clicked + % (R2022b and later only) + + % Get the changed value + % newValue = string(obj.ListBox.Value) + idx = evt.InteractionInformation.Item; + newValue = string(obj.ListBox.Items(idx)); + + if obj.Debug + % disp("onListClicked"); + fprintf("onListClicked Index: %d ItemValue: %s ListboxValue: %s\n", idx, newValue, string(obj.ListBox.Value)); + end + + % Was an item clicked (rather than empty space)? + if isscalar(newValue) + + % Complete the action + obj.EditingValue = newValue; + + % Accept the value + obj.acceptEditValue(); + + % Close the search panel + % Do not give focus to the edit field + obj.closeSearchPanel(false); + + end %if + + end %function + + + function onFigureButtonDown(obj,evt) + % Triggered on any click within the figure + + + if obj.Debug + fprintf("onFigureButtonDown ClickOn: %s ListboxValue: %s\n", class(evt.HitObject), string(obj.ListBox.Value)); + end + + + switch evt.HitObject + + case obj.ListBox + + obj.LastClick = "ListBox"; + + if isMATLABReleaseOlderThan("R2022b") + % For R2022a + + % % Get the changed value + % newValue = string(obj.ListBox.Value); + % + % + % fprintf(" onFigureButtonDown (R2022a) - Accepting ListboxValue: %s\n", newValue); + % + % % Complete the action + % obj.EditingValue = newValue; + % + % % Accept the value and close the + % obj.acceptEditValue(); + % + % % Close the search panel + % obj.closeSearchPanel(true); + + + else + % R2022b and later can use this + + end + + case obj.EditField + + obj.LastClick = "EditField"; + + % Do nothing + % The dropdown is activated elsewhere? + + otherwise + % Clicked any other figure object + + obj.LastClick = ""; + + % Close the search panel + % Do not give focus to the edit field + obj.closeSearchPanel(false); + + end %switch + + end %function + + + function onFigureButtonUp(obj,evt) + % Triggered after + + if obj.Debug + fprintf("onFigureButtonUp Hit: %s ListboxValue: %s\n", class(evt.HitObject), string(obj.ListBox.Value)); + end + + % newValue = string(obj.ListBox.Value) + + % % Close the search panel + % obj.closeSearchPanel(); + + end %function + + + function onListButtonDown(obj,evt) + % Triggered after a key press in the list field + + % Get the changed value + % newValue = string(obj.ListBox.Value) + idx = evt.InteractionInformation.Item; + newValue = string(obj.ListBox.Items(idx)); + + if obj.Debug + fprintf("onListButtonDown Index: %d ItemValue: %s ListboxValue: %s\n", idx, newValue, string(obj.ListBox.Value)); + end + + % Complete the action + obj.EditingValue = newValue; + + % Accept the value + obj.acceptEditValue(); + + % Close the search panel + % and give focus to the edit field + obj.closeSearchPanel(true); + + end %function + + + function onEditValueChanging(obj,evt) + % Triggered on edit field typing + + if obj.Debug + fprintf("onEditValueChanging New EditingValue: %s Existing Value: %s\n", evt.Value, obj.Value); + end + + % Get the editing value + newValue = evt.Value; + + % Update the value property + obj.EditingValue = newValue; + + % Verify search is open + if ~obj.SearchOpen + obj.openSearchPanel(); + end + + % Update filtered list + obj.updateFilteredList() + + end %function + + + function onEditValueChanged(obj,evt) + % Triggered on edit field enter or loss of focus + + if obj.Debug + fprintf("onEditValueChanged New EditingValue: %s Existing Value: %s LastClick: %s\n", evt.Value, obj.Value, obj.LastClick); + end + + switch obj.LastClick + + case "EditField" + + % Clear state + obj.LastClick = ""; + + % Are there items available? + if isempty(obj.ListBox.Items) + % Get the editing value + newValue = evt.Value; + else + % Get the first match in items + newValue = obj.ListBox.Items(1); + end + + % Update the value property + obj.EditingValue = newValue; + + % Accept the value + obj.acceptEditValue(); + + % Close the search panel + %obj.closeSearchPanel(true); + % Disabled this in favor of closing in the update method. The + % problem is in 22a if we start typing in the edit, then click + % an item in the list, this one fires first and hides the list + % before the list's selection callback can occur. + + end %switch + + end %function + + + function onLocationChanged(obj,~) + % Triggered on edit field location (figure) changed + + if obj.Debug + disp("onLoctionChanged"); + end + + % Attach figure + obj.Figure = ancestor(obj,'figure'); + + % Move SearchPanel + if obj.SearchExists + obj.SearchPanel.Parent = obj.Figure; + end + + % Listen to figure events + obj.createFigureListeners(); + + end %function + + + function createFigureListeners(obj) + % Create listeners to figure changes + + if obj.Debug + disp("createFigureListeners"); + end + + % Get the figure ancestor + obj.Figure = ancestor(obj,'figure'); + + % Listen to current object (focus) changes + obj.FigureCurrentObjectListener = listener(obj.Figure, ... + "CurrentObject", "PostSet", ... + @(~,evt)onFigureCurrentObjectChanged(obj,evt)); + + % Listen to figure activation + obj.FigureActivatedListener = listener(obj.Figure, ... + "FigureActivated", ... + @(~,evt)onFigureActivated(obj,evt)); + + % Listen to figure deactivation + obj.FigureDeactivatedListener = listener(obj.Figure, ... + "FigureDeactivated", ... + @(~,evt)onFigureDeactivated(obj,evt)); + + + obj.WindowMouseDownListener = listener(obj.Figure, ... + "WindowMousePress", ... + @(~,evt)onFigureButtonDown(obj,evt)); + + obj.WindowMouseReleaseListener = listener(obj.Figure, ... + "WindowMouseRelease", ... + @(~,evt)onFigureButtonUp(obj,evt)); + + % Listen to key presses + obj.WindowKeyPressListener = listener(obj.Figure, ... + "WindowKeyPress", ... + @(~,evt)onWindowKeyPress(obj,evt)); + + + end %function + + + function onFigureCurrentObjectChanged(obj,~) + % Triggered after figure's current object (focus) changed + + + if obj.Debug + fprintf("onFigureCurrentObjectChanged CurrentObject: %s\n", class(obj.Figure.CurrentObject)); + end + + obj.checkForSearchActivation(); + + end %function + + + function onFigureActivated(obj,~) + % Triggered after figure is activated + + if obj.Debug + disp("onFigureActivated"); + end + + % Close list + % obj.closeSearchPanel(false) + + end %function + + + function onFigureDeactivated(obj,~) + % Triggered after figure is deactivated + + if obj.Debug + disp("onFigureDeactivated"); + end + + % Close list + % obj.closeSearchPanel(false) + + end %function + + + function onWindowKeyPress(obj,evt) + % Triggered after a key is pressed in the figure + + + if obj.Debug + fprintf("onWindowKeyPress Key: %s CurrentObject: %s LastClick: %s\n", evt.Key, class(obj.Figure.CurrentObject), obj.LastClick); + end + + obj.LastClick = ""; + + % Button presses + escPressed = evt.Key == "escape" && isempty(evt.Modifier); + downArrowPressed = evt.Key == "downarrow" && isempty(evt.Modifier); + % upArrowPressed = evt.Key == "uparrow" && isempty(evt.Modifier); + enterPressed = evt.Key == "return" && isempty(evt.Modifier); + + % Search state + searchOpen = obj.SearchOpen; + searchExists = obj.SearchExists; + + % Edit state + curObj = obj.Figure.CurrentObject; + + % Edit focus? + editFocus = isequal(curObj, obj.EditField); + + % Search focus? + if searchExists + searchObjs = [ + obj.SearchGrid + obj.SearchPanel + obj.ListBox + ]; + searchFocus = isscalar(curObj) && any(curObj == searchObjs); + % searchIndexOne = isequal(obj.ListBox.ValueIndex, 1); + else + searchFocus = false; + % searchIndexOne = false; + end + + if editFocus && enterPressed + % If enter pressed, accept the entered value + + obj.acceptEditValue() + + % Close list + % Do not give focus to the edit field + obj.closeSearchPanel(false); + + elseif searchOpen && escPressed + % If ESC is pressed while list is open, close the list + + % Close list + % and give focus to the edit field + obj.closeSearchPanel(true); + + elseif searchOpen && editFocus && downArrowPressed + % Focus the search list + + if obj.Debug + disp(" onWindowKeyPress: Focus ListBox 1"); + end + focus(obj.ListBox) + + % elseif searchOpen && searchFocus && upArrowPressed && searchIndexOne + % + % focus(obj.EditField) + + elseif searchExists && editFocus && downArrowPressed + % Open and focus the search list + + obj.openSearchPanel(); + + if obj.Debug + disp(" onWindowKeyPress: Focus ListBox 3"); + end + focus(obj.ListBox) + + elseif searchExists && ~searchOpen && editFocus + % Open the search panel + + obj.openSearchPanel(); + + elseif searchExists && ~searchOpen && searchFocus + % If search is hidden but still focused, change to the edit + % field focus + + % Don't know why search would be closed here + + if obj.Debug + disp(" onWindowKeyPress: Focus ListBox 4"); + end + + if isMATLABReleaseOlderThan("R2022b") + % Do nothing + % The edit field might actually be focused and selected, + % causing typing to delete the existing text in the edit + % field. + else + focus(obj.EditField) + end + + end %if + + end %function + + + function checkForSearchActivation(obj) + % Check current object and activate search if conditions met + + % What is in focus? + focusObject = obj.Figure.CurrentObject; + + if focusObject == obj.EditField + % Open list + obj.openSearchPanel(); + elseif isequal(focusObject, obj.ListBox) + % Ignore - do nothing + else + % Close list + % Do not give focus to the edit field + obj.closeSearchPanel(false) + end + + end %function + + + function acceptEditValue(obj) + % Accept the currently selected value + + + % Get typed value + editValue = obj.EditingValue; + newValue = editValue; + + if obj.Debug + fprintf("acceptEditValue NewValue: %s OldValue: %s\n", newValue, obj.Value); + end + + % % Get list items + % if obj.SearchExists + % items = obj.ListBox.Items; + % else + % items = obj.Items; + % end + % + % % Select the new value intelligently + % if matches(editValue, items) + % newValue = editValue; + % elseif isempty(items) + % newValue = ""; + % else + % newValue = items(1); + % end + + % Prepare event data + oldValue = obj.Value; + evtOut = wt.eventdata.PropertyChangedData('Value', ... + newValue, oldValue); + + % Update the value + obj.Value = newValue; + + % Request update + obj.requestUpdate(); + + % Close the search panel + % obj.closeSearchPanel(false); + + % Trigger event + notify(obj,"ValueChanged",evtOut); + + end %function + + end %methods + + +end %classdef + diff --git a/widgets/doc/WidgetsList.mlx b/widgets/doc/WidgetsList.mlx index 25b9206f..f1efaad6 100644 Binary files a/widgets/doc/WidgetsList.mlx and b/widgets/doc/WidgetsList.mlx differ diff --git a/widgets/examples/SearchDropDownExample.mlx b/widgets/examples/SearchDropDownExample.mlx new file mode 100644 index 00000000..25b93dbb Binary files /dev/null and b/widgets/examples/SearchDropDownExample.mlx differ diff --git a/widgets/resources/appDesigner.json b/widgets/resources/appDesigner.json index 9af5f45b..3b1b603d 100644 --- a/widgets/resources/appDesigner.json +++ b/widgets/resources/appDesigner.json @@ -305,8 +305,25 @@ 300, 200 ] + }, + { + "className": "wt.SearchDropDown", + "componentName": "Search Drop Down", + "description": "Editable test field that drops down a list of search matches", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAB3RJTUUH6QkLFAIxX0lsjgAAAjVJREFUSIm1lT9P40AQxX+7HieOFVmkoqCgoEhBh0BpEEgI0dHQIFFSUfF1+Ap0JF2gRaJBQqSkBhoaaAKOxuu9gnN0vpyI7/C9zn/03r55M7PGe+/5j5Dn52f6/T516njvaTQaHB4eImma8vb2xtraGsaYWgRUldFohKoi3nuSJGFrawtr7bedGGMYj8c8PDwAIPBpSVVrE8iybPpsv8VWAVKohmFYmwMRKQu8vr5yeXmJMaYWAVVlPB5/CnQ6HdbX13HOfYv4V4gIvV6POI6RZrPJ0tJSbeQFilLJy8sL5+fnRFH0z2S/l9V7T57nnJ6eInmes7i4yP7+Pt77v84gCIJSqMYYPj4+GAwGwM+QW60Ww+GQq6srGo1GZfLJZMLu7i4nJydMJpOpQLPZnG4FAXDOsbKywvb2NkEQVBbIsoxut4u1FhFBVaflKTD15pxDVUsf58EYw/39Pbe3t0RRxNHREUmSlP4RAGstT09P3N3dEYZhJfLitAB5ntNut8mybGZhSmF1c3OT5eVlrP16e3jvCYKA1dXVmbxEZGaeBCAMQ4bDIYPBYG7IeZ4TxzFnZ2ckSVIi/FMHSmH34OCAnZ2duXdC4WBhYQHn3Ny2lsLazc0N19fXlTLw3pOmKcfHx2xsbKCqXwt474njmE6nUxqar6CqtFotrLWl1jbGlHIUgPf3d/b29uj1epUnubhYHh8fZ96naTrNRsIwRFW5uLioRFwFRU7WWoxzzhdjXjeiKOIHJNsIDj55zxIAAAAASUVORK5CYII=", + "category": "Widgets Toolbox", + "authorName": "Robyn Jackey - MathWorks Consulting", + "authorEmail": "", + "version": "1.0", + "avatar": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAeCAIAAABVOSykAAAAB3RJTUUH6QkLFAI2wS35LQAAAGtJREFUaIHt2rENgDAAA8EEsXZSZHJW4CuEdDeB9bXnWmvwzj3GOOd8PeMH9t7X1xv+RKxArECsQKxArECsQKxArECsQKxArECsQKxArECsQKxArECsQKxArECsQKxArECsQKxArGA6hrz3AHo+BisZkRSqAAAAAElFTkSuQmCC", + "defaultPosition": [ + 1, + 1, + 100, + 30 + ] } ], - "schema": 1, - "MATLABRelease": "R2021a" + "schema": 2, + "MATLABRelease": "R2022a" } \ No newline at end of file