diff --git a/Nasal/canvas/gui.nas b/Nasal/canvas/gui.nas index 5a128a186..a71381156 100644 --- a/Nasal/canvas/gui.nas +++ b/Nasal/canvas/gui.nas @@ -31,11 +31,17 @@ loadGUIFile("Config.nas"); loadGUIFile("Style.nas"); loadGUIFile("Widget.nas"); loadGUIFile("styles/DefaultStyle.nas"); + +# widgets loadWidget("Button"); loadWidget("CheckBox"); loadWidget("Label"); loadWidget("LineEdit"); loadWidget("ScrollArea"); +loadWidget("Rule"); +loadWidget("Slider"); + +# standard dialogs loadDialog("InputDialog"); loadDialog("MessageBox"); diff --git a/Nasal/canvas/gui/styles/DefaultStyle.nas b/Nasal/canvas/gui/styles/DefaultStyle.nas index e5421ea11..4e524e5e9 100644 --- a/Nasal/canvas/gui/styles/DefaultStyle.nas +++ b/Nasal/canvas/gui/styles/DefaultStyle.nas @@ -196,7 +196,7 @@ DefaultStyle.widgets.label = { }, setText: func(model, text) { - if( text == nil or size(text) == 0 ) + if ( !isstr(text) or size(text) == 0 ) { model.setHeightForWidthFunc(nil); return me._deleteElement('text'); @@ -451,3 +451,274 @@ DefaultStyle.widgets["scroll-area"] = { model._scroller_delta[dir] = model._size[dir] - model._scroller_size[dir]; } }; + +# A horizontal or vertical rule line +# possibly with a text label embedded +DefaultStyle.widgets.rule = { + new: func(parent, cfg) + { + me._root = parent.createChild("group", "rule"); + me._createElement("bg", "image"); + me._isVertical = cfg.get("isVertical"); + if (me._isVertical) { + me._bg.set("slice", "0 20"); + me._baseFile = "vrule"; + } else { + me._bg.set("slice", "10 0"); + me._baseFile = "hrule"; + } + }, + setSize: func(model, w, h) + { + if( me['_text'] != nil ) + { + # first 20 px + me._bg.setTranslation(2, 0); + me._bg.setSize(20, h); + + # TODO handle eliding for translations? + me._text.setTranslation(22, 2 + h / 2); + var maxW = model._cfg.get("maxTextWidth", -1); + if (maxW > 0) { + me._text.set("max-width", maxW); + } + + var bg2Left = maxW > 0 ? maxW : me._text.maxWidth() + 22; + me._bg2.setTranslation(bg2Left, 0); + me._bg2.setSize(w - bg2Left, h); + } else { + me._bg.setSize(w, h); + } + return me; + }, + setText: func(model, text) + { + if( text == nil or size(text) == 0 ) + { + # force a resize? + me._deleteElement('bg2'); + return me._deleteElement('text'); + } + + if (me._isVertical) { + logprint(LOG_DEVALERT, "Text label not supported for vertical rules, yet"); + return; + } + + me._createElement("text", "text") + .setText(text); + + var width_hint = me._text.maxWidth() + 40; + me._createElement("bg2", "image") + .set("slice", "10 0"); + + model.setLayoutMinimumSize([40, 14]); + # TODO mark as expanding? + model.setLayoutSizeHint([width_hint, 24]); + + return me.update(model); + }, + update: func(model) + { + var file = me._style._dir_widgets ~ "/"; + file ~= me._baseFile; + + if( !model._enabled ) + file ~= "-disabled"; + + me._bg.set("src", file ~ ".png"); + if ( me['_bg2'] != nil) + me._bg2.set("src", file ~ ".png"); + + # different color if disabled? + if( me['_text'] != nil ) + { + var color_name = model._windowFocus() ? "fg_color" : "backdrop_fg_color"; + me._text.set("fill", me._style.getColor(color_name)); + } + }, +# protected: + _createElement: func(name, type) + { + var mem = '_' ~ name; + if( me[ mem ] == nil ) + { + me[ mem ] = me._root.createChild(type, "rule-" ~ name); + + if( type == "text" ) + { + me[ mem ].set("font", "LiberationFonts/LiberationSans-Regular.ttf") + .set("character-size", 14) + .set("alignment", "left-center"); + } + } + return me[ mem ]; + }, + _deleteElement: func(name) + { + name = '_' ~ name; + if( me[ name ] != nil ) + { + me[ name ].del(); + me[ name ] = nil; + } + return me; + } +}; + +# a frame (sometimes called a group box), with optional label +# and enable/disable checkbox +DefaultStyle.widgets.frame = { + new: func(parent, cfg) + { + me._root = parent.createChild("group", "frame-box"); + me._createElement("bg", "image") + .set("slice", "10 10"); + me.content = me._root.createChild("group", "frame-content"); + + # handle label + checkable flag + }, + + update: func(model) + { + var file = me._style._dir_widgets ~ "/"; + file ~= "backdrop-"; + + if( !model._enabled ) + file ~= "-disabled"; + + me._bg.set("src", file ~ ".png"); + + + }, + # protected: + _createElement: func(name, type) + { + var mem = '_' ~ name; + if( me[ mem ] == nil ) + { + me[ mem ] = me._root.createChild(type, "frame-" ~ name); + + if( type == "text" ) + { + me[ mem ].set("font", "LiberationFonts/LiberationSans-Regular.ttf") + .set("character-size", 14) + .set("alignment", "left-center"); + } + } + return me[ mem ]; + }, + _deleteElement: func(name) + { + name = '_' ~ name; + if( me[ name ] != nil ) + { + me[ name ].del(); + me[ name ] = nil; + } + return me; + } + +}; + +# a horionztal or vertical slider, for selecting / +# dragging over a numerical range +DefaultStyle.widgets.slider = { + new: func(parent, cfg) + { + me._root = parent.createChild("group", "slider"); + me._createElement("bg", "image") + .set("slice", "10 10"); + + me._createElement("thumb", "image") + .set("slice", "10 10"); + + me._ticks = 0; + me._ticksPath = nil; + }, + + setNormValue: func(model, normValue) + { + var (w, h) = model._size; + var availWidthPos = w - h; # pixel range the thumb can move over + me._thumb.setTranslation(round(availWidthPos * normValue), 0); + }, + + update: func(model) + { + # set background state + var file = me._style._dir_widgets ~ "/"; + file ~= "backdrop-"; + if( !model._enabled ) + file ~= "-disabled"; + + me._bg.set("src", file ~ ".png"); + + # set thumb state + file = me._style._dir_widgets ~ "/"; + file ~= "button-"; # should we use a seperate thumb? + if( !model._enabled ) + file ~= "-disabled"; + else if (model._down) + file ~= "-down"; + elsif (model._hover) + file ~= "-hovered"; + + me._thumb.set("src", file ~ ".png"); + + # set thumb size + var (w, h) = model._size; + # fixme assumes horizonal for now + me._thumb.setSize(h, h); + + # update the position as well, since other stuff + # may have changed + me.setNormValue(model, model._normValue()); + }, + + updateRanges: func(minValue, maxValue, numTicks = 0) + { + if (me._ticks != numTicks) { + # update tick marks + if (numTicks == 0) { + me._ticks = 0; + me._deleteElement('ticksPath'); + } else { + me._createElement('ticksPath', 'path'); + me._ticks = numTicks; + + # set style + # loop adding ticks + + } + } + }, + + # protected: + _createElement: func(name, type) + { + var mem = '_' ~ name; + if( me[ mem ] == nil ) + { + me[ mem ] = me._root.createChild(type, "slider-" ~ name); + + if( type == "text" ) + { + me[ mem ].set("font", "LiberationFonts/LiberationSans-Regular.ttf") + .set("character-size", 14) + .set("alignment", "left-center"); + } + } + return me[ mem ]; + }, + _deleteElement: func(name) + { + name = '_' ~ name; + if( me[ name ] != nil ) + { + me[ name ].del(); + me[ name ] = nil; + } + return me; + } +} \ No newline at end of file diff --git a/Nasal/canvas/gui/widgets/Frame.nas b/Nasal/canvas/gui/widgets/Frame.nas new file mode 100644 index 000000000..2e746acf6 --- /dev/null +++ b/Nasal/canvas/gui/widgets/Frame.nas @@ -0,0 +1,68 @@ +# Frame.nas: container with a visual frame around it, +# and optional checkbox / label (usuallt at the top / left) +# to enable / disable it + +# Copyright (C) 2022 James Turner +# SPDX-License-Identifier: GPL-2.0-or-later + + +gui.widgets.Frame = { + new: func(parent, style, cfg) + { + var m = gui.Widget.new(gui.widgets.Frame); + m._cfg = Config.new(cfg); + # m._focus_policy = m.NoFocus; maybe? + m._setView( style.createWidget(parent, "frame", m._cfg) ); + m._checkable = cfg.get("checkable", 0); + m._label = cfg.get("label"); + m._layout = nil; + + m.setLayoutSizeHint([200, 200]); + m.setLayoutMaximumSize([m._MAX_SIZE, m._MAX_SIZE]); + return m; + }, + getContent: func() + { + return me._view.content; + }, + setLabel: func(text) + { + me._label = text; + me._view.update(me); + return me; + }, + setCheckable: func(e) + { + me._checkable = e; + me._view.update(me); + return me; + }, + setLayout: func(;) + { + me._layout = l; + l.setParent(me); + return me.update(); + }, + setSize: func + { + if( size(arg) == 1 ) + var arg = arg[0]; + var (x,y) = arg; + me._size = [x,y]; + return me.update(); + }, + # Needs to be called when the size of the content changes. + update: func() + { + # var offset = [ me._content_offset[0] - me._content_pos[0], + # me._content_offset[1] - me._content_pos[1] ]; + + me.getContent().setTranslation(10, 10); + me.getContent().setSize([me._size[0] - 20, me._size[1] - 20]); + + me._view.update(me); + me.getContent().update(); + + return me; + }, +}; \ No newline at end of file diff --git a/Nasal/canvas/gui/widgets/RadioButton.nas b/Nasal/canvas/gui/widgets/RadioButton.nas new file mode 100644 index 000000000..fec9687fa --- /dev/null +++ b/Nasal/canvas/gui/widgets/RadioButton.nas @@ -0,0 +1,95 @@ +# RadioButton.nas : radio button, and group helper +# to manage updating checked state conherently +# Copyright (C) 2022 James Turner +# SPDX-License-Identifier: GPL-2.0-or-later + +gui.widgets.RadioButton = { + new: func(parent, style, cfg) + { + cfg["type"] = "radio"; + var m = gui.widgets.Button.new(parent, style, cfg); + m._checkable = 1; + + append(m.parents, gui.widgets.RadioButton); + + if (contains(pr, "radioGroup") { + pr.radioGroup.addRadio(m); + } + + return m; + }, + + del: func() + { + var pr = getParent(); + if (contains(pr, "radioGroup") { + pr.radioGroup.removeRadio(me); + } + }, + + setCheckable: nil, + setChecked: func(checked = 1) + { + # call our base version + me.parents[0].setChecked(checked); + if (checked) { + me._setRadioGroupSiblingsUnchecked(); + } + }, +# protected members + _setRadioGroupSiblingsUnchecked: func + { + var pr = getParent(); + if (contains(pr, "radioGroup") { + pr.radioGroup._updateChecked(me); + } else { + # todo, remove me, it's okay to manually manage RadioButtons + logprint(LOG_DEV, "No radio group defined"); + } + } +}; + +# auto manage radio-button checked state +gui.widgets.RadioGroup = { + new: func(nm = 'unnamed') + { + var m = {parents:[gui.widgets.RadioGroup, name:nm, radios:[]]}; + return m; + }, + + addRadio: func(r) + { + if (r.parents[0] != RadioButton) { + logprint(LOG_ALERT, "Adding non-RadioButton to RadioGroup") + return; + } + append(me.radios, r); + }, + + removeRadio: func(r) + { + if (r.parents[0] != RadioButton) { + logprint(LOG_ALERT, "Remove non-RadioButton from RadioGroup") + return; + } + + # should we update some other item to be checked? + me.radios.remove(r); + }, + + setEnabled: func(doEnable = 1) + { + foreach (var r : me.radios) { + r.setEnabled(doEnable); + } + }, + +# protected methods + # update check state of all radios in the group + _updateChecked: func(active) + { + foreach (var r : me.radios) { + r.setChecked(r == active); + } + } +}; \ No newline at end of file diff --git a/Nasal/canvas/gui/widgets/Rule.nas b/Nasal/canvas/gui/widgets/Rule.nas new file mode 100644 index 000000000..a1dfee393 --- /dev/null +++ b/Nasal/canvas/gui/widgets/Rule.nas @@ -0,0 +1,47 @@ +# Rule.nas : horizontal or vertical dividing line, +# optionally with a text label, eg to name a section +# Copyright (C) 2022 James Turner +# SPDX-License-Identifier: GPL-2.0-or-later + + +gui.widgets.HorizontalRule = { + new: func(parent, style, cfg) + { + var m = gui.Widget.new(gui.widgets.HorizontalRule); + m._cfg = Config.new(cfg); + m._focus_policy = m.NoFocus; + m._setView( style.createWidget(parent, "rule", m._cfg) ); + +# should ask Style the rule height, not hard-code 1px + m.setLayoutMinimumSize([16, 1]); + m.setLayoutSizeHint([m._MAX_SIZE, 1]); # expand to fill + m.setLayoutMaximumSize([m._MAX_SIZE, 1]); + return m; + }, + setText: func(text) + { + me._view.setText(me, text); + return me; + } +}; + +gui.widgets.VerticalRule = { + new: func(parent, style, cfg) + { + var m = gui.Widget.new(gui.widgets.VerticalRule); + m._cfg = Config.new(cfg); + m._focus_policy = m.NoFocus; + m._setView( style.createWidget(parent, "rule", m._cfg) ); + +# should ask Style the rule height, not hard-code 1px + m.setLayoutMinimumSize([1, 16]); + m.setLayoutSizeHint([1, m._MAX_SIZE]); # expand to fill + m.setLayoutMaximumSize([1, m._MAX_SIZE]); + return m; + }, + setText: func(text) + { + me._view.setText(me, text); + return me; + } +}; diff --git a/Nasal/canvas/gui/widgets/Slider.nas b/Nasal/canvas/gui/widgets/Slider.nas new file mode 100644 index 000000000..050d69aef --- /dev/null +++ b/Nasal/canvas/gui/widgets/Slider.nas @@ -0,0 +1,54 @@ +gui.widgets.Slider = { + new: func(parent, style, cfg) + { + var cfg = Config.new(cfg); + var m = gui.Widget.new(gui.widgets.Slider); + m._focus_policy = m.StrongFocus; + m._down = 0; + m._minValue = 0; + m._maxValue = 100; + m._value = 50; + m._pageStep = 10; + m._numTicks = 10; + + + if( style != nil ) { + m._setView( style.createWidget(parent, cfg.get("type", "slider"), cfg) ); + m._view.updateRanges(m._minValue, m._maxValue, m._numTicks); + } + + return m; + }, + + setValue: func(val) + { + if( me._view != nil ) { + me._view.setNormValue(me._normValue()); + } + return me; + }, + + + +# protected: + _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()); + + # el.addEventListener("mouseleave",func me.setDown(0)); + # el.addEventListener("drag", func(e) e.stopPropagation()); + }, + + # return value as its normalised equivalent + _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/widgets/hrule.png b/gui/styles/AmbianceClassic/widgets/hrule.png new file mode 100644 index 000000000..c9090ae76 Binary files /dev/null and b/gui/styles/AmbianceClassic/widgets/hrule.png differ