commit 4c7996c915f6102872bcf06e991217d9226c3bb1 Author: luk3yx Date: Fri Jul 15 12:38:09 2022 +1200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5ca553 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +test*.lua +*.old diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..49f9b7e --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,18 @@ +max_line_length = 80 + +globals = { + 'formspec_ast', + 'minetest', + 'hud_fs', + 'flow', + 'dump', +} + +read_globals = { + string = {fields = {'split', 'trim'}}, + table = {fields = {'copy', 'indexof'}} +} + +-- This error is thrown for methods that don't use the implicit "self" +-- parameter. +ignore = {"212/self", "432/player", "43/ctx", "212/player", "212/ctx", "212/value"} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0927556 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,157 @@ +### GNU LESSER GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the +terms and conditions of version 3 of the GNU General Public License, +supplemented by the additional permissions listed below. + +#### 0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the +GNU General Public License. + +"The Library" refers to a covered work governed by this License, other +than an Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + +A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + +The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + +#### 1. Exception to Section 3 of the GNU GPL. + +You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + +#### 2. Conveying Modified Versions. + +If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + +- a) under this License, provided that you make a good faith effort + to ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or +- b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + +#### 3. Object Code Incorporating Material from Library Header Files. + +The object code form of an Application may incorporate material from a +header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + +- a) Give prominent notice with each copy of the object code that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the object code with a copy of the GNU GPL and this + license document. + +#### 4. Combined Works. + +You may convey a Combined Work under terms of your choice that, taken +together, effectively do not restrict modification of the portions of +the Library contained in the Combined Work and reverse engineering for +debugging such modifications, if you also do each of the following: + +- a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the Combined Work with a copy of the GNU GPL and this + license document. +- c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. +- d) Do one of the following: + - 0) Convey the Minimal Corresponding Source under the terms of + this License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + - 1) Use a suitable shared library mechanism for linking with + the Library. A suitable mechanism is one that (a) uses at run + time a copy of the Library already present on the user's + computer system, and (b) will operate properly with a modified + version of the Library that is interface-compatible with the + Linked Version. +- e) Provide Installation Information, but only if you would + otherwise be required to provide such information under section 6 + of the GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the Application + with a modified version of the Linked Version. (If you use option + 4d0, the Installation Information must accompany the Minimal + Corresponding Source and Corresponding Application Code. If you + use option 4d1, you must provide the Installation Information in + the manner specified by section 6 of the GNU GPL for conveying + Corresponding Source.) + +#### 5. Combined Libraries. + +You may place library facilities that are a work based on the Library +side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + +- a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities, conveyed under the terms of this License. +- b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + +#### 6. Revised Versions of the GNU Lesser General Public License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +as you received it specifies that a certain numbered version of the +GNU Lesser General Public License "or any later version" applies to +it, you have the option of following the terms and conditions either +of that published version or of any later version published by the +Free Software Foundation. If the Library as you received it does not +specify a version number of the GNU Lesser General Public License, you +may choose any version of the GNU Lesser General Public License ever +published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47c8c61 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# flow + +An experimental layout manager and formspec API replacement for Minetest. +Vaguely inspired by Flutter and GTK. + +## Features + + - No manual positioning of elements. + - Some elements have an automatic size. + - The size of elements can optionally expand to fit larger spaces + - No form names. Form names are still used internally, however they are hidden from the API. + - No having to worry about state. + - Values of fields, scrollbars, checkboxes, etc are remembered when redrawing + a formspec and are automatically applied. + +## Limitations + + - This mod doesn't support all of the features that regular formspecs do. + - [FS51](https://content.minetest.net/packages/luk3yx/fs51/) is required if + you want to have full support for Minetest 5.3 and below. + +## Basic example + +See `example.lua` for a more comprehensive example which demonstrates how +layouting and alignment works. + +```lua +-- GUI elements are accessible with flow.widgets. Using +-- `local gui = flow.widgets` is recommended to reduce typing. +local gui = flow.widgets + +-- GUIs are created with flow.make_gui(build_func). +local my_gui = flow.make_gui(function(player, ctx) + -- The build function should return a GUI element such as gui.VBox. + -- `ctx` can be used to store context. `ctx.form` is reserved for storing + -- the state of elements in the form. For example, you can use + -- `ctx.form.my_checkbox` to check whether `my_checkbox` is checked. Note + -- that ctx.form.element may be nil instead of its default value. + + -- This function may be called at any time by flow. + + -- gui.VBox is a "container element" added by this mod. + return gui.VBox { + -- GUI elements have + gui.Label {label = "Here is a dropdown:"}, + gui.Dropdown { + -- The value of this dropdown will be accessible from ctx.form.my_dropdown + name = "my_dropdown", + items = {'First item', 'Second item', 'Third item'}, + index_event = true, + }, + gui.Button { + label = "Get dropdown index", + on_event = function(player, ctx) + -- flow should guarantee that `ctx.form.my_dropdown` exists, even if the client doesn't send my_dropdown to the server. + local selected_idx = ctx.form.my_dropdown + minetest.chat_send_player(player:get_player_name(), "You have selected item #" .. selected_idx .. "!") + end, + } + } +end) + +-- Show the GUI to player as an interactive form +-- Note that `player` is a player object and not a player name. +my_gui:show(player) + +-- Close the form +my_gui:close(player) + +-- Alternatively, the GUI can be shown as a non-interactive HUD (requires +-- hud_fs to be installed). +my_gui:show_hud(player) +my_gui:close_hud(player) +``` + +## Other formspec libraries/utilities + +These utilities likely aren't compatible with flow. + + - [fs_layout](https://github.com/fluxionary/minetest-fs_layout/) is another mod library that does automatic formspec element positioning. + - [Just_Visiting's formspec editor](https://content.minetest.net/packages/Just_Visiting/formspec_editor) is a Minetest (sub)game that lets you edit formspecs and preview them as you go + - [kuto](https://github.com/TerraQuest-Studios/kuto/) is a formspec library that has some extra widgets/components and has a callback API. Some automatic sizing can be done for buttons. + - It may be possible to use kuto's components with flow somehow as they both use formspec_ast internally. + - [My web-based formspec editor](https://forum.minetest.net/viewtopic.php?f=14&t=24130) lets you add elements and drag+drop them, however it doesn't support all formspec features. + +## Elements + +You should do `local gui = flow.widgets` in your code. + +### Layouting elements + +These elements are used to lay out elements in the formspec. They don't have a +direct equivalent in Minetest formspecs. + +#### `gui.VBox` + +A vertical box, similar to a VBox in GTK. Elements in the VBox are stacked +vertically. + +```lua +gui.VBox{ + -- These elements are documented later on. + gui.Label{label="I am a label!"}, + + -- The second label will be positioned underneath the first one. + gui.Label{label="I am a second label!"}, +} +``` + +#### `gui.HBox` + +Like `gui.VBox` but stacks elements horizontally instead. + +```lua +gui.HBox{ + -- These elements are documented later on. + gui.Label{label="I am a label!"}, + + -- The second label will be positioned to the right of first one. + gui.Label{label="I am a second label!"}, + + -- You can nest HBox and VBox elements + gui.VBox{ + gui.Image{texture_name="default_dirt.png", align_h = "centre"}, + gui.Label{label="This label should be below the above texture."}, + } +} +``` + +#### `gui.ScrollableVBox` + +Similar to `gui.VBox` but uses a scroll_container and automatically adds a +scrollbar. You must specify a width and height for the scroll container. + +```lua +gui.ScrollableVBox{ + -- A name must be provided for ScrollableVBox elements. You don't + -- have to use this name anywhere else, it just makes sure flow + -- doesn't mix up scrollbar states if one gets removed or if the + -- order changes. + name = "vbox1", + + -- Specifying a height is optional but is probably a good idea. + -- If you don't specify a height, it will default to + -- min(height_of_content, 5). + h = 10, + + -- These elements are documented later on. + gui.Label{label="I am a label!"}, + + -- The second label will be positioned underneath the first one. + gui.Label{label="I am a second label!"}, +} +``` + +### Minetest formspec elements + +There is an auto-generated `elements.md` file which contains a list of elements +and parameters. Elements in this list haven't been tested and might not work. diff --git a/elements.md b/elements.md new file mode 100644 index 0000000..c83e036 --- /dev/null +++ b/elements.md @@ -0,0 +1,418 @@ +# Auto-generated elements list + +This is probably broken. + +### `gui.AnimatedImage` + +Equivalent to Minetest's `animated_image[]` element. + +**Example** +```lua +gui.AnimatedImage { + w = 1, -- Optional + h = 2, -- Optional + name = "my_animated_image", -- Optional + texture_name = "Hello world!", + frame_count = 3, + frame_duration = 4, + frame_start = 5, -- Optional + middle_x = 6, -- Optional + middle_y = 7, -- Optional + middle_x2 = 8, -- Optional + middle_y2 = 9, -- Optional +} +``` + +### `gui.Background` + +Equivalent to Minetest's `background[]` element. + +**Example** +```lua +gui.Background { + w = 1, -- Optional + h = 2, -- Optional + texture_name = "Hello world!", + auto_clip = false, -- Optional +} +``` + +### `gui.Background9` + +Equivalent to Minetest's `background9[]` element. + +**Example** +```lua +gui.Background9 { + w = 1, -- Optional + h = 2, -- Optional + texture_name = "Hello world!", + auto_clip = false, + middle_x = 3, + middle_y = 4, -- Optional + middle_x2 = 5, -- Optional + middle_y2 = 6, -- Optional +} +``` + +### `gui.Box` + +Equivalent to Minetest's `box[]` element. + +**Example** +```lua +gui.Box { + w = 1, -- Optional + h = 2, -- Optional + color = "#FF0000", +} +``` + +### `gui.Button` + +Equivalent to Minetest's `button[]` element. + +**Example** +```lua +gui.Button { + w = 1, -- Optional + h = 2, -- Optional + name = "my_button", -- Optional + label = "Hello world!", +} +``` + +### `gui.ButtonExit` + +Equivalent to Minetest's `button_exit[]` element. + +**Example** +```lua +gui.ButtonExit { + w = 1, -- Optional + h = 2, -- Optional + name = "my_button_exit", -- Optional + label = "Hello world!", +} +``` + +### `gui.Checkbox` + +Equivalent to Minetest's `checkbox[]` element. + +**Example** +```lua +gui.Checkbox { + name = "my_checkbox", -- Optional + label = "Hello world!", + selected = false, -- Optional +} +``` + +### `gui.Dropdown` + +Equivalent to Minetest's `dropdown[]` element. + +**Example** +```lua +gui.Dropdown { + w = 1, -- Optional + h = 2, -- Optional + name = "my_dropdown", -- Optional + items = "Hello world!", + selected_idx = 3, + index_event = false, -- Optional +} +``` + +### `gui.Field` + +Equivalent to Minetest's `field[]` element. + +**Example** +```lua +gui.Field { + w = 1, -- Optional + h = 2, -- Optional + name = "my_field", -- Optional + label = "Hello world!", + default = "Hello world!", +} +``` + +### `gui.Hypertext` + +Equivalent to Minetest's `hypertext[]` element. + +**Example** +```lua +gui.Hypertext { + w = 1, -- Optional + h = 2, -- Optional + name = "my_hypertext", -- Optional + text = "Hello world!", +} +``` + +### `gui.Image` + +Equivalent to Minetest's `image[]` element. + +**Example** +```lua +gui.Image { + w = 1, -- Optional + h = 2, -- Optional + texture_name = "Hello world!", + middle_x = 3, -- Optional + middle_y = 4, -- Optional + middle_x2 = 5, -- Optional + middle_y2 = 6, -- Optional +} +``` + +### `gui.ImageButton` + +Equivalent to Minetest's `image_button[]` element. + +**Example** +```lua +gui.ImageButton { + w = 1, -- Optional + h = 2, -- Optional + texture_name = "Hello world!", + name = "my_image_button", -- Optional + label = "Hello world!", + noclip = false, -- Optional + drawborder = false, -- Optional + pressed_texture_name = "Hello world!", -- Optional +} +``` + +### `gui.ImageButtonExit` + +Equivalent to Minetest's `image_button_exit[]` element. + +**Example** +```lua +gui.ImageButtonExit { + w = 1, -- Optional + h = 2, -- Optional + texture_name = "Hello world!", + name = "my_image_button_exit", -- Optional + label = "Hello world!", + noclip = false, -- Optional + drawborder = false, -- Optional + pressed_texture_name = "Hello world!", -- Optional +} +``` + +### `gui.ItemImage` + +Equivalent to Minetest's `item_image[]` element. + +**Example** +```lua +gui.ItemImage { + w = 1, -- Optional + h = 2, -- Optional + item_name = "Hello world!", +} +``` + +### `gui.ItemImageButton` + +Equivalent to Minetest's `item_image_button[]` element. + +**Example** +```lua +gui.ItemImageButton { + w = 1, -- Optional + h = 2, -- Optional + item_name = "Hello world!", + name = "my_item_image_button", -- Optional + label = "Hello world!", +} +``` + +### `gui.Label` + +Equivalent to Minetest's `label[]` element. + +**Example** +```lua +gui.Label { + label = "Hello world!", +} +``` + +### `gui.List` + +Equivalent to Minetest's `list[]` element. + +**Example** +```lua +gui.List { + inventory_location = "Hello world!", + list_name = "Hello world!", + w = 1, + h = 2, + starting_item_index = 3, -- Optional +} +``` + +### `gui.Model` + +Equivalent to Minetest's `model[]` element. + +**Example** +```lua +gui.Model { + w = 1, -- Optional + h = 2, -- Optional + name = "my_model", -- Optional + mesh = "Hello world!", + textures = "Hello world!", + rotation_x = 3, -- Optional + rotation_y = 4, -- Optional + continuous = false, -- Optional + mouse_control = false, -- Optional + frame_loop_begin = 5, -- Optional + frame_loop_end = 6, -- Optional + animation_speed = 7, -- Optional +} +``` + +### `gui.Pwdfield` + +Equivalent to Minetest's `pwdfield[]` element. + +**Example** +```lua +gui.Pwdfield { + w = 1, -- Optional + h = 2, -- Optional + name = "my_pwdfield", -- Optional + label = "Hello world!", +} +``` + +### `gui.ScrollContainer` + +Equivalent to Minetest's `scroll_container[]` element. + +**Example** +```lua +gui.ScrollContainer { + w = 1, -- Optional + h = 2, -- Optional + scrollbar_name = "Hello world!", + orientation = "vertical", + scroll_factor = 3, -- Optional +} +``` + +### `gui.Scrollbar` + +Equivalent to Minetest's `scrollbar[]` element. + +**Example** +```lua +gui.Scrollbar { + w = 1, -- Optional + h = 2, -- Optional + orientation = "vertical", + name = "my_scrollbar", -- Optional + value = 3, +} +``` + +### `gui.Tabheader` + +Equivalent to Minetest's `tabheader[]` element. + +**Example** +```lua +gui.Tabheader { + h = 1, -- Optional + name = "my_tabheader", -- Optional + captions = "Hello world!", + current_tab = "Hello world!", + transparent = false, -- Optional + draw_border = false, -- Optional + w = 2, -- Optional +} +``` + +### `gui.Table` + +Equivalent to Minetest's `table[]` element. + +**Example** +```lua +gui.Table { + w = 1, -- Optional + h = 2, -- Optional + name = "my_table", -- Optional + cells = "Hello world!", + selected_idx = 3, +} +``` + +### `gui.Textarea` + +Equivalent to Minetest's `textarea[]` element. + +**Example** +```lua +gui.Textarea { + w = 1, -- Optional + h = 2, -- Optional + name = "my_textarea", -- Optional + label = "Hello world!", + default = "Hello world!", +} +``` + +### `gui.Textlist` + +Equivalent to Minetest's `textlist[]` element. + +**Example** +```lua +gui.Textlist { + w = 1, -- Optional + h = 2, -- Optional + name = "my_textlist", -- Optional + listelems = "Hello world!", + selected_idx = 3, -- Optional + transparent = false, -- Optional +} +``` + +### `gui.Tooltip` + +Equivalent to Minetest's `tooltip[]` element. + +**Example** +```lua +gui.Tooltip { + w = 1, -- Optional + h = 2, -- Optional + tooltip_text = "Hello world!", + bgcolor = "#FF0000", -- Optional + fontcolor = "#FF0000", -- Optional + gui_element_name = "Hello world!", -- Optional +} +``` + +### `gui.Vertlabel` + +Equivalent to Minetest's `vertlabel[]` element. + +**Example** +```lua +gui.Vertlabel { + label = "Hello world!", +} +``` \ No newline at end of file diff --git a/example.lua b/example.lua new file mode 100644 index 0000000..b67db5f --- /dev/null +++ b/example.lua @@ -0,0 +1,208 @@ +-- Debugging +local gui = flow.widgets + +local elements = {"box", "label", "image", "field", "checkbox", "list"} +local alignments = {"auto", "start", "end", "centre", "fill"} + +local my_gui = flow.make_gui(function(player, ctx) + local hbox = { + min_h = 2, + } + + local elem_type = elements[ctx.form.element] or "box" + + -- Setting a width/height on labels, fields, or checkboxes can break things + local w, h + if elem_type ~= "label" and elem_type ~= "field" and + elem_type ~= "checkbox" then + w, h = 1, 1 + end + + hbox[#hbox + 1] = { + type = elem_type, + w = w, + h = h, + label = "Label", + color = "#fff", + texture_name = "air.png", + + expand = ctx.form.expand, + align_h = alignments[ctx.form.align_h], + align_v = alignments[ctx.form.align_v], + name = "testing", + + inventory_location = "current_player", + list_name = "main", + } + + if ctx.form.box2 then + hbox[#hbox + 1] = gui.Box{ + w = 1, + h = 1, + color = "#888", + expand = ctx.form.expand_box2, + } + end + + local try_it_yourself_box + if ctx.form.vbox then + try_it_yourself_box = gui.VBox(hbox) + else + try_it_yourself_box = gui.HBox(hbox) + end + + return gui.VBox{ + -- Optionally specify a minimum size for the form + min_w = 8, + min_h = 9, + + gui.HBox{ + gui.Image{w = 1, h = 1, texture_name = "air.png"}, + gui.Label{label = "Hello world!"}, + }, + gui.Label{label="This is an example form."}, + gui.Checkbox{ + name = "checkbox", + + -- flow will detect that you have accessed ctx.form.checkbox and + -- will automatically redraw the formspec if the value is changed. + label = ctx.form.checkbox and "Uncheck me!" or "Check me!", + }, + gui.Button{ + -- Names are optional + label = "Toggle checkbox", + + -- Important: Do not use the `player` and `ctx` variables from the + -- above formspec. + on_event = function(player, ctx) + -- Invert the value of the checkbox + ctx.form.checkbox = not ctx.form.checkbox + + -- Send a chat message + minetest.chat_send_player(player:get_player_name(), "Toggled!") + + -- Return true to tell flow to redraw the formspec + return true + end, + }, + + gui.Label{label="A demonstration of expansion:"}, + + -- The finer details of scroll containers are handled automatically. + -- Clients that don't support scroll_container[] will see a paginator + -- instead. + gui.ScrollableVBox{ + -- A name must be provided for ScrollableVBox elements. You don't + -- have to use this name anywhere else, it just makes sure flow + -- doesn't mix up scrollbar states if one gets removed or if the + -- order changes. + name = "vbox1", + + gui.Label{label="By default, objects do not expand\nin the " .. + "same direction as the hbox/vbox:"}, + gui.HBox{ + gui.Box{ + w = 1, + h = 1, + color = "#fff", + }, + }, + + gui.Label{label="Items are expanded in the opposite\ndirection," .. + " however:"}, + gui.HBox{ + min_h = 2, + gui.Box{ + w = 1, + h = 1, + color = "#fff", + }, + }, + + gui.Label{label="To automatically expand an object, add\n" .. + "`expand = true` to its definition."}, + gui.HBox{ + gui.Box{ + w = 1, + h = 1, + color = "#fff", + expand = true, + }, + }, + + gui.Label{label="Multiple expanded items will share the\n" .. + "remaining space evenly."}, + + gui.HBox{ + gui.Box{ + w = 1, + h = 1, + color = "#fff", + expand = true + }, + gui.Box{ + w = 1, + h = 1, + color = "#fff", + expand = true + }, + }, + + gui.HBox{ + gui.Box{ + w = 1, + h = 1, + color = "#fff", + expand = true + }, + gui.Box{ + w = 3, + h = 1, + color = "#fff", + expand = true + }, + }, + }, + + gui.Label{label="Try it yourself!"}, + gui.HBox{ + gui.VBox{ + gui.Label{label="Element:"}, + gui.Dropdown{ + name = "element", + items = elements, + index_event = true, + } + }, + gui.VBox{ + gui.Label{label="align_h:"}, + gui.Dropdown{ + name = "align_h", + items = {"auto (default)", "start / top / left", + "end / bottom / right", "centre / center", "fill"}, + index_event = true, + } + }, + gui.VBox{ + gui.Label{label="align_v:"}, + gui.Dropdown{ + name = "align_v", + items = {"auto (default)", "start / top / left", + "end / bottom / right", "centre / center", "fill"}, + index_event = true, + } + }, + }, + gui.HBox{ + gui.Checkbox{name = "expand", label = "Expand"}, + gui.Checkbox{name = "vbox", label = "Use vbox instead of hbox"}, + }, + gui.HBox{ + gui.Checkbox{name = "box2", label = "Second box"}, + gui.Checkbox{name = "expand_box2", label = "Expand second box"}, + }, + try_it_yourself_box, + } +end) + +return my_gui diff --git a/generate_docs.py b/generate_docs.py new file mode 100644 index 0000000..90cdc3a --- /dev/null +++ b/generate_docs.py @@ -0,0 +1,91 @@ +from ruamel.yaml import YAML +import collections, re, requests +yaml = YAML(typ='safe') + +def fetch_elements(): + res = requests.get('https://github.com/luk3yx/minetest-formspec_ast/raw/' + 'master/elements.yaml') + return yaml.load(res.text) + + +def search_for_fields(obj): + assert isinstance(obj, (list, tuple)) + if len(obj) == 2: + if obj[1] == '...': + yield from search_for_fields(obj[0]) + return + if isinstance(obj[0], str) and isinstance(obj[1], str): + yield tuple(obj) + return + + for e in obj: + yield from search_for_fields(e) + + +def element_to_docs(element_name, variants): + flow_name = re.sub(r'_(.)', lambda m: m.group(1).upper(), + element_name.capitalize()) + + res = [ + f'### `gui.{flow_name}`\n', + f"Equivalent to Minetest's `{element_name}[]` element.\n", + '**Example**', + '```lua', + f'gui.{flow_name} {{' + ] + + fields = collections.Counter(search_for_fields(variants)) + if (('x', 'number') not in fields or + all(field_name in ('x', 'y') for field_name, _ in fields)): + return '' + + num = 1 + + for (field_name, field_type), count in fields.items(): + if field_name in ('x', 'y'): + continue + + if field_type == 'number': + value = num + num += 1 + elif field_type == 'string': + if field_name == 'name': + value = f'"my_{element_name}"' + elif field_name == 'orientation': + value = '"vertical"' + elif 'color' in field_name: + value = '"#FF0000"' + else: + value = '"Hello world!"' + elif field_type in ('boolean', 'fullscreen'): + value = 'false' + elif field_type == 'table': + value = '{field = "value"}' + else: + value = '' + + line = f' {field_name} = {value},' + if ((field_name in ('name', 'w', 'h') and element_name != 'list') or + count < len(variants)): + line = line + ' -- Optional' + res.append(line) + + res.append('}') + res.append('```') + + return '\n'.join(res) + + +if __name__ == '__main__': + print('Fetching data...') + elements = fetch_elements() + print('Done.') + + with open('elements.md', 'w') as f: + f.write('# Auto-generated elements list\n\n') + f.write('This is probably broken.') + for element_name, variants in elements.items(): + docs = element_to_docs(element_name, variants) + if docs: + f.write('\n\n') + f.write(docs) diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..2440bb4 --- /dev/null +++ b/init.lua @@ -0,0 +1,926 @@ +-- +-- Minetest formspec layout engine +-- +-- Copyright © 2022 by luk3yx +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Lesser General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Lesser General Public License for more details. + +-- You should have received a copy of the GNU Lesser General Public License +-- along with this program. If not, see . +-- + +local DEBUG_MODE = false +local hot_reload = (DEBUG_MODE and minetest.global_exists("flow") and + flow.hot_reload or {}) +flow = {} + + +local Form = {} + +local min, max = math.min, math.max + +local function strip_escape_sequences(str) + return (str:gsub("\27%([^)]+%)", ""):gsub("\27.", "")) +end + +local LABEL_HEIGHT = 0.4 +local LABEL_OFFSET = LABEL_HEIGHT / 2 +local CHARS_PER_UNIT = 4.8 -- 5 +local function get_lines_size(lines) + local w = 0 + for _, line in ipairs(lines) do + w = max(w, #strip_escape_sequences(line) / CHARS_PER_UNIT) + end + return w, LABEL_HEIGHT * #lines +end + +local function get_label_size(label) + return get_lines_size((label or ""):split("\n", true)) +end + +local size_getters = {} + +local function get_and_fill_in_sizes(node) + if node.type == "list" then + return node.w * 1.25 - 0.25, node.h * 1.25 - 0.25 + end + + if node.w and node.h then + return node.w, node.h + end + + local f = size_getters[node.type] + if not f then return 0, 0 end + + local w, h = f(node) + node.w = node.w or max(w, node.min_w or 0) + node.h = node.h or max(h, node.min_h or 0) + return node.w, node.h +end + +function size_getters.container(node) + local w, h = 0, 0 + for _, n in ipairs(node) do + local w2, h2 = get_and_fill_in_sizes(n) + w = max(w, (n.x or 0) + w2) + h = max(h, (n.y or 0) + h2) + end + return w, h +end +size_getters.scroll_container = size_getters.container + +function size_getters.label(node) + local w, h = get_label_size(node.label) + return w, LABEL_HEIGHT + (h - LABEL_HEIGHT) * 1.25 +end + +local MIN_BUTTON_HEIGHT = 0.8 +function size_getters.button(node) + local x, y = get_label_size(node.label) + return max(x, MIN_BUTTON_HEIGHT * 2), max(y, MIN_BUTTON_HEIGHT) +end + +size_getters.button_exit = size_getters.button +size_getters.image_button = size_getters.button +size_getters.image_button_exit = size_getters.button +size_getters.item_image_button = size_getters.button + +function size_getters.field(node) + local label_w, label_h = get_label_size(node.label) + if not node._padding_top and node.label and #node.label > 0 then + node._padding_top = label_h + end + + local w, h = get_label_size(node.default) + return max(w, label_w, 3), max(h, MIN_BUTTON_HEIGHT) +end +size_getters.pwdfield = size_getters.field +size_getters.textarea = size_getters.field + +function size_getters.vertlabel(node) + return 1 / CHARS_PER_UNIT, #node.label * LABEL_HEIGHT +end + +function size_getters.textlist(node) + local w, h = get_lines_size(node.listelems) + return w, h * 1.1 +end + +function size_getters.dropdown(node) + return max(get_lines_size(node.items) + 0.3, 2), MIN_BUTTON_HEIGHT +end + +function size_getters.checkbox(node) + local w, h = get_label_size(node.label) + return w + 0.4, h +end + +local function apply_padding(node, x, y, extra_padding) + local w, h = get_and_fill_in_sizes(node) + + if extra_padding then + w = w + extra_padding + h = h + extra_padding + end + + if node.type == "label" or node.type == "checkbox" then + y = y + LABEL_OFFSET + end + + if node._padding_top then + y = y + node._padding_top + h = h + node._padding_top + end + + if node.padding then + x = x + node.padding + y = y + node.padding + w = w + node.padding * 2 + h = h + node.padding * 2 + end + + node.x, node.y = x, y + return w, h +end + +local invisible_elems = { + style = true, listring = true, scrollbaroptions = true, tableoptions = true, + tablecolumns = true, +} + +local DEFAULT_SPACING = 0.2 +function size_getters.vbox(vbox) + local spacing = vbox.spacing or DEFAULT_SPACING + local width = 0 + local y = 0 + for _, node in ipairs(vbox) do + if not invisible_elems[node.type] then + if y > 0 then + y = y + spacing + end + + local w, h = apply_padding(node, 0, y) + width = max(width, w) + y = y + h + end + end + + return width, y +end + +function size_getters.hbox(hbox) + local spacing = hbox.spacing or DEFAULT_SPACING + local x = 0 + local height = 0 + for _, node in ipairs(hbox) do + if not invisible_elems[node.type] then + if x > 0 then + x = x + spacing + end + + local w, h = apply_padding(node, x, 0) + height = max(height, h) + x = x + w + end + end + + -- Special cases + for _, node in ipairs(hbox) do + if node.type == "checkbox" then + node.y = height / 2 + end + end + + return x, height +end + +function size_getters.padding(node) + assert(#node == 1, "Padding can only have one element inside.") + local n = node[1] + local x, y = apply_padding(n, 0, 0) + if node.expand == nil then + node.expand = n.expand + end + return x, y +end + +local align_types = {} + +function align_types.fill(node, x, w, extra_space) + -- Special cases + if node.type == "list" or node.type == "checkbox" then + return align_types.centre(node, x, w, extra_space) + elseif node.type == "label" then + if x == "y" then + node.y = node.y + extra_space / 2 + return + end + + -- Hack + node.type = "container" + node[1] = { + type = "image_button", + texture_name = "blank.png", + drawborder = false, + x = 0, y = 0, + w = node.w + extra_space, h = node.h, + label = node.label, + } + + -- Overlay button to prevent clicks from doing anything + node[2] = { + type = "image_button", + texture_name = "blank.png", + drawborder = false, + x = 0, y = 0, + w = node.w + extra_space, h = node.h, + label = "", + } + + node.y = node.y - LABEL_OFFSET + node.label = nil + assert(#node == 2) + end + node[w] = node[w] + extra_space +end + +function align_types.start() + -- No alterations required +end + +-- "end" is a Lua keyword +align_types["end"] = function(node, x, _, extra_space) + node[x] = node[x] + extra_space +end + +-- Aliases for convenience +align_types.top, align_types.bottom = align_types.start, align_types["end"] +align_types.left, align_types.right = align_types.start, align_types["end"] + +function align_types.centre(node, x, w, extra_space) + if node.type == "label" then + return align_types.fill(node, x, w, extra_space) + elseif node.type == "checkbox" and x == "y" then + node.y = (node.h + extra_space) / 2 + return + end + node[x] = node[x] + extra_space / 2 +end + +align_types.center = align_types.centre + +-- Try to guess at what the best expansion setting is +local auto_align_centre = { + image = true, animated_image = true, model = true, item_image_button = true +} +function align_types.auto(node, x, w, extra_space, cross) + if auto_align_centre[node.type] then + return align_types.centre(node, x, w, extra_space) + end + + if x == "y" or (node.type ~= "label" and node.type ~= "checkbox") or + (node.expand and not cross) then + return align_types.fill(node, x, w, extra_space) + end +end + +local function expand(box) + local x, w, align_h, y, h, align_v + if box.type == "hbox" then + x, w, align_h, y, h, align_v = "x", "w", "align_h", "y", "h", "align_v" + elseif box.type == "vbox" then + x, w, align_h, y, h, align_v = "y", "h", "align_v", "x", "w", "align_h" + elseif box.type == "padding" then + box.type = "container" + local node = box[1] + if node.expand then + align_types[node.align_h or "auto"](node, "x", "w", box.w - + node.w - ((node.padding or 0) + (box.padding or 0)) * 2) + align_types[node.align_v or "auto"](node, "y", "h", box.h - + node.h - ((node.padding or 0) + (box.padding or 0)) * 2 - + (node._padding_top or 0) - (box._padding_top or 0)) + end + return expand(node) + elseif box.type == "container" or box.type == "scroll_container" then + for _, node in ipairs(box) do + if node.x == 0 and node.expand and box.w then + node.w = box.w + end + expand(node) + end + return + else + return + end + + box.type = "container" + + -- Calculate the amount of free space and put expand nodes into a table + local box_h = box[h] + local free_space = box[w] + local expandable = {} + local expand_count = 0 + for i, node in ipairs(box) do + local width, height = node[w] or 0, node[h] or 0 + if width > 0 and height > 0 then + if i > 1 then + free_space = free_space - (box.spacing or DEFAULT_SPACING) + end + if node.type == "list" then + width = width * 1.25 - 0.25 + height = height * 1.25 - 0.25 + end + free_space = free_space - width + + if node.expand then + expandable[node] = i + expand_count = expand_count + 1 + end + + -- Nodes are expanded in the other direction no matter what their + -- expand setting is + if box_h > height and height > 0 then + align_types[node[align_v] or "auto"](node, y, h, + box_h - height - (node.padding or 0) * 2 - + (y == "y" and node._padding_top or 0), true) + end + end + end + + -- If there's any free space then expand the nodes to fit + if free_space > 0 then + local extra_space = free_space / expand_count + for node, node_idx in pairs(expandable) do + align_types[node[align_h] or "auto"](node, x, w, + extra_space - (node.padding or 0) * 2) + + -- Shift other elements along + for j = node_idx + 1, #box do + if box[j][x] then + box[j][x] = box[j][x] + extra_space + end + end + end + elseif align_h == "align_h" then + -- Use the image_button hack on labels regardless of the amount of free + -- space if this is in a horizontal box. + for node in pairs(expandable) do + if node.type == "label" then + local align = node.algin_h or "auto" + if align == "centre" or align == "center" or align == "fill" or + (align == "auto" and node.expand) then + align_types.fill(node, "x", "w", 0) + end + end + end + end + + -- Recursively expand + for _, node in ipairs(box) do + expand(node) + end +end + +-- Renders the GUI into hopefully valid AST +-- This won't fill in names +local function render_ast(node) + local t1 = minetest.get_us_time() + local w, h = apply_padding(node, 0.3, 0.3, 0.6, 0.6) + local t2 = minetest.get_us_time() + expand(node) + local t3 = minetest.get_us_time() + local res = { + formspec_version = 5, + {type = "size", w = w, h = h}, + } + for field in formspec_ast.find(node, 'field') do + res[#res + 1] = { + type = 'field_close_on_enter', + name = field.name, + close_on_enter = false, + } + end + res[#res + 1] = node + local t4 = minetest.get_us_time() + print('apply_padding', t2 - t1) + print('expand', t3 - t2) + print('field_close_on_enter', t4 - t3) + return res +end + +-- Try and create short (2 byte) names +local function get_identifier(i) + if i > 127 then + -- Give up and use long (but unique) names + return '\1\1' .. tostring(i) + end + return string.char(1, i) +end + +local function chain_cb(f1, f2) + return function(...) + f1(...) + f2(...) + end +end + +local field_value_transformers = { + tabheader = tonumber, + dropdown = tonumber, + checkbox = minetest.is_yes, + table = function(value) + return minetest.explode_table_event(value).row + end, + textlist = function(value) + return minetest.explode_textlist_event(value).index + end, + scrollbar = function(value) + return minetest.explode_scrollbar_event(value).value + end, +} + +local function default_field_value_transformer(value) + return value +end + +local default_value_fields = { + field = "default", + textarea = "default", + checkbox = "selected", + dropdown = "selected_idx", + table = "selected_idx", + textlist = "selected_idx", + scrollbar = "value", + tabheader = "current_tab", +} + + +local sensible_defaults = { + default = "", selected = false, selected_idx = 1, value = 1, +} + +-- Removes on_event from a formspec_ast tree and returns a callbacks table +local function parse_callbacks(tree, ctx_form) + local i = 0 + local callbacks = {} + local saved_fields = {} + local seen_scroll_container = false + for node in formspec_ast.walk(tree) do + if node.type == "container" then + if node.bgcolor then + table.insert(node, 1, { + type = "box", color = node.bgcolor, + x = 0, y = 0, w = node.w, h = node.h, + }) + end + if node.bgimg then + table.insert(node, 1, { + type = "background", texture_name = node.bgimg, + x = 0, y = 0, w = node.w, h = node.h, + }) + end + if node.on_quit then + if callbacks.quit then + -- HACK + callbacks.quit = chain_cb(callbacks.quit, node.on_quit) + else + callbacks.quit = node.on_quit + end + end + elseif seen_scroll_container then + -- Work around a Minetest bug with scroll containers not scrolling + -- backgrounds. + if node.type == "background" and not node.auto_clip then + node.type = "image" + end + elseif node.type == "scroll_container" then + seen_scroll_container = true + end + + local node_name = node.name + if node_name then + local value_field = default_value_fields[node.type] + if value_field then + -- Add the corresponding value transformer transformer to + -- saved_fields + saved_fields[node_name] = ( + field_value_transformers[node.type] or + default_field_value_transformer + ) + + -- Update ctx.form if there is no current value, otherwise + -- change the node's value to the saved one. + local value = ctx_form[node_name] + if node.type == "dropdown" and not node.index_event then + -- Special case for dropdowns without index_event + if node.items then + if value == nil then + ctx_form[node_name] = node.items[ + node.selected_idx or 1 + ] + else + local idx = table.indexof(node.items, value) + if idx > 0 then + node.selected_idx = idx + end + end + end + + saved_fields[node_name] = default_field_value_transformer + elseif value == nil then + ctx_form[node_name] = node[value_field] or + sensible_defaults[value_field] + else + node[value_field] = value or sensible_defaults[value_field] + end + end + end + + if node.on_event then + if not node_name then + i = i + 1 + node_name = get_identifier(i) + node.name = node_name + end + + callbacks[node_name] = node.on_event + node.on_event = nil + end + end + return callbacks, saved_fields +end + +local gui = setmetatable({ + embed = function(fs, w, h) + if type(fs) ~= "table" then + fs = formspec_ast.parse(fs) + end + fs.type = "container" + fs.w = w + fs.h = h + return fs + end, + formspec_version = 0, +}, { + __index = function(gui, k) + local elem_type = k + if elem_type ~= "ScrollbarOptions" and elem_type ~= "TableOptions" and + elem_type ~= "TableColumns" then + elem_type = elem_type:gsub("([a-z])([A-Z])", function(a, b) + return a .. "_" .. b + end) + end + elem_type = elem_type:lower() + local function f(t) + t.type = elem_type + return t + end + rawset(gui, k, f) + return f + end, + __newindex = function() + error("Cannot modifiy gui table") + end +}) +flow.widgets = gui + +local current_ctx +function flow.get_context() + if not current_ctx then + error("get_context() was called outside of a GUI function!", 2) + end + return current_ctx +end + + +-- Renders a GUI into a formspec_ast tree and a table with callbacks. +function Form:_render(player, ctx, formspec_version) + local used_ctx_vars = {} + + -- Wrap ctx.form + local orig_form = ctx.form or {} + local wrapped_form = setmetatable({}, { + __index = function(_, key) + used_ctx_vars[key] = true + return orig_form[key] + end, + __newindex = function(_, key, value) + orig_form[key] = value + end, + }) + ctx.form = wrapped_form + + gui.formspec_version = formspec_version or 0 + current_ctx = ctx + local box = self._build(player, ctx) + current_ctx = nil + gui.formspec_version = 0 + + -- Restore the original ctx.form + assert(ctx.form == wrapped_form, + "Changing the value of ctx.form is not supported!") + ctx.form = orig_form + + local tree = render_ast(box) + local callbacks, saved_fields = parse_callbacks(tree, orig_form) + + local redraw_if_changed = {} + for var in pairs(used_ctx_vars) do + -- Only add it if there is no callback and the name exists in the + -- formspec. + if saved_fields[var] and not callbacks[var] then + redraw_if_changed[var] = true + end + end + + return tree, { + self = self, + formname = self._formname, + callbacks = callbacks, + saved_fields = saved_fields, + redraw_if_changed = redraw_if_changed, + ctx = ctx, + } +end + +local open_formspecs = {} +function Form:show(player, ctx) + if type(player) == "string" then + player = minetest.get_player_by_name(player) + if not player then return end + end + + local t = minetest.get_us_time() + ctx = ctx or {} + + local name = player:get_player_name() + local info = minetest.get_player_information(name) + local tree, form_info = self:_render(player, ctx, + info and info.formspec_version) + + local t2 = minetest.get_us_time() + local fs = assert(formspec_ast.unparse(tree)) + local t3 = minetest.get_us_time() + + open_formspecs[name] = form_info + print(t3 - t, t2 - t, t3 - t2) + minetest.show_formspec(name, self._formname, fs) +end + +function Form:show_hud(player, ctx) + local tree = self:_render(player, ctx or {}) + hud_fs.show_hud(player, self._formname, tree) +end + +function Form:close(player) + minetest.close_formspec(player:get_player_name(), self._formname) +end + +function Form:close_hud(player) + hud_fs.close_hud(player, self._formname) +end + +local used_ids = {} +setmetatable(used_ids, {__mode = "v"}) + +local formname_prefix = minetest and minetest.get_current_modname() or "" .. ":" + +local form_mt = {__index = Form} +function flow.make_gui(build_func) + local res = setmetatable({}, form_mt) + + -- Reserve a formname + local id = #used_ids + 1 + used_ids[id] = gui + + res._formname = formname_prefix .. get_identifier(id) + res._build = build_func + + return res +end + +local function on_fs_input(player, formname, fields) + local name = player:get_player_name() + local form_info = open_formspecs[name] + if not form_info then return end + + if formname ~= form_info.formname then return end + + local callbacks = form_info.callbacks + local ctx = form_info.ctx + local redraw_if_changed = form_info.redraw_if_changed + local ctx_form = ctx.form + + -- Update the context before calling any callbacks + local redraw_fs = false + for field, transformer in pairs(form_info.saved_fields) do + if fields[field] then + local new_value = transformer(fields[field]) + if redraw_if_changed[field] and ctx_form[field] ~= new_value then + print('Modified:', dump(field), dump(ctx_form[field]), '->', + dump(new_value)) + redraw_fs = true + end + ctx_form[field] = new_value + end + end + + -- Some callbacks may be false to indicate that they're valid fields but + -- don't need to be called + for field, value in pairs(fields) do + if callbacks[field] and callbacks[field](player, ctx, value) then + redraw_fs = true + end + end + + if open_formspecs[name] ~= form_info then return end + + if fields.quit then + open_formspecs[name] = nil + elseif redraw_fs then + form_info.self:show(player, ctx) + end +end + +local function on_leaveplayer(player) + open_formspecs[player:get_player_name()] = nil +end + +if DEBUG_MODE then + flow.hot_reload = {on_fs_input, on_leaveplayer} + if not hot_reload[1] then + minetest.register_on_player_receive_fields(function(...) + return flow.hot_reload[1](...) + end) + end + if not hot_reload[2] then + minetest.register_on_leaveplayer(function(...) + return flow.hot_reload[2](...) + end) + end +else + minetest.register_on_player_receive_fields(on_fs_input) + minetest.register_on_leaveplayer(on_leaveplayer) +end + +-- Extra GUI elements + +-- Please don't use rawset(gui, ...) in your own code +rawset(gui, "PaginatedVBox", function(def) + local w, h = def.w, def.h + def.w, def.h = nil, nil + local paginator_name = "_paginator-" .. assert(def.name) + + def.type = "vbox" + local inner_w, inner_h = get_and_fill_in_sizes(def) + h = h or min(inner_h, 5) + + local ctx = flow.get_context() + + -- Build a list of pages + local page = {} + local pages = {page} + local max_y = h + for _, node in ipairs(def) do + if node.y and node.y + (node.h or 0) > max_y then + -- Something overflowed, go to a new page + page = {} + pages[#pages + 1] = page + max_y = node.y + h + end + + -- Add to the current page + node.x, node.y = nil, nil + page[#page + 1] = node + end + + -- Get the current page + local current_page = ctx.form[paginator_name] or 1 + if current_page > #pages then + current_page = #pages + ctx.form[paginator_name] = current_page + end + + page = pages[current_page] or {} + page.h = h + + return gui.VBox { + min_w = w or inner_w, + gui.VBox(page), + gui.HBox { + gui.Button { + label = "<", + on_event = function(_, ctx) + ctx.form[paginator_name] = max(current_page - 1, 1) + return true + end, + }, + gui.Label { + label = "Page " .. current_page .. " of " .. #pages, + align_h = "centre", + expand = true, + }, + gui.Button { + label = ">", + on_event = function(_, ctx) + ctx.form[paginator_name] = current_page + 1 + return true + end, + }, + } + } +end) + +rawset(gui, "ScrollableVBox", function(def) + -- On older clients fall back to a paginated vbox + if gui.formspec_version < 4 then + return gui.PaginatedVBox(def) + end + + local w, h = def.w, def.h + local scrollbar_name = "_scrollbar-" .. assert( + def.name, "Please provide a name for all ScrollableVBox elements!" + ) + + def.type = "vbox" + def.x, def.y = 0, 0 + def.w, def.h = nil, nil + local inner_w, inner_h = get_and_fill_in_sizes(def) + def.w = w or inner_w + def.expand = true + h = h or min(inner_h, 5) + + return gui.HBox { + { + type = "scroll_container", + expand = true, + w = w or inner_w, + h = h, + scrollbar_name = scrollbar_name, + orientation = "vertical", + def, + }, + gui.ScrollbarOptions{opts = {max = max(inner_h - h + 0.05, 0) * 10}}, + gui.Scrollbar{ + w = 0.5, h = 0.5, + orientation = "vertical", + name = scrollbar_name, + } + } +end) + +rawset(gui, "Flow", function(def) + local vbox = { + type = "vbox", + bgcolor = def.bgcolor, + bgimg = def.bgimg, + align_h = "centre", + align_v = "centre", + } + local width = assert(def.w) + + local spacing = def.spacing or DEFAULT_SPACING + local line = {spacing = spacing} + for _, node in ipairs(def) do + local w = get_and_fill_in_sizes(node) + if w > width then + width = def.w + vbox[#vbox + 1] = gui.HBox(line) + line = {spacing = spacing} + end + line[#line + 1] = node + width = width - w - spacing + end + vbox[#vbox + 1] = gui.HBox(line) + return vbox +end) + +local modpath = minetest.get_modpath("flow") +local example_form +minetest.register_chatcommand("flow-example", { + privs = {server = true}, + help = "Shows an example formspec", + func = function(name) + -- Only load example.lua when it's needed + if not example_form then + example_form = dofile(modpath .. "/example.lua") + end + example_form:show(name) + end, +}) + +if DEBUG_MODE then + local f, err = loadfile(modpath .. "/test-fs.lua") + if not f then + minetest.log("error", "[flow] " .. tostring(err)) + end + return f() +end diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..c83e0b9 --- /dev/null +++ b/mod.conf @@ -0,0 +1,3 @@ +name = flow +depends = formspec_ast +optional_depends = fs51, hud_fs