Initial commit

master
luk3yx 2022-07-15 12:38:09 +12:00
commit 4c7996c915
9 changed files with 1982 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
test*.lua
*.old

18
.luacheckrc Normal file
View File

@ -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"}

157
LICENSE.md Normal file
View File

@ -0,0 +1,157 @@
### GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
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.

159
README.md Normal file
View File

@ -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.

418
elements.md Normal file
View File

@ -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!",
}
```

208
example.lua Normal file
View File

@ -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

91
generate_docs.py Normal file
View File

@ -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)

926
init.lua Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
--
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

3
mod.conf Normal file
View File

@ -0,0 +1,3 @@
name = flow
depends = formspec_ast
optional_depends = fs51, hud_fs