diff --git a/Nasal/canvas/gui.nas b/Nasal/canvas/gui.nas index da6b8a7df..deb7b3c20 100644 --- a/Nasal/canvas/gui.nas +++ b/Nasal/canvas/gui.nas @@ -42,6 +42,7 @@ loadWidget("Button"); loadWidget("CheckBox"); loadWidget("Label"); loadWidget("LineEdit"); +loadWidget("List"); loadWidget("MenuBar"); loadWidget("PropertyWidgets"); loadWidget("ScrollArea"); diff --git a/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas b/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas index dfa035b00..30a76fdb6 100644 --- a/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas +++ b/Nasal/canvas/gui/dialogs/WidgetsFactoryDialog.nas @@ -57,8 +57,12 @@ var WidgetsFactoryDialog = { var r2 = gui.widgets.HorizontalRule.new(m.tabsContent, style, {}); m.tab_1.addItem(r2); - m.tab_2 = VBoxLayout.new(); + m.tab_2 = HBoxLayout.new(); m.tabs.addTab("tab-2", "Tab 2", m.tab_2); + + m.button_box = VBoxLayout.new(); + m.tab_2.addItem(m.button_box); + m.button = gui.widgets.Button.new(m.tabsContent, style, {}) .setText("A button") .setFixedSize(60, 30) @@ -67,13 +71,13 @@ var WidgetsFactoryDialog = { MessageBox.information("You clicked the button …", "… and entered '" ~ (text != nil ? text : "nothing") ~ "' !"); }); }); - m.tab_2.addItem(m.button); + m.button_box.addItem(m.button); m.image = gui.widgets.Label.new(m.tabsContent, style, {}) .setImage("Textures/Splash1.png") .setVisible(0) .setFixedSize(128, 128); - m.tab_2.addItem(m.image); + m.button_box.addItem(m.image); m.image._view._root.addEventListener("mousedown", func (e) { logprint(LOG_INFO, "Image was clicked at:" ~ e.localX ~ "," ~ e.localY); logprint(LOG_INFO, "Client pos:" ~ e.clientX ~ "," ~ e.clientY); @@ -97,7 +101,7 @@ var WidgetsFactoryDialog = { .listen("toggled", func (e) { m.image.setVisible(int(e.detail.checked)); }); - m.tab_2.addItem(m.checkable_button); + m.button_box.addItem(m.checkable_button); m.upsize_button = gui.widgets.Button.new(m.tabsContent, style, {}) .setText("Upsize window") @@ -106,7 +110,7 @@ var WidgetsFactoryDialog = { var s = m.window.getSize(); m.window.setSize(s[0] + 100, s[1] + 100); }); - m.tab_2.addItem(m.upsize_button, 5); + m.button_box.addItem(m.upsize_button); m.downsize_button = gui.widgets.Button.new(m.tabsContent, style, {}) .setText("Downsize window") @@ -115,11 +119,30 @@ var WidgetsFactoryDialog = { var s = m.window.getSize(); m.window.setSize(s[0] - 100, s[1] - 100); }); - m.tab_2.addItem(m.downsize_button, 5); + m.button_box.addItem(m.downsize_button); + + m.list_box = VBoxLayout.new(); + m.tab_2.addItem(m.list_box); + + m.list = gui.widgets.List.new(m.tabsContent); + for (var i = 0; i < 30; i += 1) { + m.list.createItem("Item " ~ i); + } + m.list.listen("selection-changed", func { + m.list_selection_label.setText("Selected items: " ~ (string.join(", ", map(func(item) item._text, m.list.getSelectedItems())) or "none")); + }); + m.list.setSizeHint([m.list._MAX_SIZE, m.list._MAX_SIZE]); + m.list_box.addItem(m.list); + + m.list_selection_label = gui.widgets.Label.new(m.tabsContent, canvas.style, {}) + .setText("Selected items: none"); + m.list_selection_label.setAlignment(canvas.AlignBottom); + m.list_box.addItem(m.list_selection_label); m.benchmark_tab = VBoxLayout.new(); m.tabs.addTab("benchmark", "Benchmark", m.benchmark_tab); m.benchmark_tab_scroll = canvas.gui.widgets.ScrollArea.new(m.tabsContent, canvas.style, {}); + m.benchmark_tab_scroll.setSizeHint([m.list._MAX_SIZE, m.list._MAX_SIZE]); m.benchmark_tab_scroll_layout = VBoxLayout.new(); m.benchmark_tab_scroll.setLayout(m.benchmark_tab_scroll_layout); m.benchmark_tab.addItem(m.benchmark_tab_scroll); diff --git a/Nasal/canvas/gui/styles/DefaultStyle.nas b/Nasal/canvas/gui/styles/DefaultStyle.nas index 0ab020e57..90ff3732a 100644 --- a/Nasal/canvas/gui/styles/DefaultStyle.nas +++ b/Nasal/canvas/gui/styles/DefaultStyle.nas @@ -964,6 +964,164 @@ DefaultStyle.widgets["menu-bar"] = { return me; }, + update: func(model) { + me._bg.set("fill", me._style.getColor("bg_color")); + + return me; + } +}; + +# A button +DefaultStyle.widgets["combo-box"] = { + new: func(parent, cfg) + { + me._root = parent.createChild("group", "combo-box"); + me._bg = + me._root.createChild("path"); + me._border = + me._root.createChild("image", "border") + .set("slice", "10 6"); #"7") + me._buttonBorder = + me._root.createChild("image", "border-button") + .set("slice", "10 6"); #"7") + me._arrowIcon = me._root.createChild("image", "arrow"); + me._label = + me._root.createChild("text") + .set("font", "LiberationFonts/LiberationSans-Regular.ttf") + .set("character-size", 14) + .set("alignment", "left-center"); + }, + setSize: func(model, w, h) + { + var halfWidth = int(w * 0.5); + me._bg.reset() + .rect(3, 3, w - 6, h - 6, {"border-radius": 5}); + + # we split the two pieces + me._border.setSize(halfWidth, h); + me._buttonBorder.setTranslation(halfWidth, 0); + me._buttonBorder.setSize(w - halfWidth, h); + + var arrowSize = me._arrowIcon.imageSize(); + me._arrowIcon.setTranslation(w - (arrowSize[0] + 20), (h - arrowSize[1]) * 0.5); + + me._label.setTranslation(20, h * 0.5); + }, + setText: func(model, text) + { + me._label.setText(text); + + var min_width = math.max(80, me._label.maxWidth() + 16 + me._arrowIcon.imageSize()[0]); + model.setLayoutMinimumSize([min_width, 16]); + model.setLayoutSizeHint([min_width, 28]); + + return me; + }, + update: func(model) + { + var backdrop = !model._windowFocus(); + var (w, h) = model._size; + var file = me._style._dir_widgets ~ "/"; + + # TODO unify color names with image names + var bg_color_name = "button_bg_color"; + if( backdrop ) + bg_color_name = "button_backdrop_bg_color"; + else if( !model._enabled ) + bg_color_name = "button_bg_color_insensitive"; + else if( model._down ) + bg_color_name = "button_bg_color_down"; + else if( model._hover ) + bg_color_name = "button_bg_color_hover"; + me._bg.set("fill", me._style.getColor(bg_color_name)); + + var arrowIconFile = file ~ "combobox-arrow"; + + if( backdrop ) + { + file ~= "backdrop-"; + me._label.set("fill", me._style.getColor("backdrop_fg_color")); + } + else + me._label.set("fill", me._style.getColor("fg_color")); + file ~= "combobox"; + + var buttonFile = file ~ "-button"; + file ~= "-entry"; + + var suffix = ""; + if( model._down ) + { # no pressed image for the left half + buttonFile ~= "-pressed"; + } + + if( model._enabled ) { + if( model._focused and !backdrop ) + suffix ~= "-focused"; + } else { + suffix ~= "-disabled"; + arrowIconFile ~= "-disabled"; + } + + me._border.set("src", file ~ suffix ~ ".png"); + me._buttonBorder.set("src", buttonFile ~ suffix ~ ".png"); + me._arrowIcon.set("src", arrowIconFile ~ ".png"); + } +}; + +DefaultStyle.widgets["list-item"] = { + new: func(parent, cfg) { + me._root = parent.createChild("group", "list-item"); + me._bg = me._root.createChild("path"); + + me._label = me._root.createChild("text") + .set("font", "LiberationFonts/LiberationSans-Regular.ttf") + .set("character-size", 14) + .set("alignment", "left-baseline"); + }, + + setSize: func(model, w, h) { + me._bg.reset().rect(0, 0, w, 24); + me._label.setTranslation(5, int(h / 2) + 4); + return me; + }, + + _updateLayoutSizes: func(model) { + var min_width = 5 + me._label.maxWidth() + 5; + model.setLayoutMinimumSize([min_width, 24]); + model.setLayoutSizeHint([min_width, 24]); + + return me; + }, + + setText: func(model, text) { + me._label.setText(text); + return me._updateLayoutSizes(model); + }, + + update: func(model) { + me._bg.set("fill", me._style.getColor("list_item_bg" ~ (model._selected ? "_selected" : ""))); + var text_color_name = "list_item_fg"; + if (model._selected) { + text_color_name ~= "_selected"; + } + me._label.set("fill", me._style.getColor(text_color_name)); + + return me; + } +}; + +DefaultStyle.widgets.list = { + new: func(parent, cfg) { + me._root = parent.createChild("group", "list"); + me._bg = me._root.createChild("path"); + }, + + setSize: func(model, w, h) { + me._bg.reset().rect(0, 0, w, h); + return me; + }, + update: func(model) { me._bg.set("fill", me._style.getColor("bg_color")); diff --git a/Nasal/canvas/gui/widgets/List.nas b/Nasal/canvas/gui/widgets/List.nas new file mode 100644 index 000000000..dce10988e --- /dev/null +++ b/Nasal/canvas/gui/widgets/List.nas @@ -0,0 +1,291 @@ +# List.nas - a list widget similar to Qt's QListWidget + +# SPDX-FileCopyrightText: (C) 2022 Frederic Croix +# SPDX-License-Identifier: GPL-2.0-or-later + +gui.widgets.ListItem = { + new: func(parent, style = nil, cfg = nil) { + var m = gui.Widget.new(gui.widgets.ListItem); + style = style or canvas.style; + m._cfg = Config.new(cfg or {}); + m._focus_policy = m.NoFocus; + + m._data = m._cfg.get("data", {}); + m._text = m._cfg.get("text", ""); + m._selected = m._cfg.get("selected", 0); + m._list = nil; + + m._setView(style.createWidget(parent, "list-item", m._cfg)); + + m.setLayoutMinimumSize([48, 24]); + m.setLayoutMaximumSize([m._MAX_SIZE, 24]); + + m.setText(m._text); + m.setSelected(m._selected); + + return m; + }, + + # @description Set the data for this item. + # @param key Union[scalar, hash] required If @param key is a hash, this item's data is replaced with that hash. + # If @param key is a scalar, the data field with that name will be set to value. + # If @param key is anything else, an error will be raised. + # @param value Any optional The value to set the data field with key @param key to, if @param key is a scalar; + # otherwise, this argument is ignored. + # @return canvas.gui.widgets.ListItem This list item to support method chaining. + setData: func(key, value = nil) { + if (isscalar(key)) { + me._data[key] = value; + } elsif (ishash(key)) { + me._data = key; + } else { + die("cannot set data field with non-scalar key !"); + } + return me; + }, + + # @description Get the data of this item. + # @param key Union[scalar, nil] The scalar key of the data field to return the value of, or nil to return the whole data. + # @return Any If @param key is a scalar, the value of the field with key @param key, else the whole data as a hash. + getData: func(key = nil) { + if (key != nil) { + if (!isscalar(key)) { + die("cannot get data field with non-scalar key !") + } + return me._data[key]; + } else { + return me._data; + } + }, + + # @description Clear data + # @return canvas.gui.widgets.ListItem This list item to support method chaining. + clearData: func { + me._data = {}; + return me; + }, + + setText: func(text) { + me._text = text; + me._view.setText(me, text); + return me; + }, + + getText: func { + return me._text; + }, + + setSelected: func(selected = 1) { + if (me.getParent() != nil) { + me.getParent().setItemSelection(me, selected); + } + return me; + }, + + getSelected: func { + return me._selected; + }, + + _setSelected: func(selected) { + if (selected != me._selected) { + me._selected = selected; + me.update(); + me._trigger("selection-state-changed", {"selected": selected}); + if (me._selected) { + me._trigger("selected"); + } else { + me._trigger("unselected"); + } + } + return me; + }, + + _setView: func(view) { + call(gui.Widget._setView, [view], me); + + var el = view._root; + el.addEventListener("click", func me.setSelected()); + }, + + update: func { + me._view.update(me); + return me; + }, +}; + +gui.widgets.List = { + new: func(parent, style = nil, cfg = nil) { + var m = gui.Widget.new(gui.widgets.List); + m._style = style = style or canvas.style; + m._cfg = Config.new(cfg or {}); + m._focus_policy = m.NoFocus; + + m._setView(style.createWidget(parent, "list", m._cfg)); + + m._scroll = gui.widgets.ScrollArea.new(m._view._root, style, {}); + m._scrollLayout = VBoxLayout.new(); + m._scroll.setLayout(m._scrollLayout); + m._scrollLayout.setSpacing(0); + + m.setLayoutMinimumSize([48, 24]); + + return m; + }, + + setSize: func { + if (size(arg) == 1) { + var arg = arg[0]; + } + var (x, y) = arg; + me._size = [x, y]; + me._scroll.setSize(x, y); + return me.update(); + }, + + _onItemSelectionStateChanged: func { + var items = me.getSelectedItems(); + me._trigger("selection-changed"); + }, + + # @description Add the given item to this list. + # @param item canvas.gui.widgets.ListItem required The item to be added to this list. + # @return canvas.gui.widgets.List This list to support method chaining. + addItem: func(item) { + item.listen("selection-state-changed", func me._onItemSelectionStateChanged()); + me._scrollLayout.addItem(item); + item.setParent(me); + return me; + }, + + # @description Create an item with the given text and optionally the given config. + # @param text str required Text of the new item. + # @param cfg hash optional Additional configuration of the item. + # @return canvas.gui.widgets.ListItem The created list item. + createItem: func(text, cfg = nil) { + cfg = cfg or {}; + cfg["text"] = text; + var item = gui.widgets.ListItem.new(me._scroll.getContent(), me._style, cfg); + me.addItem(item); + return item; + }, + + # @description Count the items of this list. + # @return int Number of items in this list. + count: func { + return me._scrollLayout.count(); + }, + + # @description Remove all items from this list. + # @return canvas.gui.widgets.List This list to support method chaining. + clear: func { + me._scrollLayout.clear(); + return me; + }, + + # @description Get the item with the given index or text + # @param text_or_index Union[int, str] required If text_or_index is an integer, the item at index text_or_index is returned. + # If text_or_index is a string, the item with text == text_or_index is returned. + # @return canvas.gui.widgets.ListItem The item with the given text or index, or nil if no such item is found. + getItem: func(text_or_index) { + if (isint(text_or_index)) { + return me._scrollLayout.itemAt(text_or_index); + } else { + for (var i = 0; i < me.count(); i += 1) { + if (me._scrollLayout.itemAt(i)._text == text_or_index) { + return me.getItem(i); + } + } + } + }, + + # @description Find the index of the given item or item with the given text. + # @param text_or_item Union[str, canvas.gui.widgets.ListItem] required The item or text of the item to return the index of. + # @return int The index of the given item or item with the given text, or -1 if the given item or no item with the given text is found. + findItem: func(text_or_item) { + for (var i = 0; i < me.count(); i += 1) { + if ( + (isstr(text_or_item) and me._scrollLayout.itemAt(i)._text == text_or_index) or + text_or_item == me.getItem(i) + ) { + return i; + } + } + return -1; + }, + + # @description Remove and return the item with the given text or index. + # @param text_or_item Union[str, int] required The text or index of the item to remove and return + # @return int The item with the given text or index, or nil if no item with the given text or index is found. + takeItem: func(text_or_index) { + var index = text_or_index; + if (!isint(index)) { + index = me.findItem(index); + if (index < 0) { + return nil; + } + } elsif (index < 0) { + return nil; + } + return me._scrollLayout.takeAt(index); + }, + + # @description Remove the item with the given text or index or given item. + # @param text_or_index_or_item Union[str, int, canvas.gui.widgets.ListItem] required The text or index of the item, or the item, to remove. + # @return canvas.gui.widgets.List This list to support method chaining. + removeItem: func(text_or_index_or_item) { + if (isscalar(text_or_index_or_item)) { + me.takeItem(text_or_index_or_item); + } else { + me._scrollLayout.removeItem(item); + } + return me; + }, + + # @description Unselect all items of this list. + # @return canvas.gui.widgets.List This list to support method chaining. + clearSelection: func { + for (var i = 0; i < me.count(); i += 1) { + me.getItem(i)._selected = 0; + me.getItem(i).update(); + } + return me; + }, + + # @description Set the selected state of the item item with the given index or text. + # @param text_or_index Union[int, str] required If text_or_index is an integer, the item at index text_or_index is returned. + # If text_or_index is a string, the item with text == text_or_index is returned. + # @param selected bool optional The selection state of the item, defaults to selected if not given. + # @return canvas.gui.widgets.List This list to support method chaining. + setItemSelection: func(text_or_index_or_item, selected = 1) { + var item = text_or_index_or_item; + if (typeof(item) == "scalar") { + item = me.getItem(text_or_index_or_item); + if (!item) { + die("no item found with given text or index '" ~ text_or_index_or_item ~ "' found !"); + } + } + me.clearSelection(); + item._setSelected(selected); + return me; + }, + + # @description Get a vector containing all selected items of this list + # @return vector[canvas.gui.widgets.ListItem] Vector containing the selected items + getSelectedItems: func { + var items = []; + for (var i = 0; i < me.count(); i += 1) { + var item = me.getItem(i); + if (item._selected) { + append(items, item); + } + } + return items; + }, + + update: func { + me._scroll.update(); + me._view.update(me); + return me; + } +} + diff --git a/gui/styles/AmbianceClassic/style.xml b/gui/styles/AmbianceClassic/style.xml index 90c348833..bb8d92c34 100644 --- a/gui/styles/AmbianceClassic/style.xml +++ b/gui/styles/AmbianceClassic/style.xml @@ -185,5 +185,29 @@ 0.945 0.941 + + + 0.298 + 0.298 + 0.298 + + + + 0.95 + 0.95 + 0.95 + + + + 0.949 + 0.945 + 0.941 + + + + 0.15 + 0.15 + 1 +