diff --git a/test/+wt/+test/DateRangeSlider.m b/test/+wt/+test/DateRangeSlider.m new file mode 100644 index 0000000..bca2cac --- /dev/null +++ b/test/+wt/+test/DateRangeSlider.m @@ -0,0 +1,540 @@ +classdef DateRangeSlider < wt.test.BaseWidgetTest + % Implements a unit test for a widget or component + + + %% Test Method Setup + methods (TestMethodSetup) + + function setup(testCase) + + % Assure the correct MATLAB version is present + assumeMinimumRelease(testCase, "R2024b") + + % Call superclass method + testCase.setup@wt.test.BaseWidgetTest(); + + % Change figure size to accomodate for slider ticks + testCase.Figure.Position(3) = 800; + + % Setup widget + fcn = @()wt.DateRangeSlider(testCase.Grid); + testCase.Widget = verifyWarningFree(testCase,fcn); + + % Set callback + testCase.Widget.ValueChangedFcn = @(s,e)onCallbackTriggered(testCase,e); + + % Ensure it renders + drawnow + + end %function + + end %methods + + + %% Unit Tests + methods (Test) + + function testValueProperty(testCase) + + % Set the value + newValue = datetime("01-Jan-2020") + [days(0) days(2)]; + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValues(newValue); + + % Set the value + newValue = datetime("01-Jan-2020") + [days(1) days(3)]; + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValues(newValue); + + end %function + + + function testValueBoundaries(testCase) + + % Configure the control + expValue = datetime("yesterday") + [-days(20) -days(19)]; + newLimits = datetime("yesterday") + [-days(20) days(15)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(expValue); + + % Set the value within limits + newValue = datetime("yesterday") + [days(10) days(15)]; + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValues(newValue); + + % Change the limits out of bounds + newLimits = datetime("yesterday") + [days(20) days(30)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues([newLimits(1) newLimits(1) + testCase.Widget.MinGap]); + + % Set an invalid value + expValue = [datetime("yesterday") + days(20) datetime("yesterday") + days(20) + testCase.Widget.MinGap]; + invldValue = [datetime("yesterday") + days(31) datetime("yesterday") + days(32)]; + testCase.verifySetPropertyError("Value", invldValue, 'MATLAB:validators:mustBeInRange'); + testCase.verifyControlValues(expValue); + + % Set an invalid value + expValue = [datetime("yesterday") + days(20) datetime("yesterday") + days(20) + testCase.Widget.MinGap]; + invldValue = [datetime("yesterday") + days(15) datetime("yesterday") + days(25)]; + testCase.verifySetPropertyError("Value", invldValue, 'MATLAB:validators:mustBeInRange'); + testCase.verifyControlValues(expValue); + + end %function + + function testDatepicker(testCase) + + % Configure the control + expValue = datetime("today") + [-days(20) -days(19)]; + newLimits = [datetime("today") datetime("today")] + [-days(20) days(15)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(expValue); + + % Enable the datepicker to be able to type + testCase.Widget.DatepickerLeft.Editable = true; + testCase.Widget.DatepickerRight.Editable = true; + + % Pick a date using datepicker (right) + newValue = datetime("today") + [-days(20) days(15)]; + testCase.verifyTypeAction(testCase.Widget.DatepickerRight, newValue(2), "Value", newValue) + testCase.verifyControlValues(newValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([true false false true]) + testCase.verifyCallbackCount(1); + + newValue = datetime("today") + [-days(20) days(10)]; + testCase.verifyTypeAction(testCase.Widget.DatepickerRight, newValue(2), "Value", newValue) + testCase.verifyControlValues(newValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([true false true true]) + testCase.verifyCallbackCount(2); + + % Pick a date using datepicker (left) + newValue = datetime("today") + [days(9) days(10)]; + testCase.verifyTypeAction(testCase.Widget.DatepickerLeft, newValue(1), "Value", newValue) + testCase.verifyControlValues(newValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([false true true false]) + testCase.verifyCallbackCount(3); + + newValue = datetime("today") + [-days(10) days(10)]; + testCase.verifyTypeAction(testCase.Widget.DatepickerLeft, newValue(1), "Value", newValue) + testCase.verifyControlValues(newValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([true true true true]) + testCase.verifyCallbackCount(4); + + + % Pick an out-of-range date using datepicker + expValue = datetime("today") + [-days(10) days(10)]; + + invldValue = datetime("today") + days(10); + testCase.verifyTypeAction(testCase.Widget.DatepickerLeft, invldValue, "Value", expValue) + testCase.verifyControlValues(expValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([true true true true]) + testCase.verifyCallbackCount(4); + + invldValue = datetime("today") - days(10); + testCase.verifyTypeAction(testCase.Widget.DatepickerRight, invldValue, "Value", expValue) + testCase.verifyControlValues(expValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([true true true true]) + testCase.verifyCallbackCount(4); + + end + + function testDatepickerButtons(testCase) + + % Configure the control + newLimits = datetime("yesterday") + [days(0) days(10)]; + expValue = newLimits; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifySetProperty("Value", expValue); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(expValue); + + % Verify buttons enabled state + testCase.verifyButtonsEnabled([true false false true]) + + % Push down is disabled + testCase.verifyButtonPushAction("left", "down", expValue) + testCase.verifyButtonPushAction("right", "up", expValue) + testCase.verifyCallbackCount(0); + + % Push the button up once + testCase.verifyButtonPushAction("left", "up", expValue + days([1 0])) + testCase.verifyButtonPushAction("right", "down", expValue + days([1 -1])) + testCase.verifyControlValues(expValue + days([1 -1])); + testCase.verifyControlLimits(newLimits); + testCase.verifyButtonsEnabled([true true true true]) + testCase.verifyCallbackCount(2); + + % Change step size and push down + testCase.Widget.Step = caldays(4); + testCase.verifyButtonPushAction("left", "down", expValue + days([0 -1])) + testCase.verifyButtonsEnabled([true false true true]) + testCase.verifyCallbackCount(3); + + % Push up + testCase.verifyButtonPushAction("left", "up", expValue + days([4 -1])) + testCase.verifyButtonPushAction("left", "up", expValue + days([8 -1])) + testCase.verifyCallbackCount(5); + + % Push up disabled at this point + testCase.verifyButtonsEnabled([false true true false]) + testCase.verifyButtonPushAction("left", "up", expValue + days([8 -1])) + testCase.verifyCallbackCount(5); + + % Push down + testCase.verifyButtonPushAction("left", "down", expValue + days([4 -1])) + testCase.verifyButtonPushAction("left", "down", expValue + days([0 -1])) + testCase.verifyButtonsEnabled([true false true true]) + testCase.verifyCallbackCount(7); + + % Push up + testCase.verifyButtonPushAction("right", "up", expValue + days([0 0])) + testCase.verifyButtonsEnabled([true false false true]) + testCase.verifyCallbackCount(8); + + % Push down + testCase.verifyButtonPushAction("right", "down", expValue + days([0 -4])) + testCase.verifyButtonPushAction("right", "down", expValue + days([0 -8])) + testCase.verifyButtonPushAction("right", "down", expValue + days([0 -9])) + testCase.verifyButtonsEnabled([false false true false]) + testCase.verifyControlValues(expValue + days([0 -9])); + testCase.verifyControlLimits(newLimits); + testCase.verifyCallbackCount(11); + + end + + function testTickLabels(testCase) + + % Change the limits + newLimits = datetime("today") + days([5 10]); + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(datetime("today") + days([5 6])); + + % Allow some time for the component to catch up + drawnow; + pause(2) + + % Check tick labels + expLabels = string(datetime("today") + days(5:10)); + actualLabels = testCase.Widget.Slider.MajorTickLabels; + actualLabels = convertCharsToStrings(actualLabels(1:min(6, numel(actualLabels)))); + + testCase.verifyEqual(actualLabels, expLabels) + + % Change the limits + newLimits = datetime("today") + days([0 10]); + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(datetime("today") + days([5 6])); + + % Allow some time for the component to catch up + drawnow + pause(2) + + % Check tick labels + expLabels = string(datetime("today") + days(0:2:10)); + actualLabels = testCase.Widget.Slider.MajorTickLabels; + actualLabels = convertCharsToStrings(actualLabels(1:min(6, numel(actualLabels)))); + testCase.verifyEqual(actualLabels, expLabels) + + % Change datepicker size so that slider barely fits + testCase.Widget.DatepickerSize = 220; + + % Allow some time for the component to catch up + drawnow + pause(2) + + % Change the limits + newLimits = datetime("today") + days([0 20]); + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(datetime("today") + days([5 6])); + + % Allow some time for the component to catch up + drawnow + pause(2) + + % Check widgets exist + expLabels = string(datetime("today") + days([0 10 20])); + actualLabels = testCase.Widget.Slider.MajorTickLabels; + actualLabels = convertCharsToStrings(actualLabels(1:min(3, numel(actualLabels)))); + testCase.verifyEqual(actualLabels, expLabels) + + end %function + + + function testMinGap(testCase) + + % Configure the control + newLimits = datetime("today") + days([50 100]); + newValue = datetime("today") + days([50 50]); + testCase.verifySetProperty("MinGap", 0, days(0)); + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValues(newValue) + + % Change the minimum gap + minGap = 10; + newValue = datetime("today") + days(50 + [0 minGap]); + testCase.verifySetProperty("MinGap", minGap, days(minGap)); + testCase.verifyControlValues(newValue) + testCase.verifyButtonsEnabled(["off" "off" "on" "off"]) + + % Change the value + newValue = datetime("today") + days(70 + [-1 minGap + 1]); + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValues(newValue) + testCase.verifyButtonsEnabled(["on" "on" "on" "on"]) + + newValue = datetime("today") + days(70 + [0 minGap]); + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValues(newValue) + testCase.verifyButtonsEnabled(["off" "on" "on" "off"]) + + % Increase gap + minGap = 20; + newValue = datetime("today") + days(70 + [0 minGap]); + testCase.verifySetProperty("MinGap", minGap, days(minGap)); + testCase.verifyControlValues(newValue) + + minGap = 30; + newValue = datetime("today") + days(70 + [0 minGap]); + testCase.verifySetProperty("MinGap", minGap, days(minGap)); + testCase.verifyControlValues(newValue) + testCase.verifyButtonsEnabled(["off" "on" "off" "off"]) + + % Increate gap even further + minGap = 40; + newValue = datetime("today") + days(60 + [0 minGap]); + testCase.verifySetProperty("MinGap", minGap, days(minGap)); + testCase.verifyControlValues(newValue) + + minGap = 50; + newValue = datetime("today") + days(50 + [0 minGap]); + testCase.verifySetProperty("MinGap", minGap, days(minGap)); + testCase.verifyControlValues(newValue) + testCase.verifyButtonsEnabled(["off" "off" "off" "off"]) + + % Check decreasing the limits + newLimits = datetime("today") + days(60 + [0 20]); + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits) + testCase.verifyEqual(testCase.Widget.MinGap, days(20)); + + % MinGap cannot exceed limits + testCase.verifyError(@() set(testCase.Widget, 'MinGap', 30), 'MATLAB:validators:mustBeGreaterThanOrEqual') + testCase.verifyError(@() set(testCase.Widget, 'MinGap', -1), 'MATLAB:validators:mustBeNonnegative') + testCase.verifyWarningFree(@() set(testCase.Widget, 'MinGap', 10)) + testCase.verifyButtonsEnabled(["off" "off" "off" "off"]) + + % No callbacks fired + testCase.verifyCallbackCount(0) + + end %function + + function testValueIndexMappingAndBounds(testCase) + newLimits = datetime("today") + [days(0) days(10)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + + % Set by indices (0-based offsets from lower limit) + testCase.verifySetProperty("ValueIndex", [1 11]); + testCase.verifyControlValues([newLimits(1) newLimits(2)]); + + % Mid-range indices + testCase.verifySetProperty("ValueIndex", [2 7]); + testCase.verifyControlValues([newLimits(1)+days(1) newLimits(1)+days(6)]); + + % Non-increasing should error (identifier currently has a typo in the class) + testCase.verifySetPropertyError("ValueIndex", [5 4], 'DateRageSlider:mustBeIncreasing'); + + % Out-of-bounds should error + testCase.verifySetPropertyError("ValueIndex", [-1 3], 'MATLAB:validators:mustBeInRange'); + testCase.verifySetPropertyError("ValueIndex", [0 11], 'MATLAB:validators:mustBeInRange'); + end %function + + function testOrientationVerticalLayout(testCase) + % Switch to vertical and verify positions/size wiring + testCase.verifySetProperty("Orientation", wt.enum.HorizontalVerticalState.vertical); + % Force a distinctive size to assert against + testCase.verifySetProperty("DatepickerSize", 180); + + % Let layout settle + drawnow; + + % Grid is 2 rows; first row equals DatepickerSize + testCase.verifyEqual(testCase.Widget.Grid.RowHeight{1}, 180); + + % Left side positions + testCase.verifyEqual(testCase.Widget.GridButtonLeft.Layout.Row, 1); + testCase.verifyEqual(testCase.Widget.GridButtonLeft.Layout.Column, 1); + testCase.verifyEqual(testCase.Widget.DatepickerLeft.Layout.Row, 1); + testCase.verifyEqual(testCase.Widget.DatepickerLeft.Layout.Column, 2); + + % Right side positions + testCase.verifyEqual(testCase.Widget.GridButtonRight.Layout.Row, 1); + testCase.verifyEqual(testCase.Widget.GridButtonRight.Layout.Column, 5); + testCase.verifyEqual(testCase.Widget.DatepickerRight.Layout.Row, 1); + testCase.verifyEqual(testCase.Widget.DatepickerRight.Layout.Column, 4); + + % Slider spans the second row + testCase.verifyEqual(testCase.Widget.Slider.Layout.Row, 2); + testCase.verifyEqual(testCase.Widget.Slider.Layout.Column, [1 5]); + end %function + + function testDisplayFormatPropagation(testCase) + fmt = "yyyy-MM-dd"; + testCase.verifySetProperty("DisplayFormat", fmt); + drawnow; + + % Datepickers should reflect the same format + testCase.verifyEquality(testCase.Widget.DatepickerLeft.DisplayFormat, fmt); + testCase.verifyEquality(testCase.Widget.DatepickerRight.DisplayFormat, fmt); + + % Tick labels non-empty (format checked indirectly) + labels = testCase.Widget.Slider.MajorTickLabels; + testCase.verifyTrue(~isempty(labels)); + end %function + + % function testValueChangingEventOnDrag(testCase) + % + % % Add this callback only for this test + % testCase.Widget.ValueChangingFcn = @(s,e)onCallbackTriggered(testCase,e); + % + % % NOTE: "drag" gesture does not support objects of class "matlab.ui.control.RangeSlider". + % end %function + + function testStepWithCalendarMonthsAndMinGap(testCase) + base = datetime(2020,1,1); + newLimits = base + [days(0) calmonths(3)]; % Jan 1 .. Apr 1 + testCase.verifySetProperty("Limits", newLimits); + + % Start mid-range + startVal = [base + calmonths(1), base + calmonths(2)]; % Feb 1 .. Mar 1 + testCase.verifySetProperty("Value", startVal); + + % Use month step + testCase.Widget.Step = calmonths(1); + testCase.verifyButtonsEnabled(["on" "on" "on" "on"]); + + % Increase MinGap; inward moves should be prevented or error via Value validation + testCase.verifySetProperty("MinGap", 20, days(20)); + + % Left "up" (narrows gap) — with current code this likely errors due to hardcoded days(1) + testCase.verifyError(@() testCase.press(testCase.Widget.ButtonsLeft(1)), ... + 'MATLAB:validators:mustBeGreaterThanOrEqual'); + + % Right "down" (narrows gap) — same expectation + testCase.verifyError(@() testCase.press(testCase.Widget.ButtonsRight(2)), ... + 'MATLAB:validators:mustBeGreaterThanOrEqual'); + end %function + + function testLimitsNormalizationToStartOfDay(testCase) + d1 = dateshift(datetime("today"), 'start', 'day') + hours(9); + d2 = d1 + days(5) + hours(17); + + testCase.verifySetProperty("Limits", [d1 d2], [d1 d2] - timeofday([d1 d2])); + end + + end %methods (Test) + + %% Helper methods + methods (Access = private) + + function verifyControlValues(testCase, dateValue, absTol) + % Verifies the control fields have the specified value + + arguments + testCase + dateValue (1,2) datetime + absTol (1,1) double = 0 + end + + drawnow + + numValue = days(dateValue - testCase.Widget.Limits(1)) + 1; + testCase.verifyEqual(testCase.Widget.Slider.Value, numValue, 'AbsTol', absTol); + testCase.verifyEqual(testCase.Widget.DatepickerLeft.Value, dateValue(1), 'AbsTol', absTol); + testCase.verifyEqual(testCase.Widget.DatepickerRight.Value, dateValue(2), 'AbsTol', absTol); + + end %function + + function verifyControlLimits(testCase, dateLimits, absTol) + % Verifies the control fields have the specified limit + + arguments + testCase + dateLimits (1,2) datetime + absTol (1,1) double = 0 + end + + drawnow + + % What is the minimum gap? + minGap = testCase.Widget.MinGap; + + % Verify slider limits + numLimits = [0 days(dateLimits(2) - dateLimits(1))] + 1; + testCase.verifyEqual(testCase.Widget.Slider.Limits, numLimits, 'AbsTol', absTol); + + % Verify left datepicker + limitsLeft = [dateLimits(1) testCase.Widget.DatepickerRight.Value - minGap]; + testCase.verifyEqual(testCase.Widget.DatepickerLeft.Limits, limitsLeft, 'AbsTol', absTol); + + % Verify right datepicker + limitsRight = [testCase.Widget.DatepickerLeft.Value + minGap dateLimits(2)]; + testCase.verifyEqual(testCase.Widget.DatepickerRight.Limits, limitsRight, 'AbsTol', absTol); + + end %function + + function verifyButtonsEnabled(testCase, enabledStatus) + % Verifies the control buttons have the specified enable status + + arguments + testCase + enabledStatus (1,4) matlab.lang.OnOffSwitchState = [true true true true] + end + + testCase.verifyEqual([testCase.Widget.ButtonsLeft.Enable testCase.Widget.ButtonsRight.Enable], enabledStatus); + end %function + + function verifyButtonPushAction(testCase, side, direction, expValue) + + arguments + testCase + side (1,1) string {mustBeMember(side, ["right", "left"])} + direction (1,1) string {mustBeMember(direction, ["up", "down"])} + expValue (1,2) datetime + end + + if side == "right" + buttons = testCase.Widget.ButtonsRight; + else + buttons = testCase.Widget.ButtonsLeft; + end + + if direction == "up" + buttonIdx = 1; + else + buttonIdx = 2; + end + + % Type the new value into the control + testCase.press(buttons(buttonIdx)); + + % Verify new property value + testCase.verifyControlValues(expValue); + + end %function + + end %private methods + +end %classdef \ No newline at end of file diff --git a/test/+wt/+test/DateSlider.m b/test/+wt/+test/DateSlider.m new file mode 100644 index 0000000..ef59d3f --- /dev/null +++ b/test/+wt/+test/DateSlider.m @@ -0,0 +1,408 @@ +classdef DateSlider < wt.test.BaseWidgetTest + % Implements a unit test for a widget or component + + + %% Test Method Setup + methods (TestMethodSetup) + + function setup(testCase) + + % Call superclass method + testCase.setup@wt.test.BaseWidgetTest(); + + % Setup widget + fcn = @()wt.DateSlider(testCase.Grid); + testCase.Widget = verifyWarningFree(testCase,fcn); + + % Set callback + testCase.Widget.ValueChangedFcn = @(s,e)onCallbackTriggered(testCase,e); + + % Ensure it renders + drawnow + + end %function + + end %methods + + %% Unit Tests + methods (Test) + + function testValuePropertyAndBoundaries(testCase) + + % Set the value + newValue = datetime("01-Jan-2020"); + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValue(newValue); + + % Set the value + newValue = datetime("03-Jan-2020"); + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValue(newValue); + + % Configure the control + newLimits = datetime("today") + [-days(20) days(15)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValue(newLimits(1)); + + % Set the value within limits + newValue = datetime("today") + days(15); + testCase.verifySetProperty("Value", newValue); + testCase.verifyControlValue(newValue); + + % Change the limits out of bounds + newLimits = datetime("today") + [days(20) days(30)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValue(newLimits(1)); + + % Set an invalid value + expValue = datetime("today") + days(20); + invldValue = datetime("today") + days(31); + testCase.verifySetPropertyError("Value", invldValue, 'MATLAB:ui:DatePicker:valueNotValid'); + testCase.verifyControlValue(expValue); + + % Set an invalid value + expValue = datetime("today") + days(20); + invldValue = datetime("today") + days(15); + testCase.verifySetPropertyError("Value", invldValue, 'MATLAB:ui:DatePicker:valueNotValid'); + testCase.verifyControlValue(expValue); + + % Configure a simple range + newLimits = datetime("today") + [days(0) days(10)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + + % Expect index 1 -> min date, index 11 -> max date + testCase.verifySetProperty("ValueIndex", 1); + testCase.verifyControlValue(newLimits(1)); + testCase.verifySetProperty("ValueIndex", 11); + testCase.verifyControlValue(newLimits(2)); + + % Out-of-bounds should error + testCase.verifySetPropertyError("ValueIndex", -1, 'MATLAB:validators:mustBeInRange'); + testCase.verifySetPropertyError("ValueIndex", 12, 'MATLAB:validators:mustBeInRange'); + + end %function + + function testDatepicker(testCase) + + % Configure the control + newLimits = datetime("today") + [-days(20) days(15)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValue(newLimits(1)); + + % Enable the datepicker to be able to type + testCase.Widget.Datepicker.Editable = true; + + % Pick a date using datepicker + newValue = datetime("today") + days(15); + testCase.verifyTypeAction(testCase.Widget.Datepicker, newValue, "Value") + testCase.verifyEqual(testCase.Widget.Value, newValue) + testCase.verifyCallbackCount(1); + + newValue = datetime("today") + days(10); + testCase.verifyTypeAction(testCase.Widget.Datepicker, newValue, "Value") + testCase.verifyEqual(testCase.Widget.Value, newValue) + testCase.verifyCallbackCount(2); + + % Pick an out-of-range date using datepicker + invldValue = datetime("today") + days(20); + testCase.verifyTypeAction(testCase.Widget.Datepicker, invldValue, "Value", newValue) + testCase.verifyEqual(testCase.Widget.Value, newValue) + testCase.verifyCallbackCount(2); + + % Change date using value property + newValue = datetime("today") - days(20); + testCase.verifySetProperty("Value", newValue) + testCase.verifyEqual(testCase.Widget.Datepicker.Value, newValue) + testCase.verifyCallbackCount(2); + + end + + function testDatepickerButtons(testCase) + + % Configure the control + newLimits = datetime("today") + [days(0) days(10)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValue(newLimits(1)); + + % Verify buttons enabled state + testCase.verifyButtonsEnabled([true false]) + + % Push down is disabled + testCase.verifyButtonPushAction("down", newLimits(1)) + testCase.verifyCallbackCount(0); + + % Push the button up once + testCase.verifyButtonPushAction("up", newLimits(1) + days(1)) + testCase.verifyButtonsEnabled([true true]) + testCase.verifyCallbackCount(1); + + % Change step size and push down + testCase.Widget.Step = caldays(4); + testCase.verifyButtonPushAction("down", newLimits(1)) + testCase.verifyButtonsEnabled([true false]) + testCase.verifyCallbackCount(2); + + % Push up + testCase.verifyButtonPushAction("up", newLimits(1) + days(4)) + testCase.verifyButtonPushAction("up", newLimits(1) + days(8)) + testCase.verifyButtonPushAction("up", newLimits(2)) + testCase.verifyCallbackCount(5); + + % Push up disabled at this point + testCase.verifyButtonsEnabled([false true]) + testCase.verifyButtonPushAction("up", newLimits(2)) + testCase.verifyCallbackCount(5); + + end + + function testValueChangingEventOnDrag(testCase) + % Attach ValueChanging callback to count intermediate events + + % Add ValueChanging callback function only for this test + testCase.Widget.ValueChangingFcn = @(s,e)onCallbackTriggered(testCase,e); + + % Configure range + newLimits = datetime("today") + [days(0) days(50)]; + testCase.verifySetProperty("Limits", newLimits); + + % Drag the slider; ValueChanging should fire at least once + sliderControl = testCase.Widget.Slider; + startValue = 5; newValue = 25; + testCase.drag(sliderControl, startValue, newValue); + + % We don't assert exact count (depends on platform), just > 1 + testCase.verifyTrue(testCase.CallbackCount > 1); + end %function + + function testStepWithCalendarMonths(testCase) + % Configure range across months + base = datetime(2020,1,1); + newLimits = base + [days(0) calmonths(2)]; % Jan 1 .. Mar 1 (approx) + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifySetProperty("Value", newLimits(1)); + testCase.verifyControlValue(newLimits(1)); + + % Set step to one calendar month, start at min + testCase.verifySetProperty("Step", calmonths(1)); + + % Push 'up' twice: Jan->Feb->Mar (clamped at max) + testCase.verifyButtonPushAction("up", base + calmonths(1)); + testCase.verifyButtonPushAction("up", newLimits(2)); + + % Push 'down' twice: back to Feb->Jan + testCase.verifyButtonPushAction("down", base + calmonths(1)); + testCase.verifyButtonPushAction("down", newLimits(1)); + end %function + + function testButtonsEnabledMidRange(testCase) + % In mid-range both buttons must be enabled + newLimits = datetime("today") + [days(0) days(10)]; + testCase.verifySetProperty("Limits", newLimits); + mid = newLimits(1) + days(5); + testCase.verifySetProperty("Value", mid); + testCase.verifyButtonsEnabled([true true]); + end %function + + function testLimitsNormalizationToStartOfDay(testCase) + % Provide limits with non-midnight time components + d1 = dateshift(datetime("today"), 'start', 'day') + hours(10); + d2 = d1 + days(3) + hours(12); + testCase.verifySetProperty("Limits", [d1 d2], [d1 d2 ] - timeofday([d1 d2])); + end %function + + function testTickLabels(testCase) + + % Change the limits + expValue = datetime("today") + days(5); + newLimits = datetime("today") + [days(5) days(10)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValue(newLimits(1)); + + % Check tick labels + expLabels = string(datetime("today") + days(5:10)); + actualLabels = testCase.Widget.Slider.MajorTickLabels; + actualLabels = convertCharsToStrings(actualLabels(1:min(6, numel(actualLabels)))); + + testCase.verifyEqual(actualLabels, expLabels) + + % Change the limits + newLimits = datetime("today") + [days(0) days(10)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + testCase.verifyControlValue(expValue); + + % Change datepicker size so that slider barely fits + testCase.Widget.DatepickerSize = 240; + + % Check widgets exist + expLabels = string(datetime("today") + days([0 5 10])); + actualLabels = testCase.Widget.Slider.MajorTickLabels; + actualLabels = convertCharsToStrings(actualLabels(1:min(3, numel(actualLabels)))); + + testCase.verifyEqual(actualLabels, expLabels) + + end %function + + function testSlider(testCase) + + % Configure the control + newLimits = datetime("today") + [days(0) days(100)]; + testCase.verifySetProperty("Limits", newLimits); + testCase.verifyControlLimits(newLimits); + + % Get the control + sliderControl = testCase.Widget.Slider; + + % Click the slider + newValue = 75; + expValue = datetime("today") + days(newValue - 1); + testCase.choose(sliderControl,newValue); + testCase.verifyControlValue(expValue); + testCase.verifyEqual(testCase.Widget.Value, expValue); + + % Verify callback triggered + testCase.verifyEqual(testCase.CallbackCount, 1) + + % Drag the slider + startValue = 1; + newValue = 32; + expValue = datetime("today") + days(newValue - 1); + testCase.drag(sliderControl,startValue,newValue); + testCase.verifyControlValue(expValue); + testCase.verifyEqual(testCase.Widget.Value, expValue); + + % Verify callback triggered + testCase.verifyEqual(testCase.CallbackCount, 2) + + % Drag the slider to a fraction (with rounding turned on) + startValue = 32; + newValue = 13.8; + expValue = datetime("today") + days(round(newValue - 1)); + testCase.drag(sliderControl,startValue,newValue); + testCase.verifyControlValue(expValue); + testCase.verifyEqual(testCase.Widget.Value, expValue); + + % Verify callback triggered + testCase.verifyEqual(testCase.CallbackCount, 3) + + end %function + + function testOrientationVerticalLayout(testCase) + % Switch to vertical orientation + testCase.verifySetProperty("Orientation", wt.enum.HorizontalVerticalState.vertical); + + % Slider orientation must change + testCase.verifyEquality(testCase.Widget.Slider.Orientation, "vertical"); + + % Layout positions should match update() vertical branch + drawnow + testCase.verifyEqual(testCase.Widget.Datepicker.Layout.Row, 2); + testCase.verifyEqual(testCase.Widget.Datepicker.Layout.Column, 1); + testCase.verifyEqual(testCase.Widget.GridButtons.Layout.Row, 2); + testCase.verifyEqual(testCase.Widget.GridButtons.Layout.Column, 2); + + % DatepickerSize affects height in vertical orientation + testCase.verifySetProperty("DatepickerSize", 180); + drawnow + % Grid.RowHeight should include DatepickerSize as second row + testCase.verifyEqual(testCase.Widget.Grid.RowHeight{2}, 180); + end %function + + function testDisplayFormatPropagationAndTicks(testCase) + % Change the display format + fmt = "uuuu-MM-dd"; + testCase.Widget.DisplayFormat = fmt; + drawnow + + % Verify propagation to datepicker and limits + testCase.verifyEquality(testCase.Widget.Datepicker.DisplayFormat, fmt); + testCase.verifyEquality(testCase.Widget.Limits.Format, fmt); + + % Ticks/labels should be updated (non-empty categorical labels) + drawnow + labels = testCase.Widget.Slider.MajorTickLabels; + testCase.verifyTrue(~isempty(labels)); + end %function + + end %methods (Test) + + %% Helper methods + methods (Access = private) + + function verifyControlValue(testCase, dateValue, absTol) + % Verifies the control fields have the specified value + + arguments + testCase + dateValue (1,1) datetime + absTol (1,1) double = 0 + end + + drawnow + + numValue = days(dateValue - testCase.Widget.Limits(1)) + 1; + testCase.verifyEqual(testCase.Widget.Slider.Value, numValue, 'AbsTol', absTol); + testCase.verifyEqual(testCase.Widget.Datepicker.Value, dateValue, 'AbsTol', absTol); + + end %function + + function verifyControlLimits(testCase, dateLimits, absTol) + % Verifies the control fields have the specified limit + + arguments + testCase + dateLimits (1,2) datetime + absTol (1,1) double = 0 + end + + drawnow + + numLimits = [0 days(dateLimits(2) - dateLimits(1))] + 1; + testCase.verifyEqual(testCase.Widget.Slider.Limits, numLimits, 'AbsTol', absTol); + testCase.verifyEqual(testCase.Widget.Datepicker.Limits, dateLimits, 'AbsTol', absTol); + + end %function + + function verifyButtonsEnabled(testCase, enabledStatus) + % Verifies the control buttons have the specified enable status + + arguments + testCase + enabledStatus (1,2) matlab.lang.OnOffSwitchState = [true true] + end + + testCase.verifyEqual([testCase.Widget.Buttons.Enable], enabledStatus); + end %function + + function verifyButtonPushAction(testCase, direction, expValue) + + arguments + testCase + direction (1,1) string {mustBeMember(direction, ["up", "down"])} + expValue (1,1) datetime + end + + if direction == "up" + buttonIdx = 1; + else + buttonIdx = 2; + end + + % Type the new value into the control + testCase.press(testCase.Widget.Buttons(buttonIdx)); + + % Verify new property value + testCase.verifyControlValue(expValue); + + end %function + + end %methods + +end %classdef \ No newline at end of file diff --git a/widgets/+wt/+mixin/TickableDateSlider.m b/widgets/+wt/+mixin/TickableDateSlider.m new file mode 100644 index 0000000..b0a1861 --- /dev/null +++ b/widgets/+wt/+mixin/TickableDateSlider.m @@ -0,0 +1,102 @@ +classdef TickableDateSlider < handle + % Mixin to add date ticks to a slider component + + %% Abstract properties + properties (Abstract, Transient, NonCopyable, Hidden, SetAccess = protected) + + % Slider + Slider matlab.ui.control.internal.model.mixin.SliderComponent {mustBeScalarOrEmpty} + + end + + %% Protected methods + methods (Access = protected) + + function [majorTicks, minorTicks] = getSliderTicks(obj, orientation, options) + % Update the limits in date-picker and slider + + arguments + obj (1,1) + orientation (1,1) string {mustBeMember(orientation, ["horizontal" "vertical"])} = "horizontal"; + options.TickLength (1,1) double {mustBeInteger, mustBePositive} + end + + % How many days? + nDays = obj.Slider.Limits(2); + + % What space is needed for the labels? + % Depends on font size and font weight. + if obj.Slider.FontWeight == "bold" + elSize = 0.6; + else + elSize = 0.5; + end + fontSize = obj.Slider.FontSize; + + % What is the slider orientation? + if orientation == "horizontal" + + % Size of slider. Always in pixel units (cannot be changed) + sliderSpace = obj.Slider.Position(3); + + % Minimum space required for label + if isfield(options, 'TickLength') + tickLength = options.TickLength; + else + tickLength = 11; + end + minSpaceForTickLabel = elSize * fontSize * tickLength; + else + % Size of slider. Always in pixel units (cannot be changed) + sliderSpace = obj.Slider.Position(4); + + % Minimum space required for label + minSpaceForTickLabel = elSize * fontSize * 2; + end + + % How many major ticks fit in the available size? + maxMajorInterval = floor(sliderSpace / minSpaceForTickLabel) - 1; + maxMajorInterval = max(1, maxMajorInterval); + + % How large are the steps for the major ticks? + + % Is the interval size a prime number? + if isprime((nDays - 1)) + majorIntervalStep = max(1, round((nDays - 1) / maxMajorInterval)); + else + majorStepArray = (nDays - 1) ./ (maxMajorInterval:-1:1); + isIntegerStepSize = majorStepArray == floor(majorStepArray); + majorIntervalStep = majorStepArray(find(isIntegerStepSize, 1)); + end + + % At what tick location do the major tick labels need to go? + majorTicks = floor(1:majorIntervalStep:nDays); + majorTicks(end + 1) = nDays; + + % How much space is left for the minor ticks? + minSpaceForMinorTick = 10; + nMajorInterval = ((nDays - 1) / majorIntervalStep); + maxMinorInterval = floor(sliderSpace / nMajorInterval / minSpaceForMinorTick); + + % Is there enough room for all steps that are left between the major + % ticks? + if maxMinorInterval < majorIntervalStep + + % What is the maximum number of minor ticks that can fit in the + % major tick interval? + minorStepArray = majorIntervalStep ./ (maxMinorInterval:-1:1); + isIntegerStepSize = minorStepArray == floor(minorStepArray); + minorIntervalStep = minorStepArray(find(isIntegerStepSize, 1)); + else + minorIntervalStep = 1; + end + + % How much room is available between the major ticks? + minorTickSteps = majorTicks(1):minorIntervalStep:majorTicks(2); + minorTicks = majorTicks + minorTickSteps(:); + minorTicks = unique(minorTicks(:)'); + end + + end + +end \ No newline at end of file diff --git a/widgets/+wt/DateRangeSlider.m b/widgets/+wt/DateRangeSlider.m new file mode 100644 index 0000000..3d2c51c --- /dev/null +++ b/widgets/+wt/DateRangeSlider.m @@ -0,0 +1,624 @@ +classdef DateRangeSlider < wt.abstract.BaseWidget & ... + wt.mixin.TickableDateSlider & ... + wt.mixin.Enableable & ... + wt.mixin.FieldColorable & ... + wt.mixin.FontStyled & ... + wt.mixin.ButtonColorable + % A slider and date-picker combination + + %% Events + events (HasCallbackProperty, NotifyAccess = protected) + + % Triggered on value changed, has companion callback + ValueChanged + + % Triggered on value changing during slider motion, has companion callback + ValueChanging + + end %events + + %% Public properties + properties (AbortSet, Dependent, UsedInUpdate = false) + % These properties do not trigger the update method + + % Value of the slider and date-time selector + Value (1,2) datetime + + end + + properties (AbortSet) + + % Limits of the slider and spinner + Limits (1,2) datetime = datetime("01-Jan-2020") + days([0 3]); + + % Date format + DisplayFormat (1,1) string = 'dd-MMM-uuuu' + + % Orientation of the spinner and slider + Orientation (1,1) wt.enum.HorizontalVerticalState = wt.enum.HorizontalVerticalState.horizontal + + % Size of date-picker (width for horizontal, height for vertical + DatepickerSize = 120 + + end %properties + + properties (AbortSet, Dependent, UsedInUpdate = false) + + % Minimum gap in value range (days) + MinGap (1,1) double {mustBeNonnegative, mustBeInteger} = 1 + + end + + properties (AbortSet, Dependent) + + % Index of selected item + ValueIndex (1,2) double {mustBeInteger} + + end + + properties (AbortSet, UsedInUpdate = false) + % These properties do not trigger the update method + + % Define step size for buttons + Step (1,1) calendarDuration = calendarDuration(0,0,1) + + end + + %% Internal Properties + properties (Access = private) + + % Minimum gap + MinGap_I = 1; + + end + + properties (Transient, NonCopyable, Hidden, SetAccess = protected) + + % Slider + Slider = matlab.ui.control.RangeSlider.empty + + % Date picker on the left + DatepickerLeft matlab.ui.control.DatePicker + + % Date picker on the right + DatepickerRight matlab.ui.control.DatePicker + + % Button grid on the left + GridButtonLeft matlab.ui.container.GridLayout + ButtonsLeft (1,:) matlab.ui.control.Button + + % Button grid on the right + GridButtonRight matlab.ui.container.GridLayout + ButtonsRight (1,:) matlab.ui.control.Button + + end + + %% Accessors + methods + + % ValueIndex + function set.ValueIndex(obj, val) + + % Validate input + maxRange = days(obj.Limits(2) - obj.Limits(1)) + 1; + try + mustBeInRange(val, 1, maxRange) + mustBeIncreasing(val) + catch ME + throwAsCaller(ME) + end + + % Set value + obj.Value = obj.Limits(1) + days(val - 1); + + end %function + function val = get.ValueIndex(obj) + val = days([obj.DatepickerLeft.Value obj.DatepickerRight.Value] - obj.Limits(1)) + 1; + end %function + + % Value + function set.Value(obj, val) + + % Validate input + valueIndex = days(val - obj.Limits(1) + 1); + valueGap = days(diff(val)); + maxRange = days(obj.Limits(2) - obj.Limits(1)) + 1; + try + mustBeInRange(valueIndex, 1, maxRange) + mustBeGreaterThanOrEqual(valueGap, days(obj.MinGap)) + mustBeIncreasing(valueIndex) + catch ME + if strcmp(ME.identifier, 'MATLAB:validators:mustBeInRange') + msg = 'Value must be greater than or equal to %s, and less than or equal to %s.'; + Merr = MException(ME.identifier, ... + msg, ... + char(obj.Limits(1)), ... + char(obj.Limits(2))); + throwAsCaller(Merr); + elseif strcmp(ME.identifier, 'MATLAB:validators:mustBeGreaterThanOrEqual') + msg = 'Value gap must be greater than or equal to %d days.'; + Merr = MException(ME.identifier, ... + msg, ... + days(obj.MinGap)); + throwAsCaller(Merr); + else + throwAsCaller(ME) + end + end + + % Update datepicker components + updateControlLimitsAndValue(obj, val); + + % Update buttons + updateButtonEnable(obj); + + end %function + function val = get.Value(obj) + val = [obj.DatepickerLeft.Value obj.DatepickerRight.Value]; + end %function + + % Display format + function set.DisplayFormat(obj, val) + try + d = uidatepicker('Parent', []); + d.DisplayFormat = val; + obj.DisplayFormat = d.DisplayFormat; + catch ME + throwAsCaller(ME) + end + end %function + + % Limits + function set.Limits(obj, val) + startOfDay = dateshift(val, 'start', 'days'); + limitIndex = days(val - val(1)); + try + mustBeIncreasing(limitIndex) + catch ME + throwAsCaller(ME) + end + obj.Limits = startOfDay; + end %function + + % Gap + function set.MinGap(obj, val) + + % Parse input + if isduration(val) + val = days(val); + end + + % Validate input + maxGap = days(diff(obj.Limits)); + try + mustBeGreaterThanOrEqual(maxGap, val) + catch ME + if strcmp(ME.identifier, 'MATLAB:validators:mustBeGreaterThanOrEqual') + msg = 'Value must be smaller than or equal to %d days.'; + Merr = MException(ME.identifier, ... + msg, ... + maxGap); + throwAsCaller(Merr); + else + throwAsCaller(ME) + end + end + + % Set value + obj.MinGap_I = val; + end + + function val = get.MinGap(obj) + val = days(obj.MinGap_I); + end + + % Datepicker size + function set.DatepickerSize(obj, val) + try + g = uigridlayout("Parent", []); + cleanup = onCleanup(@() delete(g)); + g.ColumnWidth{1} = val; + catch ME + throwAsCaller(ME) + end + obj.DatepickerSize = val; + end %function + + end + + %% Protected methods + methods (Access = protected) + + function setup(obj) + + % Call superclass method + obj.setup@wt.abstract.BaseWidget() + + % Set default size + obj.Position = [20 100 530 40]; + + % Configure grid + obj.Grid.ColumnWidth = {'fit', obj.DatepickerSize, '1x', obj.DatepickerSize, 'fit'}; + obj.Grid.RowHeight = {'1x'}; + obj.Grid.ColumnSpacing = 5; + + % Buttons left + obj.GridButtonLeft = uigridlayout(obj.Grid); + obj.GridButtonLeft.RowHeight = {"1x", "1x"}; + obj.GridButtonLeft.ColumnWidth = "fit"; + obj.GridButtonLeft.ColumnSpacing = 0; + obj.GridButtonLeft.RowSpacing = 2; + obj.GridButtonLeft.Padding(:) = 0; + + obj.ButtonsLeft = [uibutton(obj.GridButtonLeft), uibutton(obj.GridButtonLeft)]; + obj.ButtonsLeft(1).ButtonPushedFcn = @(h,e)obj.onButtonPushed(e); + obj.ButtonsLeft(1).Tag = "leftUp"; + obj.ButtonsLeft(1).Icon = "up_grey_12.png"; + obj.ButtonsLeft(1).Text = ""; + obj.ButtonsLeft(2).ButtonPushedFcn = @(h,e)obj.onButtonPushed(e); + obj.ButtonsLeft(2).Tag = "leftDown"; + obj.ButtonsLeft(2).Icon = "down_grey_12.png"; + obj.ButtonsLeft(2).Text = ""; + + % Date picker left + obj.DatepickerLeft = uidatepicker(obj.Grid); + obj.DatepickerLeft.ValueChangedFcn = @(h,e)obj.onDatepickerChanged(e); + obj.DatepickerLeft.Value = datetime("02-Jan-2020"); + obj.DatepickerLeft.Limits = [obj.Limits(1) datetime("02-Jan-2020")]; + obj.DatepickerLeft.Editable = false; + obj.DatepickerLeft.Tag = "left"; + + % Slider + obj.Slider = uislider(obj.Grid, 'range'); + obj.Slider.ValueChangedFcn = @(h,e)obj.onSliderChanged(e); + obj.Slider.ValueChangingFcn = @(h,e)obj.onSliderChanging(e); + obj.Slider.Limits = [0 3]; + obj.Slider.Value = [1 2]; + + % Date picker right + obj.DatepickerRight = uidatepicker(obj.Grid); + obj.DatepickerRight.ValueChangedFcn = @(h,e)obj.onDatepickerChanged(e); + obj.DatepickerRight.Value = datetime("03-Jan-2020"); + obj.DatepickerRight.Limits = [datetime("03-Jan-2020") obj.Limits(2)]; + obj.DatepickerRight.Editable = false; + obj.DatepickerRight.Tag = "right"; + + % Buttons right + obj.GridButtonRight = uigridlayout(obj.Grid); + obj.GridButtonRight.RowHeight = {"1x", "1x"}; + obj.GridButtonRight.ColumnWidth = "fit"; + obj.GridButtonRight.ColumnSpacing = 0; + obj.GridButtonRight.RowSpacing = 2; + obj.GridButtonRight.Padding(:) = 0; + + obj.ButtonsRight = [uibutton(obj.GridButtonRight), uibutton(obj.GridButtonRight)]; + obj.ButtonsRight(1).ButtonPushedFcn = @(h,e)obj.onButtonPushed(e); + obj.ButtonsRight(1).Tag = "rightUp"; + obj.ButtonsRight(1).Icon = "up_grey_12.png"; + obj.ButtonsRight(1).Text = ""; + obj.ButtonsRight(2).ButtonPushedFcn = @(h,e)obj.onButtonPushed(e); + obj.ButtonsRight(2).Tag = "rightDown"; + obj.ButtonsRight(2).Icon = "down_grey_12.png"; + obj.ButtonsRight(2).Text = ""; + + % Update the internal component lists + obj.BackgroundColorableComponents = [obj.Grid, obj.GridButtonLeft, obj.GridButtonRight]; + obj.FontStyledComponents = [obj.DatepickerLeft, obj.Slider, obj.DatepickerRight]; + obj.EnableableComponents = [obj.DatepickerLeft, obj.Slider, obj.DatepickerRight, obj.ButtonsLeft, obj.ButtonsRight]; + obj.FieldColorableComponents = [obj.DatepickerLeft, obj.DatepickerRight]; + obj.ButtonColorableComponents = [obj.ButtonsLeft, obj.ButtonsRight]; + + % Update the slider ticks + tickLoc = 0:days(obj.Limits(2) - obj.Limits(1)); + dtArray = datetime("01-Jan-2020") + days(tickLoc); + obj.Slider.MajorTicks = tickLoc; + obj.Slider.MinorTicks = tickLoc; + obj.Slider.MajorTickLabels = categorical(dtArray(tickLoc + 1)); + + end %function + + + function update(obj) + % Set the value ranges + + % Set orientation in grid + if obj.Orientation == "vertical" + + % Grid + obj.Grid.ColumnWidth = {'fit', 'fit', '1x', 'fit', 'fit'}; + obj.Grid.RowHeight = {obj.DatepickerSize, '1x'}; + + % Left + obj.GridButtonLeft.Layout.Row = 1; + obj.GridButtonLeft.Layout.Column = 1; + obj.DatepickerLeft.Layout.Row = 1; + obj.DatepickerLeft.Layout.Column = 2; + + % Right + obj.GridButtonRight.Layout.Row = 1; + obj.GridButtonRight.Layout.Column = 5; + obj.DatepickerRight.Layout.Row = 1; + obj.DatepickerRight.Layout.Column = 4; + + % Slider + obj.Slider.Layout.Row = 2; + obj.Slider.Layout.Column = [1 5]; + + else + + % Grid + obj.Grid.ColumnWidth = {'fit', obj.DatepickerSize, '1x', obj.DatepickerSize, 'fit'}; + obj.Grid.RowHeight = {'1x'}; + + % Left + obj.GridButtonLeft.Layout.Row = 1; + obj.GridButtonLeft.Layout.Column = 1; + obj.DatepickerLeft.Layout.Row = 1; + obj.DatepickerLeft.Layout.Column = 2; + + % Right + obj.GridButtonRight.Layout.Row = 1; + obj.GridButtonRight.Layout.Column = 5; + obj.DatepickerRight.Layout.Row = 1; + obj.DatepickerRight.Layout.Column = 4; + + % Slider + obj.Slider.Layout.Row = 1; + obj.Slider.Layout.Column = 3; + end + + % Make sure values are a datetime number + if isnat(obj.DatepickerLeft.Value) + obj.DatepickerLeft.Value = obj.DatepickerLeft.Limits(1); + end + if isnat(obj.DatepickerRight.Value) + obj.DatepickerRight.Value = obj.DatepickerRight.Limits(2); + end + + % Gap is bounded by limits + obj.MinGap = boundValue(obj, days(obj.MinGap), 0, days(diff(obj.Limits))); + + % Update datepicker limits + updateControlLimitsAndValue(obj); + + % Update datepicker display formats + obj.DatepickerLeft.DisplayFormat = obj.DisplayFormat; + obj.DatepickerRight.DisplayFormat = obj.DisplayFormat; + + % Update ticks + updateSliderTicks(obj); + + % Update buttons + updateButtonEnable(obj); + + end %function + + + function onButtonPushed(obj, evt) + % Triggered on button pushed + + % What button was pushed? + oldDate = obj.Value; + newDate = obj.Value; + + switch evt.Source.Tag + case "leftUp" + newDate(1) = min(newDate(1) + obj.Step, newDate(2) - days(1)); + case "leftDown" + newDate(1) = max(newDate(1) - obj.Step, obj.Limits(1)); + case "rightUp" + newDate(2) = min(newDate(2) + obj.Step, obj.Limits(2)); + case "rightDown" + newDate(2) = max(newDate(2) - obj.Step, newDate(1) + days(1)); + end + + % Update the value + obj.Value = newDate; + + % Trigger event + evtOut = wt.eventdata.PropertyChangedData('Value', newDate, oldDate); + notify(obj, "ValueChanged", evtOut); + + end %function + + function onSliderChanged(obj,evt) + % Triggered on slider moved + + % What changed the date? + prevDate = obj.Limits(1) + round(evt.PreviousValue); + newDate = obj.Limits(1) + round(evt.Value); + + % Update datepicker limits + updateControlLimitsAndValue(obj, newDate); + + % Update button enable status + updateButtonEnable(obj) + + % Event data + evtOut = wt.eventdata.PropertyChangedData('Value', newDate, prevDate); + + % Trigger event "ValueChanged" + notify(obj, "ValueChanged", evtOut); + + end %function + + function onSliderChanging(obj,evt) + % Triggered on slider moving + + % What changed the date? + changedDate = obj.Limits(1) + round(evt.Value); + + % Update datepicker values + obj.DatepickerLeft.Value = changedDate(1); + obj.DatepickerRight.Value = changedDate(2); + + % Event data + evtOut = wt.eventdata.PropertyChangedData('Value', changedDate); + + % Trigger event "ValueChanging" + notify(obj, "ValueChanging", evtOut); + + end %function + + + function onDatepickerChanged(obj,evt) + % Triggered on button pushed + + % What changed? + prevValue = obj.Value; + newValue = obj.Value; + + switch evt.Source.Tag + case "left" + prevValue(1) = evt.PreviousValue; + case "right" + prevValue(2) = evt.PreviousValue; + end + + % Prepare event data + evtOut = wt.eventdata.PropertyChangedData('Value', newValue, prevValue); + + % Update the components with the new value + updateControlLimitsAndValue(obj, newValue) + + % Update the buttons + updateButtonEnable(obj) + + % Trigger event + notify(obj, "ValueChanged", evtOut); + + end %function + + + function updateButtonEnable(obj) + + % What are the buttons? + buttonsLeft = obj.ButtonsLeft; + buttonsRight = obj.ButtonsRight; + + if ~(numel(buttonsLeft) == 2 && numel(buttonsRight) == 2) + return + end + + % Can interval increase to lower bound? + if obj.Value(1) > obj.Limits(1) + buttonsLeft(2).Enable = true; + else + buttonsLeft(2).Enable = false; + end + + % Can interval further increase to upper bound? + if obj.Value(2) < obj.Limits(2) + buttonsRight(1).Enable = true; + else + buttonsRight(1).Enable = false; + end + + % Can interval further decrease to center? + if obj.Value(1) == obj.Value(2) - obj.MinGap + buttonsLeft(1).Enable = false; + buttonsRight(2).Enable = false; + else + buttonsLeft(1).Enable = true; + buttonsRight(2).Enable = true; + end + + end %function + + + function updateSliderTicks(obj) + % Update the limits in date-picker and slider + + % Get ticks + [majorTicks, minorTicks] = getSliderTicks( ... + obj, ... + obj.Orientation, ... + "TickLength", strlength(obj.DisplayFormat)); + + % Set slider limits and ticks + obj.Slider.MajorTicks = majorTicks; + obj.Slider.MinorTicks = minorTicks; + + minLimit = obj.Limits(1); + minLimit.Format = obj.DisplayFormat; + obj.Slider.MajorTickLabels = categorical(minLimit + days(majorTicks - 1)); + end %function + + + function updateControlLimitsAndValue(obj, val) + % Update values in datepicker components + + % Current control value + currentVal = [obj.DatepickerLeft.Value obj.DatepickerRight.Value]; + + % Value provided? + if nargin < 2 + val = currentVal; + end + + % Bound current value by lower limit and upper limit. + if eq(currentVal(1), val(1)) + val(1) = boundValue(obj, val(1), obj.Limits(1), obj.Limits(2) - obj.MinGap); + val(2) = boundValue(obj, val(2), val(1) + obj.MinGap, obj.Limits(2)); + else + val(2) = boundValue(obj, val(2), obj.Limits(1) + obj.MinGap, obj.Limits(2)); + val(1) = boundValue(obj, val(1), obj.Limits(1), val(2) - obj.MinGap); + end + + % Update the limits and value + obj.DatepickerLeft.Limits = [obj.Limits(1) val(2) - obj.MinGap]; + obj.DatepickerLeft.Value = val(1); + + obj.DatepickerRight.Limits = [val(1) + obj.MinGap obj.Limits(2)]; + obj.DatepickerRight.Value = val(2); + + % Update the limits and value in the slider + obj.Slider.Limits = days([0, diff(obj.Limits)]) + 1; + obj.Slider.Value = days(val - obj.Limits(1)) + 1; + + end %function + + end %methods + + %% Private helper functions + methods (Access = private) + + function boundedVal = boundValue(~, val, lowerBound, upperBound) + boundedVal = min( ... + max(val, lowerBound), ... + upperBound); + end + + end + +end % classdef + +function mustBeIncreasing(A) +%MUSTBEINCREASING Validate that value array is increasing +% MUSTBEINCREASING(A) throws an error if array A is not increating. +% MATLAB calls sort to determine if A is increasing. +% +% Class support: +% All numeric classes +% MATLAB classes that define these methods: +% sort, isnumeric +% +% See also: MUSTBENUMERIC, MUSTBEREAL, MUSTBEVECTOR. + +try + mustBeNumeric(A) + mustBeReal(A) + mustBeVector(A) +catch ME + throwAsCaller(ME) +end + +[~, idx] = sort(A, 'ascend'); +if ~all(eq(idx, 1:numel(idx))) + ME = MException( ... + 'DateRageSlider:mustBeIncreasing', ... + 'Value must be increasing.'); + throwAsCaller(ME); +end + +end \ No newline at end of file diff --git a/widgets/+wt/DateSlider.m b/widgets/+wt/DateSlider.m new file mode 100644 index 0000000..50e9651 --- /dev/null +++ b/widgets/+wt/DateSlider.m @@ -0,0 +1,369 @@ +classdef DateSlider < wt.abstract.BaseWidget & ... + wt.mixin.TickableDateSlider & ... + wt.mixin.Enableable & ... + wt.mixin.FieldColorable & ... + wt.mixin.FontStyled & ... + wt.mixin.ButtonColorable + % A slider and date-picker combination + + %% Events + events (HasCallbackProperty, NotifyAccess = protected) + + % Triggered on value changed, has companion callback + ValueChanged + + % Triggered on value changing during slider motion, has companion callback + ValueChanging + + end %events + + %% Public dependent properties + properties (AbortSet, Dependent) + % These properties do not trigger the update method + + % Value of the slider and date-time selector + Value (1,1) datetime + + % Index of selected item + ValueIndex (1,1) double {mustBeInteger} + + % Date format + DisplayFormat (1,1) string + + end %properties + + properties (AbortSet, UsedInUpdate = false) + % These properties do not trigger the update method + + % Define step size for buttons + Step (1,1) calendarDuration = calendarDuration(0,0,1) + + end + + %% Public properties + properties (AbortSet) + % These properties trigger the update method + + % Limits of the slider and spinner + Limits (1,2) datetime = datetime("01-Jan-2020") + days([0 2]); + + % Orientation of the spinner and slider + Orientation (1,1) wt.enum.HorizontalVerticalState = wt.enum.HorizontalVerticalState.horizontal + + % Size of date-picker (width for horizontal, height for vertical + DatepickerSize = 120 + + end %properties + + + + %% Internal Properties + properties (Transient, NonCopyable, Hidden, SetAccess = protected) + + % Slider + Slider = matlab.ui.control.Slider.empty + + % Date picker + Datepicker matlab.ui.control.DatePicker + + % Selection buttons + GridButtons matlab.ui.container.GridLayout + Buttons (1,:) matlab.ui.control.Button + + end + + %% Accessors + methods + + % ValueIndex + function set.ValueIndex(obj, val) + maxRange = days(obj.Limits(2) - obj.Limits(1)) + 1; + try + mustBeInRange(val, 1, maxRange) + catch ME + throwAsCaller(ME) + end + obj.Value = obj.Limits(1) + days(val - 1); + end %function + function val = get.ValueIndex(obj) + val = obj.Slider.Value; + end %function + + % Value + function set.Value(obj, val) + try + obj.Datepicker.Value = val; + catch ME + throwAsCaller(ME); + end + obj.Slider.Value = days(val - obj.Limits(1)) + 1; + obj.Datepicker.Value = val; + end %function + function val = get.Value(obj) + val = obj.Datepicker.Value; + end %function + + % Display format + function set.DisplayFormat(obj, val) + try + obj.Datepicker.DisplayFormat = val; + catch ME + throwAsCaller(ME) + end + end %function + function val = get.DisplayFormat(obj) + val = obj.Datepicker.DisplayFormat; + end %function + + % Limits + function set.Limits(obj, val) + startOfDay = dateshift(val, 'start', 'days'); + try + numVal = days(startOfDay - startOfDay(1)); + mustBeGreaterThan(numVal(2), numVal(1)) + catch ME + if strcmp(ME.identifier, 'MATLAB:validators:mustBeGreaterThanOrEqual') + msg = 'Error setting property ''Limits'' of class ''%s''. Value must be increasing.'; + Merr = MException(ME.identifier, msg, classname2link(class(obj))); + throwAsCaller(Merr); + else + rethrow(ME) + end + end + obj.Limits = startOfDay; + end %function + function val = get.Limits(obj) + val = obj.Limits; + val.Format = obj.Datepicker.DisplayFormat; + end %function + + % Datepicker size + function set.DatepickerSize(obj, val) + try + g = uigridlayout("Parent", []); + cleanup = onCleanup(@() delete(g)); + g.ColumnWidth{1} = val; + catch ME + throwAsCaller(ME) + end + obj.DatepickerSize = val; + end %function + + end + + %% Protected methods + methods (Access = protected) + + function setup(obj) + + % Call superclass method + obj.setup@wt.abstract.BaseWidget() + + % Set default size + obj.Position(3:4) = [400 40]; + + % Configure grid + obj.Grid.ColumnWidth = {'1x', obj.DatepickerSize, 'fit'}; + obj.Grid.ColumnSpacing = 5; + + % Slider + obj.Slider = uislider(obj.Grid); + obj.Slider.ValueChangedFcn = @(h,e)obj.onSliderChanged(e); + obj.Slider.ValueChangingFcn = @(h,e)obj.onSliderChanged(e); + obj.Slider.Limits = [1 3]; + obj.Slider.Value = 2; + + % Date picker + obj.Datepicker = uidatepicker(obj.Grid); + obj.Datepicker.ValueChangedFcn = @(h,e)obj.onDatepickerChanged(e); + obj.Datepicker.Value = obj.Limits(1) + days(1); + obj.Datepicker.Limits = obj.Limits; + obj.Datepicker.Editable = false; + + % Button grid + obj.GridButtons = uigridlayout(obj.Grid); + obj.GridButtons.RowHeight = {"1x", "1x"}; + obj.GridButtons.ColumnWidth = "fit"; + obj.GridButtons.ColumnSpacing = 0; + obj.GridButtons.RowSpacing = 2; + obj.GridButtons.Padding(:) = 0; + + obj.Buttons = [uibutton(obj.GridButtons) uibutton(obj.GridButtons)]; + obj.Buttons(1).ButtonPushedFcn = @(h,e)obj.onButtonPushed(e); + obj.Buttons(1).Tag = "up"; + obj.Buttons(1).Icon = "up_grey_12.png"; + obj.Buttons(1).Text = ""; + obj.Buttons(2).ButtonPushedFcn = @(h,e)obj.onButtonPushed(e); + obj.Buttons(2).Tag = "down"; + obj.Buttons(2).Icon = "down_grey_12.png"; + obj.Buttons(2).Text = ""; + + % Update the internal component lists + obj.BackgroundColorableComponents = obj.Grid; + obj.FontStyledComponents = [obj.Datepicker, obj.Slider]; + obj.EnableableComponents = [obj.Datepicker, obj.Slider, obj.Buttons]; + obj.FieldColorableComponents = [obj.Datepicker]; + obj.ButtonColorableComponents = [obj.Buttons]; + + end %function + + + function update(obj) + + % Set the value ranges + + % Set orientation in grid + if obj.Orientation == "vertical" + obj.Grid.ColumnWidth = {'1x', 'fit'}; + obj.Grid.RowHeight = {'1x', obj.DatepickerSize}; + obj.Datepicker.Layout.Row = 2; + obj.Datepicker.Layout.Column = 1; + obj.GridButtons.Layout.Row = 2; + obj.GridButtons.Layout.Column = 2; + obj.Slider.Orientation = "vertical"; + else + obj.Grid.ColumnWidth = {'1x', obj.DatepickerSize, 'fit'}; + obj.Grid.RowHeight = {'1x'}; + obj.Datepicker.Layout.Row = 1; + obj.Datepicker.Layout.Column = 2; + obj.GridButtons.Layout.Row = 1; + obj.GridButtons.Layout.Column = 3; + obj.Slider.Orientation = "horizontal"; + end + + % Set component range + obj.Slider.Limits = [0 days(obj.Limits(2) - obj.Limits(1))] + 1; + obj.Datepicker.Limits = obj.Limits; + + % Update ticks + updateSliderTicks(obj) + + % Set value + if isnat(obj.Datepicker.Value) + obj.Datepicker.Value = obj.Limits(1); + end + obj.Slider.Value = days(obj.Datepicker.Value - obj.Limits(1)) + 1; + + % Update the buttons + updateButtonEnable(obj) + + end %function + + + function onButtonPushed(obj, evt) + % Triggered on button pushed + + % What button was pushed? + oldDate = obj.Value; + newDate = obj.Value; + switch evt.Source.Tag + case "up" + newDate = min(newDate + obj.Step, obj.Limits(2)); + case "down" + newDate = max(newDate - obj.Step, obj.Limits(1)); + + end + + % Update the state + obj.Datepicker.Value = newDate; + obj.Slider.Value = days(newDate - obj.Limits(1)) + 1; + + % Update button enable status + updateButtonEnable(obj) + + % Trigger event + evtOut = wt.eventdata.PropertyChangedData('Value', newDate, oldDate); + notify(obj, "ValueChanged", evtOut); + + end %function + + function onSliderChanged(obj,evt) + % Triggered on slider moved + + % What changed? + newValue = evt.Value; + oldValue = obj.Datepicker.Value; + + % Round value to whole days + newValue = round(newValue); + newValue = min(max(newValue,1), days(obj.Limits(2) - obj.Limits(1)) + 1); + + % Prepare event data + newDate = obj.Limits(1) + days(newValue - 1); + evtOut = wt.eventdata.PropertyChangedData('Value', newDate, oldValue); + + % Update the state + obj.Datepicker.Value = newDate; + + % Skip for event ValueChanging for performance + if evt.EventName == "ValueChanged" + obj.Slider.Value = newValue; + + % Update button enable status + updateButtonEnable(obj) + end + + % Trigger event ("ValueChanged" or "ValueChanging") + notify(obj, evt.EventName, evtOut); + + end %function + + + function onDatepickerChanged(obj,e) + % Triggered on button pushed + + % What changed? + newValue = e.Value; + + % Prepare event data + evtOut = wt.eventdata.PropertyChangedData('Value', newValue, e.PreviousValue); + + % Update the state + obj.Slider.Value = days(newValue - obj.Limits(1)) + 1; + + % Update button enable status + updateButtonEnable(obj) + + % Trigger event + notify(obj, "ValueChanged", evtOut); + + end %function + + + function updateButtonEnable(obj) + + % What are the buttons? + + % Limits of interval reached? + if obj.Value <= obj.Limits(1) + obj.Buttons(1).Enable = true; + obj.Buttons(2).Enable = false; + elseif obj.Value >= obj.Limits(2) + obj.Buttons(1).Enable = false; + obj.Buttons(2).Enable = true; + else + obj.Buttons(1).Enable = true; + obj.Buttons(2).Enable = true; + end %if + + end %function + + function updateSliderTicks(obj) + % Update the limits in date-picker and slider + + % Get ticks + [majorTicks, minorTicks] = getSliderTicks( ... + obj, ... + obj.Orientation, ... + "TickLength", strlength(obj.DisplayFormat)); + + % Set slider limits and ticks + obj.Slider.MajorTicks = majorTicks; + obj.Slider.MinorTicks = minorTicks; + obj.Slider.MajorTickLabels = categorical(obj.Limits(1) + days(majorTicks - 1)); + + end %function + + end %methods + +end % classdef \ No newline at end of file diff --git a/widgets/doc/WidgetsList.mlx b/widgets/doc/WidgetsList.mlx index 8721f10..d377c68 100644 Binary files a/widgets/doc/WidgetsList.mlx and b/widgets/doc/WidgetsList.mlx differ diff --git a/widgets/icons/down_grey_12.png b/widgets/icons/down_grey_12.png new file mode 100644 index 0000000..68f2ad1 Binary files /dev/null and b/widgets/icons/down_grey_12.png differ diff --git a/widgets/icons/up_grey_12.png b/widgets/icons/up_grey_12.png new file mode 100644 index 0000000..d6feb7d Binary files /dev/null and b/widgets/icons/up_grey_12.png differ