diff --git a/Nasal/canvas/gui.nas b/Nasal/canvas/gui.nas index 473a23811..de375e4c2 100644 --- a/Nasal/canvas/gui.nas +++ b/Nasal/canvas/gui.nas @@ -39,6 +39,7 @@ loadGUIFile("styles/DefaultStyle.nas"); # widgets loadWidget("Button"); loadWidget("CheckBox"); +loadWidget("Dial"); loadWidget("Label"); loadWidget("LineEdit"); loadWidget("List"); diff --git a/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas b/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas index f763764c1..3df91124d 100644 --- a/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas +++ b/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas @@ -221,6 +221,48 @@ var WidgetsFactoryDialog = { }) .setValue(42); m.numericControlsTab.addItem(m.slider); + + m.dialBox = HBoxLayout.new(); + m.dialBox.setContentsMargin(10); + m.numericControlsTab.addItem(m.dialBox); + + m.dial = gui.widgets.Dial.new(m.tabsContent, style, { + "min-value": 5, + "max-value": 50, + "step-size": 0.5, + "page-size": 5, + "tick-step": 2, + "show-value": 0, + "show-ticks": 0, + "value": 14, + "value-format": "%.1f", + "wrap": 0, + }); + m.dialBox.addItem(m.dial); + + m.dialOptionsBox =VBoxLayout.new(); + m.dialBox.addItem(m.dialOptionsBox); + m.dialShowValueCheckBox = gui.widgets.CheckBox.new(m.tabsContent, canvas.style, {}) + .setText("Show value") + .listen("toggled", func(e) { + m.dial.setShowValue(e.detail.checked); + }); + m.dialShowValueCheckBox.setAlignment(canvas.AlignTop); + m.dialOptionsBox.addItem(m.dialShowValueCheckBox); + m.dialShowTicksCheckBox = gui.widgets.CheckBox.new(m.tabsContent, canvas.style, {}) + .setText("Show ticks") + .listen("toggled", func(e) { + m.dial.setShowTicks(e.detail.checked); + }); + m.dialShowTicksCheckBox.setAlignment(canvas.AlignTop); + m.dialOptionsBox.addItem(m.dialShowTicksCheckBox); + m.dialWrapCheckBox = gui.widgets.CheckBox.new(m.tabsContent, canvas.style, {}) + .setText("Wrap value") + .listen("toggled", func(e) { + m.dial.setWrap(e.detail.checked); + }); + m.dialWrapCheckBox.setAlignment(canvas.AlignTop); + m.dialOptionsBox.addItem(m.dialWrapCheckBox); return m; diff --git a/Nasal/canvas/gui/styles/DefaultStyle.nas b/Nasal/canvas/gui/styles/DefaultStyle.nas index cef100c24..b0957a344 100644 --- a/Nasal/canvas/gui/styles/DefaultStyle.nas +++ b/Nasal/canvas/gui/styles/DefaultStyle.nas @@ -1138,6 +1138,205 @@ DefaultStyle.widgets.slider = { } }; +DefaultStyle.widgets.dial = { + new: func(parent, cfg) { + me._root = parent.createChild("group", "dial"); + me._knob = me._root.createChild("path", "dial-knob"); + me._handle = me._root.createChild("image", "dial-handle"); + me._handleTranslateTransform = me._handle.createTransform(); + me._value = me._root.createChild("text", "dial-value") + .set("font", "LiberationFonts/LiberationSans-Regular.ttf") + .set("character-size", me._style.getSize("dial-value-font-size", me._style.getSize("base-font-size", 14))) + .set("alignment", "center-center") + .setText(0); + me._ticks = me._root.createChild("path", "dial-ticks"); + + me._maxValueWidth = 20; + }, + + _updateLayoutSizes: func(model) { + var handleSize = me._handle.imageSize()[1]; + var borderHandleMargin = math.clamp( + math.min(model._size[0], model._size[1]) * 0.0625, + 2, + me._style.getSize("dial-border-handle-margin", 8), + ); + var valueSize = [0, 0]; + var valueHandleMargin = me._style.getSize("dial-value-handle-margin", 5); + if (model._showValue) { + valueSize = [ + me._maxValueWidth + valueHandleMargin * 2, + me._style.getSize("dial-value-font-size", me._style.getSize("base-font-size", 14)) + valueHandleMargin * 2 + ]; + } else { + valueSize = me._style.getSize("dial-center-handle-margin", 2) * 2; + valueSize = [valueSize, valueSize]; + } + var minW = borderHandleMargin * 2 + valueSize[0] + handleSize * 2; + var minH = borderHandleMargin * 2 + valueSize[1] + handleSize * 2; + var length = math.max(minW, minH); + if (model._showTicks) { + length += me._style.getSize("dial-knob-ticks-margin", 2) + me._style.getSize("dial-ticks-length", 10); + } + model.setLayoutMinimumSize([length, length]); + }, + + setSize: func(model, length) { + var knobTicksMargin = me._style.getSize("dial-knob-ticks-margin", 2); + var ticksLength = me._style.getSize("dial-ticks-length", 10); + var knobRadius = (length - me._style.getSize("dial-knob-border-width", 1)) / 2; + var halfLength = length / 2; + if (model._showTicks) { + knobRadius -= knobTicksMargin + ticksLength; + } + me._knob.reset() + .circle(knobRadius, halfLength, halfLength); + me._value.setTranslation( + halfLength, + halfLength + ); + var handleOffset = [ + halfLength - me._handle.imageSize()[0] / 2, + math.clamp(knobRadius * 0.125, 2, me._style.getSize("dial-border-handle-margin", 8)) + ]; + if (model._showTicks) { + handleOffset[1] += knobTicksMargin + ticksLength; + } + me._handleTranslateTransform.setTranslation(handleOffset[0], handleOffset[1]); + me._handle.setCenter( + me._handle.imageSize()[0] / 2, + halfLength - handleOffset[1] + ); + var degreesRange = 360; + var nowrapMargin = me._style.getSize("dial-nowrap-margin-deg", 30); + var offset = 0; + if (!model._wraps) { + degreesRange -= nowrapMargin; + offset = nowrapMargin / 2; + } + me._handle.setRotation((model._normValue() * degreesRange + offset) * D2R); + me._drawTicks(model); + }, + + _drawTicks: func(model) { + var length = math.min(model._size[0], model._size[1]); + var halfLength = length / 2; + var knobTicksMargin = me._style.getSize("dial-knob-ticks-margin", 2); + var ticksLength = me._style.getSize("dial-ticks-length", 10); + var knobRadius = (length - me._style.getSize("dial-knob-border-width", 1)) / 2 - knobTicksMargin - ticksLength; + + var degreesRange = 360; + var nowrapMargin = me._style.getSize("dial-nowrap-margin-deg", 30); + var offset = 0; + if (!model._wraps) { + degreesRange -= nowrapMargin; + offset = nowrapMargin / 2; + } + var range = model._maxValue - model._minValue; + var center = [halfLength, halfLength]; + var degreesPerTickStep = (degreesRange / range) * model._tickStep; + var lengthBegin = knobRadius + knobTicksMargin; + var lengthEnd = lengthBegin + ticksLength; + me._ticks.reset(); + + for (var i = 0; i < degreesRange / degreesPerTickStep; i += 1) { + var radians = (i * degreesPerTickStep + offset) * D2R; + var sin = math.sin(radians); + var cos = -math.cos(radians); + var start = [center[0] + sin * lengthBegin, center[1] + cos * lengthBegin]; + var tick = [center[0] + sin * lengthEnd, center[1] + cos * lengthEnd]; + me._ticks.moveTo(start[0], start[1]).lineTo(tick[0], tick[1]); + } + }, + + _updateMaxValueWidth: func(model) { + if (model._valueFormat != nil) { + me._value.setText(sprintf(model._valueFormat, model._minValue)); + var min = me._value.maxWidth(); + me._value.setText(sprintf(model._valueFormat, model._maxValue)); + var max = me._value.maxWidth(); + me._value.setText(sprintf(model._valueFormat, model._minValue + model._stepSize)); + var minStep = me._value.maxWidth(); + me._value.setText(sprintf(model._valueFormat, model._value)); + } else { + me._value.setText(model._minValue); + var min = me._value.maxWidth(); + me._value.setText(model._maxValue); + var max = me._value.maxWidth(); + me._value.setText(model._minValue + model._stepSize); + var minStep = me._value.maxWidth(); + me._value.setText(model._value); + } + me._maxValueWidth = math.max(min, max, minStep); + }, + + setValue: func(model, value) { + var degreesRange = 360; + var nowrapMargin = me._style.getSize("dial-nowrap-margin-deg", 30); + var offset = 0; + if (!model._wraps) { + degreesRange -= nowrapMargin; + offset = nowrapMargin / 2; + } + me._handle.setRotation((model._normValue() * degreesRange + offset) * D2R); + if (model._valueFormat != nil) { + me._value.setText(sprintf(model._valueFormat, value)); + } else { + me._value.setText(value); + } + }, + + update: func(model) { + var color_name = model._windowFocus() ? "fg_color" : "backdrop_fg_color"; + me._value.set("fill", me._style.getColor(color_name)); + + color_name = "dial_knob_bg"; + if (!model._enabled) { + color_name ~= "_disabled"; + } elsif (model._focused and model._windowFocus()) { + color_name ~= "_focused"; + } elsif (!model._windowFocus()) { + color_name ~= "_backdrop"; + } + if (model._hover and model._enabled and model._windowFocus()) { + color_name ~= "_hovered"; + } + me._knob.set("fill", me._style.getColor(color_name)); + + color_name = "dial_knob_border"; + if (!model._enabled) { + color_name ~= "_disabled"; + } elsif (model._focused and model._windowFocus()) { + color_name ~= "_focused"; + } + if (model._hover and model._enabled and model._windowFocus()) { + color_name ~= "_hovered"; + } + me._knob.set("stroke", me._style.getColor(color_name)); + + var file = me._style._dir_widgets ~ "/dial-handle"; + if (!model._enabled) { + file ~= "-disabled"; + } elsif (model._handleDown) { + file ~= "-down"; + } + me._handle.set("src", file ~ ".png"); + + me._ticks.set("stroke", me._style.getColor("dial_ticks")); + + if (model._showValue) { + me._value.show(); + } else { + me._value.hide(); + } + if (model._showTicks) { + me._ticks.show(); + } else { + me._ticks.hide(); + } + } +}; + DefaultStyle.widgets["menu-item"] = { new: func(parent, cfg) { me._root = parent.createChild("group", "menu-item"); diff --git a/Nasal/canvas/gui/widgets/Dial.nas b/Nasal/canvas/gui/widgets/Dial.nas index 566277cfc..81b35f73b 100644 --- a/Nasal/canvas/gui/widgets/Dial.nas +++ b/Nasal/canvas/gui/widgets/Dial.nas @@ -1,61 +1,195 @@ +# Dial.nas : show a user-rotable dial knob +# with optional tick marks and value display # SPDX-FileCopyrightText: (C) 2022 James Turner # SPDX-License-Identifier: GPL-2.0-or-later gui.widgets.Dial = { - new: func(parent, style, cfg) - { - var cfg = Config.new(cfg); + new: func(parent, style = nil, cfg = nil) { + style = style or canvas.style; var m = gui.Widget.new(gui.widgets.Dial); + m._cfg = Config.new(cfg or {}); m._focus_policy = m.StrongFocus; - m._down = 0; - m._minValue = 0; - m._maxValue = cfg.get("max-value", 100); - m._value = 50; + m._minValue = m._cfg.get("min-value", 0); + m._maxValue = m._cfg.get("max-value", 100); + m._value = m._mouseValue = m._cfg.get("value", 50); + m._stepSize = m._cfg.get("step-size", 1); + m._pageSize = m._cfg.get("page-size", 10); + m._tickStep = m._cfg.get("tick-step", 10); + m._showTicks = m._cfg.get("show-ticks", 0); + m._showValue = m._cfg.get("show-value", 1); + m._valueFormat = m._cfg.get("value-format", nil); + m._wraps = m._cfg.get("wrap", 0); - m._wraps = cfg.get("wrap", 0); - m._pageStep = cfg.get("page-step", 0); - m._numTicks = cfg.get("tick-count", 0); - m._tickStyle = cfg.get("ticks-style", 0); + m._handleDown = 0; + m._lastMouseAngle = 0; + m._dragging = 0; - # todo : optional value display in the center - - if( style != nil ) { - m._setView( style.createWidget(parent, cfg.get("type", "slider"), cfg) ); - m._view.updateRanges(m._minValue, m._maxValue, m._numTicks); - } + m._setView(style.createWidget(parent, m._cfg.get("type", "dial"), m._cfg)); + m.setValueFormat(m._valueFormat); + m.setValue(m._value); + m.setMinValue(m._minValue); + m.setMaxValue(m._maxValue); + m.setShowTicks(m._showTicks); + m.setShowValue(m._showValue); + m._onStateChange(); return m; }, - setValue: func(val) - { - if( me._view != nil ) { - me._view.setNormValue(me._normValue()); + setShowTicks: func(showTicks) { + me._showTicks = showTicks; + me._view._updateLayoutSizes(me); + me._onStateChange(); + }, + + setShowValue: func(showValue) { + me._showValue = showValue; + me._view._updateLayoutSizes(me); + me._onStateChange(); + }, + + setWrap: func(wrap) { + me._wraps = wrap; + me._view.setValue(me, me._value); + me._view._drawTicks(me); + }, + + setSize: func(w, h) { + var size = math.min(w, h); + me._size[0] = size; + me._size[1] = size; + + me._view.setSize(me, size, size); + return me; + }, + + setValueFormat: func(format) { + me._valueFormat = format; + me._view._updateMaxValueWidth(me); + me._view._updateLayoutSizes(me); + me.setValue(me._value); + }, + + setValue: func(value) { + value = math.clamp(value, me._minValue, me._maxValue); + me._view.setValue(me, value); + if (me._value != value) { + me._value = value; + me._trigger("value-changed", {"value": value}); } return me; }, - + setMinValue: func(minValue) { + me._minValue = minValue; + me._view._updateMaxValueWidth(me); + me._view._updateLayoutSizes(me); + me._view._drawTicks(me); + }, + + setMaxValue: func(maxValue) { + me._maxValue = maxValue; + me._view._updateMaxValueWidth(me); + me._view._updateLayoutSizes(me); + me._view._drawTicks(me); + }, # protected: - _setView: func(view) - { + _setView: func(view) { call(gui.Widget._setView, [view], me); - # var el = view._root; - # el.addEventListener("mousedown", func if( me._enabled ) me.setDown(1)); - # el.addEventListener("mouseup", func if( me._enabled ) me.setDown(0)); - # el.addEventListener("click", func if( me._enabled ) me.toggle()); + var el = view._root; + + el.addEventListener("drag", func(e) { + me._dragDial(e); + e.stopPropagation(); + }); + el.addEventListener("mousedown", func(e) { + me._handleDown = 1; + me._dragDial(e); + me._onStateChange(); + }); + el.addEventListener("mouseup", func(e) { + me._handleDown = 0; + me._dragging = 0; + me._onStateChange(); + }); + el.addEventListener("mouseleave", func(e) { + me._handleDown = 0; + me._dragging = 0; + me._onStateChange(); + }); + el.addEventListener("wheel", func(e) { + if (!me._enabled) { + return; + } - # el.addEventListener("mouseleave",func me.setDown(0)); - # el.addEventListener("drag", func(e) e.stopPropagation()); + me.setValue(me._value + e.deltaY * me._stepSize); + e.stopPropagation(); + }); + el.addEventListener("keydown", func(e) { + var value = me._value; + if (contains([ + keyboard.FunctionKeys.Left, keyboard.FunctionKeys.KP_Left, + keyboard.FunctionKeys.Down, keyboard.FunctionKeys.KP_Down, + keyboard.PrintableKeys.Minus, keyboard.FunctionKeys.KP_Subtract, + ], e.keyCode)) { + value -= me._stepSize; + } elsif (contains([ + keyboard.FunctionKeys.Right, keyboard.FunctionKeys.KP_Right, + keyboard.FunctionKeys.Up, keyboard.FunctionKeys.KP_Up, + keyboard.PrintableKeys.Plus, keyboard.FunctionKeys.KP_Add, + ], e.keyCode)) { + value += me._stepSize; + } elsif (contains([keyboard.FunctionKeys.Page_Down, keyboard.FunctionKeys.KP_Page_Down], e.keyCode)) { + value -= me._pageSize; + } elsif (contains([keyboard.FunctionKeys.Page_Up, keyboard.FunctionKeys.KP_Page_Up], e.keyCode)) { + value += me._pageSize; + } elsif (contains([keyboard.FunctionKeys.Home, keyboard.FunctionKeys.KP_Home], e.keyCode)) { + value = me._minValue; + } elsif (contains([keyboard.FunctionKeys.End, keyboard.FunctionKeys.KP_End], e.keyCode)) { + value = me._maxValue; + } + me.setValue(value); + }); + }, + + _dragDial: func(e) { + if (!me._enabled) { + return; + } + var vr = me._view._root; + + var localPos = vr.canvasToLocal([e.clientX - me._size[0] / 2, e.clientY - me._size[1] / 2]); + var mouseAngle = math.atan2(localPos[0], -localPos[1]) * R2D + 180; + var deltaAngle = math.periodic(-180, 180, mouseAngle - me._lastMouseAngle); + me._lastMouseAngle = mouseAngle; + if (!me._dragging) { + me._dragging = 1; + return; + } + + var value = me._mouseValue + (me._maxValue - me._minValue) * (deltaAngle / 360); + if (!me._wraps) { + if (value > me._maxValue) { + value = me._maxValue; + } elsif (value < me._minValue) { + value = me._minValue; + } + } else { + value = math.periodic(me._minValue, me._maxValue, value); + } + me._mouseValue = value; + if (me._stepSize != 0) { + value = math.round(value / me._stepSize) * me._stepSize; + } + me.setValue(value); }, # return value as its normalised equivalent - _normValue: func - { + _normValue: func { var range = me._maxValue - me._minValue; var v = math.clamp(me._value, me._minValue, me._maxValue) - me._minValue; return v / range; - } + }, }; diff --git a/gui/styles/AmbianceClassic/style.xml b/gui/styles/AmbianceClassic/style.xml index 1dc6d83de..8548d0760 100644 --- a/gui/styles/AmbianceClassic/style.xml +++ b/gui/styles/AmbianceClassic/style.xml @@ -317,6 +317,78 @@ 0.5 0.5 + + + 0.949 + 0.945 + 0.941 + + + + 0.934 + 0.930 + 0.926 + + + + 0.966 + 0.962 + 0.958 + + + + 0.966 + 0.962 + 0.988 + + + + 0.986 + 0.982 + 0.998 + + + + 0.89 + 0.89 + 0.89 + + + + 0.6 + 0.6 + 0.6 + + + + 0.65 + 0.65 + 0.65 + + + + 0.83 + 0.54 + 0.43 + + + + 0.79 + 0.41 + 0.25 + + + + 0.71 + 0.71 + 0.71 + + + + 0.5 + 0.5 + 0.5 + diff --git a/gui/styles/AmbianceClassic/widgets/dial-handle-disabled.png b/gui/styles/AmbianceClassic/widgets/dial-handle-disabled.png new file mode 100644 index 000000000..7cacdd072 Binary files /dev/null and b/gui/styles/AmbianceClassic/widgets/dial-handle-disabled.png differ diff --git a/gui/styles/AmbianceClassic/widgets/dial-handle-down.png b/gui/styles/AmbianceClassic/widgets/dial-handle-down.png new file mode 100644 index 000000000..7c601945b Binary files /dev/null and b/gui/styles/AmbianceClassic/widgets/dial-handle-down.png differ diff --git a/gui/styles/AmbianceClassic/widgets/dial-handle.png b/gui/styles/AmbianceClassic/widgets/dial-handle.png new file mode 100644 index 000000000..2fab00b2c Binary files /dev/null and b/gui/styles/AmbianceClassic/widgets/dial-handle.png differ