commit 1040034efa4320bdc501abd08c3283628191268b Author: Signal Date: Fri Jan 23 19:06:33 2026 -0500 Initial commit. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..1625c17 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8afdf10 --- /dev/null +++ b/README.md @@ -0,0 +1,377 @@ +# What is this? + +Imfs is a reactive, immediate-mode functional abstration over Minetest's often cumbersome string-based formspec format. +What this means is that: +* An interface (or "formspec") is represented by a builder function, which creates and returns an imfs element tree. +* Every time state changes, the builder function is called again to create a new tree reflecting the new state. +* Elements and containers are represented as linear function calls. You don't have to care about child management or tree manipulation; imfs does that internally. +* You don't have to manually interpolate variables. +* You don't have to remember to update the UI every time you make a state change. By wrapping data in states, you can make a fully reactive UI contained almost entirely in one builder function. +* You don't have to care about element names and event handling. With imfs, event handlers can be registered inline, while the library does the rest. + +# Concepts + +### Builder functions + +A builder function is a function that creates and returns an element tree to be rendered. There is thus only one requirement of such a function: it must call `imfs.begin(window_width, window_height)`, then return `imfs.end_()`. Elements may then be added between the calls to `start` and `end_`. The most important advantage of this approach is that it allows you to perform conditional rendering merely by using if statements, in addition to any other operations you may see fit. + +Builder functions are used by either `imfs.show` (to show an interface to a player) or `imfs.set_inventory` (to set the player's inventory). These functions are described in more detail below. + +You can pass a table as the last argument to an entry point like `imfs.show` to hold state external to the builder function; it will then be passed as an argument to the builder function during every rebuild. This way, you can declare the builder function itself in one place and use it in another without having to rely entirely on closure capture or global variables. + +Imfs does not strictly require using a builder function: if an interface is purely static and need never change, you can use a prebuilt element tree instead of a builder function, which avoids rebuilding the tree every time the interface is shown. + +### Elements + +Elements are created with function calls, like `imfs.box(0, 0, 2, 2, "#faa")`. This creates an element, adds it to the current container, and returns it. For simple elements like `label`, this is all that needs to happen; however, some elements, like `field`, support further configuring the element using chainable methods, like so: + +```lua +imfs.field(4, 2, 4, 0.75, "Test", str) + :multiline() + :onchange(function(value) + print(value) + end) +``` + +Notice that you can register an event handler with `:onchange`; in this case, the event handler will be called whenever a change in the field's value is detected. This makes event handling quite straightforward, since event responses are tied directly to the element that is expected to trigger them (rather than being crammed together in a single callback registration somewhere else). + +Because elements are just function calls, you can easily create your own widgets simply by creating a function that creates a predefined sequence of elements. For example: + +```lua +-- Adds four colored boxes using a single call. +local function quadbox(x, y, w, h) + imfs.box(x, y, w/2, h/2, "#faa") + imfs.box(x + w/2, y, w/2, h/2, "#afa") + imfs.box(x, y + h/2, w/2, h/2, "#aaf") + imfs.box(x + w/2, y + h/2, w/2, h/2, "#ffa") +end + +local function builder() + imfs.begin(12, 10) + + imfs.label(0.5, 0.5, "Quadbox:") + + quadbox(0.5, 1, 4, 4) + + return imfs.end_() +end + +``` + +More advanced widgets with chainable methods can be created using Lua classes (the implementations of the builtin elements typify this technique). + +### Containers + +Containers are, in essence, lists of elements. There are three types of containers: scroll containers, layouting managers, and the formspec itself. The top-level formspec container performs minimal layouting; it only evaluates percentage units to always be relative to its own size. The scroll container functions similarly, but wraps its children in a `scroll_container[]` element internally. + +Besides these basic containers, however, imfs also provides several layouting containers. These are: +* `imfs.group`: The same type of container as the root; the positions and percentage units of its children are relative to the position and size of the container respectively. +* `imfs.row`: A flex row. This automatically positions elements within it based on the provided gap and alignment. Child elements may specify their width as a grow ratio like `"1x"` to dynamically resize to fill any empty space in the row. Higher grow ratios will cause the element to take up a larger portion of the available space compared to other dynamically sized elements. +* `imfs.column`: The same as `imfs.row`, but in the vertical direction. + +To create a custom layouting container, the procedure is essentially as follows: +1. Create a Lua class for the container. +2. In the class constructor, call `imfs.container_start(container)` with the newly created container instance. +3. Create an end function that wraps `imfs.container_end()`. +4. In the class's `render(self)` method, `for _, child in ipairs(self) do child:render() end`. +5. Add custom layouting calculations to `render()`, and pass modifications to `child:render()`. + +(Be advised that certain elements, like labels, have no knowable size.) + +### State + +Being able to imperatively create a UI is nice, and already better than formspecs, but many UIs must dynamically update to reflect state. Doing this manually can become bothersome, which is why imfs is also reactive. This means that it can trigger a rebuild automatically whenever state changes, saving you the trouble of remembering to update the UI when you update a variable. To do this, you must set any of an element's properties to a state object. + +State is created by wrapping the initial value in an `imfs.state`, like so: `local counter = imfs.state(0)`. To read the state's value (and register it as a dependency of any active observers, like an imfs UI), call it like a function: `counter()`. To set the state's value (and notify any dependents), call it with the new value: `counter(3)`. + +Imfs also provides `imfs.derive`, which creates a derived state from a function based on the states accessed in the funtion. This way, you can perform operations on a state's value for display while still depending on the state from the element involved. It should be noted, however, that beause full rebuilds for every change are an intrinsic property of formspecs, there is not currently much benefit to having a certain element depend on a state rather than the formspec itself, so in most situations derived states will not really be useful. An exception is if you want to create side effects; since `imfs.derive` takes a function, you can do anything you like in that function, so it might sometimes be useful to have a function that is called whenever a dependent state's value is changed. (Indeed, this mechanism may prove more useful in non-UI applications than in actual imfs UI.) + +Each state object includes an `_old_val` property, which holds the value the state had before it was last changed. In addition, you can also use the `_val` property to access a state's value directly and bypass dependency resolution. + +If you want to use the state API for a system of your own, remember that: +* State objects store dependents in `_getters`. +* When set, a state notifies each entry in `_getters` by calling that object's `:update()` method. +* To collect state dependencies from a given function, 1) create a tracking table and `table.insert(imfs.state.observers, tracker)`, 2) call the function, and 3) `table.remove(imfs.state.observers)`. The tracking table will then be filled with all states that were accessed between the `table.insert` and `table.remove`. + +# Example code + +Create a mod with this code and run `/demo` to see the example in action. +Note particularly that if you join multiple users and have them all run `/demo`, any action by one will also update all the others' formspecs, as the state used here is global. + +```lua +local counter = imfs.state(0) +local str = imfs.state("") +local pressed = imfs.state(false) + +local function fs() + imfs.begin(12, 10) + + imfs.label(1, 2, "Counter: "..counter()) -- We don't need to use a derived state here because the window will detect this dependency. + imfs.label(1, 3, derive(function() return "Length: "..#str() end)) -- However, using a derived state will still work just fine. + + if pressed() then -- Reactive conditional rendering can be achieved using states. + imfs.label(1, 4, "Pressed!!") + end + + imfs.button(4, 0.5, 4, 0.75, "Increment") + :style { + bgcolor = "#aaf" + } + :style("hovered", { + bgcolor = "#faa" + }) + :style("pressed", { + bgcolor = "#afa" + }) + :onclick(function() + counter(counter() +1) + end) + + imfs.field(4, 2, 4, 0.75, "Test", str) + :onchange(function(value) + str(value) + end) + :onenter(function(value) + str(value) + pressed(true) + end) + + + imfs.scroll_container(4, 3, 8, 7) + :named("test") + + for i = 0, 10 do -- One benefit of functional style over table style is that you can use loops seamlessly. + imfs.label(0, i, i) + end + + imfs.row(0, 1, 8, 4) + + imfs.box(0, 0, "1x", 2, "#f88") -- The "1x" unit for the width marks this element as dynamically resizing. + imfs.box(0, 0, counter() *0.25, 2, "#8f8") + imfs.box(0, 0, 2, 2, "#88f") + + imfs.row_end() + + imfs.column(0, 3, 4, 8) + :align "center" -- Notice that the elements are centered vertically in the column. + + imfs.box(0, 0, 2, counter() *0.25, "#8f8") + imfs.box(0, 0, 2, 2, "#88f") + + imfs.column_end() + + imfs.scroll_container_end() + + return imfs.end_() +end + +minetest.register_chatcommand("demo", { + func = function(name) + imfs.show(name, fs) + end +}) +``` + +# API Reference + +## Functions + +### `imfs.export()` + +Export the imfs API to \_G, with "fs_" prefixes. This is mainly intended for custom, from-scratch games to make life a bit easier. + +### `imfs.begin(window_width, window_height)` + +Begin a root imfs container. This returns a `Window` object with the following methods: +* `Window:no_prepend()`: Disables formspec prepends (i.e. global theme) in this interface. +* `Window:position(x, y)`: Set the position of the formspec window. See `position[]` in the [Minetest API documentation](https://api.luanti.org/formspec/). +* `Window:anchor(x, y)`: Set the anchor point of the formspec window. See `anchor[]` in the [Minetest API documentation](https://api.luanti.org/formspec/). +* `Window:padding(x, y)`: Set the padding of the formspec window. See `padding[]` in the [Minetest API documentation](https://api.luanti.org/formspec/). +* `Window:modal([modal])`: Make this window modal (meaning that it cannot be directly closed by the user with Esc). If `modal` is false, there will be no effect (this can be useful for making modality state-dependent). +* `Window:onclose(fn)`: Registers `fn` to be called when the window closes. (This may be triggered when the target player leaves the game.) + +### `imfs.end_()` + +Ends the current window, and returns it. Note: The name ends with an underscore so as not to conflict with the Lua keyword (I couldn't think of a better word for ending the window than 'end'). + +### `imfs.show(player, builder, state)` + +Show the interface defined by the provided builder function to the given player. The generated context will remain active until the player closes the formspec or leaves the game. + +This returns a `Context` object, which is the the top-level dependent for all states used in the interface. Unlike in some other libraries, you don't really store state on the `Context` object (the builder function can't access it directly). Instead, you should store state as local variables in the function from which you call `show` (or somewhere else if they should be global) along with the builder function; that way, the builder function can capture the state specific to the action and the player that invoked the interface and only that. You can alternatively declare the builder function externally, then pass the new state objects in the `args` table, as it will be passed on to the builder function and will persist across rebuilds. This is arguably the better option since it avoids re-allocating the builder function every time it's first shown. + +You can pass a static imfs tree instead of a builder if you so desire. + +### `imfs.set_inventory(player, builder, state)` + +Set `player`'s inventory to the interface defined by `builder`. This context will persist indefinitely until it is manually removed or the player leaves the game. Otherwise, this behaves in the same way as `imfs.show`. + +### `imfs.clear_inventory(player)` + +Manually invalidate the imfs context associated with `player`'s inventory formspec. Doing this will _not_ clear the player's inventory formspec; it is intended for interoperability with mods that use raw formspecs for the player's inventory (e.g. if your main inventory uses imfs but can switch to a view from a mod that doesn't), so that imfs will not erroneously keep handling events for such formspecs. + +### `imfs.add_to_context(element)` + +Add this element to the current container. This can be used in the creation of custom elements if the element needs to render itself in its container (rather than merely being an alias for a longer series of elements). + +### `imfs.container_start()` + +Creates and returns a new container base table, adds it to the current container, then makes it the new context to which subsequent elements are added. This can be used to create custom containers. + +### `imfs.container_end()` + +End the current container and revert to its parent. This can be used to create custom containers. + + +## Elements + +Note that all elements have a `:render()` method that outputs their formspec representation, and accepts overrides for x, y, width, and height as extra arguments. This is used for creating custom layouting containers which interpret position and size in a non-absolute way. + +Many sized elements have a `:tooltip(text[, background_color[, text_color]])` method that creates a tooltip for the element. Note that for elements without a `name` attribute in the formspec format, this will be an area tooltip and will not be occluded by any overlaying elements. Since imfs doesn't track which elements occlude which, it is up to you to not add area tooltips to occluded elements. + +### `imfs.label(x, y, text)` + +Creates a label element at the given position with the given text. + +### `imfs.arealabel(x, y, width, height, text)` + +Similar to `imfs.label`, but the created label will line-wrap. By default, overflowing text will be clipped to the label's bounding box. + +Methods: +* `:scrollable()`: Causes overflowing text to make the label scrollable rather than being clipped. + +### `imfs.box(x, y, width, height, color)` + +Creates a colored rectangle. + +### `imfs.hypertext(x, y, width, height, hypertext)` + +Creates a hypertext element. Refer to the markup language reference in the [Minetest API documentation](https://api.luanti.org/formspec/) for what `hypertext` may consist of. + +Methods: +* `:onaction(fn)`: Registers `fn` to be called when an `` element is triggered in this element. + +### `imfs.style(name[, state], properties, [internal])` + +Creates a style element. Refer to the style reference in the [Minetest API documentation](https://api.luanti.org/formspec/) for which properties may be used and on which element types. + +If `internal` is true, this element will not be automatically added to the element tree. This is used to allow inline styles on elements, since the style element must come first and the target element's name is not known beforehand. + +### `imfs.tooltip(x, y, width, height, text[, background_color[, text_color]])` + +Creates a tooltip that will be shown when the user hovers over the target area. + +### `imfs.image(x, y, width, height, texture, [middle])` + +Creates an image element. If `middle` is provided, the image will be rendered 9-sliced with `middle` as the center tile. + +Methods: +`:animated(frames, duration = 50, start = 1)`: The image will be interpreted as a tile animation with the given properties. + +### `imfs.item_image(x, y, width, height, item)` + +Creates an item image element. + +### `imfs.model(x, y, width, height, mesh, textures)` + +Creates a model element. + +Methods: +* `:style([state, ]props)`: Applies the styling properties `props` to the model when in the `state` state. If omitted, `state` is `"default"`. +* `:rotation(rotation[, continuous])`: Set the rotation of the model in the view, and optionally whether it is continuous. +* `:mouse_control(state)`: Passing `false` will prevent the user from changing the model's rotation by clicking and dragging. +* `:animated(frames, speed)`: Sets an animation on the model defined by the given frame range and speed. + +### `imfs.button(x, y, width, height, label)` + +Creates a button element. + +Methods: +* `:style([state, ]props)`: Applies the styling properties `props` to the button when in the `state` state. If omitted, `state` is `"default"`. +* `:item_image(item)`: Convert this button to an item image button, representing the given item. +* `:image(image[, pressed_image])`: Convert this button to an image button, with the texture `image`. If specified, `pressed_image` will be the image shown by the button in pressed state. (Note that this can also be achieved using styles.) This will have no effect if `:item_image` is called on the same button. +* `:exit()`: This button will close the formspec when pressed. This will have no effect if `:item_image` or `:image` with a `pressed_image` is called on the same button. +* `:onclick(fn)`: Registers `fn` to be called when this button is pressed. + +### `imfs.list(x, y, width, height, location = "current_player", list = "main"[, start])` + +Creates an inventory element. + +`imfs.inventory` is an alias for this element. + +### `imfs.listring([location, list])` + +Inserts a `listring[]` element. Refer to the [Minetest API documentation](https://api.luanti.org/formspec/) for an explanation of listrings. + +### `imfs.field(x, y, width, height[, label], value)` + +Creates a field element. + +Methods: +* `:style([state, ]props)`: Applies the styling properties `props` to the field when in the `state` state. If omitted, `state` is `"default"`. +* `:onchange(fn)`: Registers `fn` to be called with the new value when the field's value changes. +* `:onenter(fn)`: Registers `fn` to be called with the field's value when Enter is pressed in the field. (This will never trigger if the field is multiline.) +* `:multiline()`: Makes this field multiline. `:close_on_enter()` will have no effect if this is present. +* `:close_on_enter()`: This field will close the formspec when Enter is pressed. (Note that the default in imfs is _not_ to close the formspec when Enter is pressed, contrary to ordinary formspecs.) +* `:password()`: Makes this field a password field. `:multiline()` will have no effect if this is present. + +### `imfs.textarea(x, y, width, height[, label], value)` + +An alias for `imfs.field(...):multiline()`. + +### `imfs.checkbox(x, y, label, checked)` + +Creates a checkbox element. + +Methods: +* `:onchange(fn)`: Registers `fn` to be called with the new value when the checkbox's value changes. + +### `imfs.scrollbar(x, y, width, height[, orientation = "vertial"], value)` + +Creates a scrollbar element. + +Methods: +* `:style([state, ]props)`: Applies the styling properties `props` to the scrollbar when in the `state` state. If omitted, `state` is `"default"`. (This isn't very useful right now, but it might be if scrollbars become themable.) +* `:options(optios)`: Set the scrollbar options to `options`. Refer to the [Minetest API documentation](https://api.luanti.org/formspec/) for which options are permitted. +* `:onchange(fn)`: Registers `fn` to be called with the action and value value of each scroll event for this scrollbar. + +### `imfs.scroll_container(x, y, width, height, orientation = "vertical"[, factor][, padding])` + +Begins a scroll container. If you wish to manually set up the scroll container's scrollbar, you must pass `""` for `padding`. Note that if `padding` is not provided, `factor` will serve as an alias to it. + +Methods: +* `:scrollbar(fn, ...)`: Use the scrollbar returned by `fn()` instead of a generated one. Alternatively, if `fn` is not a function, this will just generate a scrollbar with `imfs.scrollbar(fn, ...)`. +* `:named(name)`: Set the name of this scroll container. If you want to persist the container's scroll position during rebuilds without putting forth much effort into tracking state and managing the scrollbar, passing a static string here will do so. + +### `imfs.scroll_container_end()` + +Ends the current scroll container. + +### `imfs.group(x, y, width, height)` + +Creates a group container. Group containers exist solely to position all their children relative to themselves, rather than to the top-level formspec. Note that percentage units will also be relative to the group's size rather than the window's. + +### `imfs.group_end()` + +Ends the current group. + +### `imfs.row(x, y, width, height)` + +Creates a flex row container. Flex containers will automatically lay out their content according to the provided alignment and gap, ignoring user-provided position. Immediate children of flex containers may specify their width (or height) property as a grow ratio (e.g. "1x"), which will cause them to automatially resize along the container's axis to fill any empty space. If an element has a higher grow ratio than another, it will take up a greater proportion of the available space. + +Methods: +* `:align(alignment)`: Set the alignment of elements in this row. May be "left" (default), "right", or "center". +* `:gap(gap)`: Set the spacing between elements in this row. +* `:direction(direction)`: Set the direction in which this row will lay out its children. May be "row" (X axis, default) or "column" (Y axis). + +### `imfs.row_end()` + +Ends the current row. + +### `imfs.column(x, y, width, height)` + +An alias for `imfs.row(...):direction("column")`. + +### `imfs.column_end()` + +An alias for `imfs.row_end()`. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..8cad266 --- /dev/null +++ b/init.lua @@ -0,0 +1,1168 @@ + +local fe = minetest.formspec_escape +local hte = minetest.hypertext_escape + +local ctx + +local contexts = {} +local inventories = {} + +local theme = {} + +-- MARK: Helpers + +-- Detects percentage units andconverts them into an appropriate number (based on `ref`). +local function resolve_layout_units(value, ref) + if type(value) == "string" then + local num = tonumber(value) + if num then return num end + + local percent = value:match "(%d+)%%" + if ref and percent then + return tonumber(percent) /100 *ref + end + + error("Malformed layout units: "..value) + else + return value + end +end + +-- The same as resolve_layout_units, but allows (and notifies of) flex units. +local function resolve_flex_layout_units(value, ref) + if type(value) == "string" then + local num = tonumber(value) + if num then return num end + + local percent = value:match "(%d+)%%" + if ref and percent then + return tonumber(percent) /100 *ref + end + + local flex = value:match "(%d+)x" + if flex then + return tonumber(flex), "flex" + end + + error("Malformed layout units: "..value) + else + return value + end +end + +-- This should be a method on any element classes that support inline styling. +local function add_elem_style(e, state, props) + -- Allow omitting the state while keeping the properties the last argument. + if not props then + props = state + state = "default" + end + e._styles[state] = imfs.style(e.__id..":"..state, props, true) + return e +end + +-- This should be a method on any element classes that support tooltips. +local function add_elem_tooltip(e, text, bgcolor, txtcolor) + -- For named elements, prefer name-associated tooltips. + if e.__id then + imfs._named_tooltip(e.__id, text, bgcolor, txtcolor) + -- For other elements, if they have a size, use an area tooltip instead. + elseif e.w then + imfs.tooltip(e.x, e.y, e.w, e.h, text, bgcolor, txtcolor) + end + return e +end + +local function new_id() + return "_"..minetest.get_us_time().."_"..math.random(1, 100000) +end + +-- MARK: Data structures + +local observers = {} +local state = { + observers = observers, + get = function(e) + local observer = observers[#observers] + if observer then + observer[e] = true -- Add this base state as a dep (using table for set-like uniqueness) + end + return e._val + end, + set = function(e, val) + if val == nil then error "!" end + e._old_val = e._val + e._val = val + for _, x in pairs(e._getters) do + x:update() + end + end, + __call = function(e, val) + if val == nil then -- Getter + return e:get() + else -- Setter + return e:set(val) + end + end +} +state.__index = state +setmetatable(state, { + __call = function(_, val) + local e = {_getters = {}, _val = val} + setmetatable(e, state) + + return e + end +}) + +local DerivedState = { + get = function(e) + if e._stale then + e:update() + end + return e._val + end, + update = function(e) + for dep in pairs(e._deps) do + dep._getters[e.__id] = nil + e._deps[dep] = nil + end + + local tracker = e._deps + table.insert(observers, tracker) + + local val = e._fn() + + table.remove(observers) + + e._val = val + + for dep in pairs(e._deps) do + dep._getters[e.__id] = e + end + + -- Don't propagate updates on initial computation to avoid stack overflow. + if not e._stale then + for _, x in pairs(e._getters) do + x:update() + end + end + e._stale = false + end, + __call = function(e) + return e:get() + end +} +DerivedState.__index = DerivedState + +function derive(fn) + return setmetatable({_fn = fn, _stale = true, _getters = {}, _deps = {}, __id = minetest.get_us_time()}, DerivedState) +end + +local function get(e, x) + local item = e[x] + local mt = getmetatable(item) + if mt == state or mt == DerivedState then + return fe(item()) + end + return fe(item) +end + +-- MARK: Elements + +local fs_style = { + render = function(e) + local props = {} + for k, v in pairs(e.props) do + props[#props +1] = ";" + props[#props +1] = k + props[#props +1] = "=" + props[#props +1] = tostring(v) + end + return string.format("style[%s%s]", e.name, table.concat(props)) + end +} +fs_style.__index = fs_style +setmetatable(fs_style, { + __call = function(_, name, props, internal) + local e = {name = name, props = props} + setmetatable(e, fs_style) + if not internal then table.insert(ctx, e) end + return e + end +}) + +local fs_tooltip = { + render = function(e, x, y, w, h) + if e._name then + return string.format("tooltip[%s;%s;%s;%s]", e._name, get(e, "text"), get(e, "bgcolor"), get(e, "txtcolor")) + else + return string.format( + "tooltip[%f,%f;%f,%f;%s;%s;%s]", + x or get(e, "x"), y or get(e, "y"), + w or get(e, "w"), h or get(e, "h"), + get(e, "text"), + get(e, "bgcolor"), + get(e, "txtcolor") + ) + end + end +} +fs_tooltip.__index = fs_tooltip +setmetatable(fs_tooltip, { + __call = function(_, x, y, w, h, text, bgcolor, txtcolor) + local e = {x = x, y = y, w = w, h = h, text = text, bgcolor = bgcolor or "", txtcolor = txtcolor or ""} + setmetatable(e, fs_tooltip) + table.insert(ctx, e) + return e + end +}) + +local function fs_named_tooltip(name, text, bgcolor, txtcolor) + local e = {_name = name, text = text, bgcolor = bgcolor or "", txtcolor = txtcolor or ""} + setmetatable(e, fs_tooltip) + table.insert(ctx, e) + return e +end + +local fs_label = { + render = function(e) + return string.format("label[%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "txt")) + end +} +fs_label.__index = fs_label +setmetatable(fs_label, { + __call = function(_, x, y, txt) + local e = {x = x, y = y, txt = txt} + setmetatable(e, fs_label) + table.insert(ctx, e) + return e + end +}) + +local fs_arealabel = { + render = function(e) + if e._scrollable then + return string.format("textarea[%f,%f;%f,%f;;;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "txt")) + else + return string.format("label[%f,%f;%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "txt")) + end + end, + scrollable = function(e) + e._scrollable = true + return e + end, + tooltip = add_elem_tooltip, +} +fs_arealabel.__index = fs_arealabel +setmetatable(fs_arealabel, { + __call = function(_, x, y, w, h, txt) + local e = {x = x, y = y, w = w, h = h, txt = txt} + setmetatable(e, fs_arealabel) + table.insert(ctx, e) + return e + end +}) + +local fs_hypertext = { + render = function(e) + return string.format("hypertext[%f,%f;%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), hte(get(e, "txt"))) + end, + onaction = function(e, fn) + ctx._events.on_click[e.__id] = fn + return e + end, + tooltip = add_elem_tooltip, +} +fs_hypertext.__index = fs_hypertext +setmetatable(fs_hypertext, { + __call = function(_, x, y, w, h, txt) + local e = {x = x, y = y, w = w, h = h, txt = txt} + e.__id = "_"..minetest.get_us_time().."_"..math.random(1, 100000) + setmetatable(e, fs_hypertext) + table.insert(ctx, e) + return e + end +}) + +local fs_box = { + render = function(e, x, y, w, h) + return string.format("box[%f,%f;%f,%f;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "bg")) + end, + tooltip = add_elem_tooltip, +} +fs_box.__index = fs_box +setmetatable(fs_box, { + __call = function(_, x, y, w, h, bg) + local e = {x = x, y = y, w = w, h = h, bg = bg} + setmetatable(e, fs_box) + table.insert(ctx, e) + return e + end +}) + +local fs_image = { + render = function(e, x, y, w, h) + if e._anim_frames then + return string.format( + "animated_image[%f,%f;%f,%f;%s;%s;%f;%f;%f;%s]", + x or get(e, "x"), y or get(e, "y"), + w or get(e, "w"), h or get(e, "h"), + e.__id, + get(e, "texture"), + get(e, "_anim_frames"), + get(e, "_anim_duration"), + get(e, "_anim_start"), + get(e, "middle") + ) + else + return string.format("image[%f,%f;%f,%f;%s;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "texture"), get(e, "middle")) + end + end, + animated = function(e, frames, duration, start) + -- We only need an ID if the image is animated and must persist its state. + e.__id = e.__id or new_id() + e._anim_frames = frames + e._anim_duration = duration or 50 + e._anim_start = start or 1 + return e + end, + tooltip = add_elem_tooltip, +} +fs_image.__index = fs_image +setmetatable(fs_image, { + __call = function(_, x, y, w, h, texture, middle) + local e = {x = x, y = y, w = w, h = h, texture = texture, middle = middle or ""} + setmetatable(e, fs_image) + table.insert(ctx, e) + return e + end +}) + +local fs_item_image = { + render = function(e, x, y, w, h) + return string.format("item_image[%f,%f;%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "item")) + end, + tooltip = add_elem_tooltip, +} +fs_item_image.__index = fs_item_image +setmetatable(fs_item_image, { + __call = function(_, x, y, w, h, item) + local e = {x = x, y = y, w = w, h = h, item = item} + setmetatable(e, fs_item_image) + table.insert(ctx, e) + return e + end +}) + +local fs_model = { + render = function(e, x, y, w, h) + local out = {} + for _, x in pairs(e._styles) do + out[#out +1] = x:render() + end + out[#out +1] = string.format( + "model[%f,%f;%f,%f;%s;%s;%s;%f;%s;%s;%s;%f]", + x or get(e, "x"), y or get(e, "y"), + w or get(e, "w"), h or get(e, "h"), + e.__id, + get(e, "mesh"), get(e, "textures"), + get(e, "_rotation") or "", get(e, "_continuous") or "", + get(e, "_mouse_control"), + get(e, "_animation") or "", get(e, "_animation_speed") + ) + return table.concat(out) + end, + rotation = function(e, rot, continuous) + e._rotation = rot + e._continuous = continuous + end, + mouse_control = function(e, mouse_control) + e._mouse_control = mouse_control ~= false and true or false + end, + animated = function(frames, speed) + e._animation = frames + e._animation_speed = speed or 1 + end, + style = add_elem_style, + tooltip = add_elem_tooltip, +} +fs_model.__index = fs_model +setmetatable(fs_model, { + __call = function(_, x, y, w, h, mesh, textures) + local e = {x = x, y = y, w = w, h = h, mesh = mesh, textures = textures, mouse_control = true} + e.__id = new_id() + setmetatable(e, fs_model) + table.insert(ctx, e) + return e + end +}) + +local fs_button = { + render = function(e, x, y, w, h) + local out = {} + for _, x in pairs(e._styles) do + out[#out +1] = x:render() + end + if e._item then + out[#out +1] = string.format("item_image_button[%f,%f;%f,%f;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_item"), e.__id, get(e, "label")) + elseif e._image then + if e._image_pressed then + -- We never specify noclip or border here. That's a job for styles. + out[#out +1] = string.format("image_button[%f,%f;%f,%f;%s;%s;;;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_image"), e.__id, get(e, "label"), get(e, "_image_pressed")) + else + out[#out +1] = string.format("image_button%s[%f,%f;%f,%f;%s;%s]", e._exit and "_exit" or "", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_image"), e.__id, get(e, "label")) + end + else + out[#out +1] = string.format("button%s[%f,%f;%f,%f;%s;%s]", e._exit and "_exit" or "", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), e.__id, get(e, "label")) + end + return table.concat(out) + end, + image = function(e, img, pressed_img) + e._image = img + e._image_pressed = pressed_img + end, + item_image = function(e, item) + e._item = item + end, + exit = function(e) + e._exit = true + end, + onclick = function(e, fn) + ctx._events.on_click[e.__id] = fn + return e + end, + style = add_elem_style, + tooltip = add_elem_tooltip, +} +fs_button.__index = fs_button +setmetatable(fs_button, { + __call = function(_, x, y, w, h, label) + local e = {x = x, y = y, w = w, h = h, label = label, _styles = {}} + e.__id = new_id() + setmetatable(e, fs_button) + table.insert(ctx, e) + return e + end +}) + +local fs_checkbox = { + render = function(e, x, y) + return string.format("checkbox[%f,%f;%s;%s;%s]", x or get(e, "x"), y or get(e, "y"), e.__id, get(e, "label"), get(e, "checked") and "true" or "false") + end, + onchange = function(fn) + ctx._events.on_change[e.__id] = fn + return e + end, + tooltip = add_elem_tooltip, +} +fs_checkbox.__index = fs_checkbox +setmetatable(fs_checkbox, { + __call = function(_, x, y, label, checked) + local e = {x = x, y = y, label = label or "", checked = checked or label or false} + e.__id = new_id() + setmetatable(e, fs_checkbox) + table.insert(ctx, e) + return e + end +}) + +local fs_list = { + render = function(e, x, y, w, h) + w = w or get(e, "w") + h = h or get(e, "h") + + -- These lines will cause `w` and `h` to be interpreted as formspec coordinates rather than numbers of slots. + -- w = w -((w -1) /4) + -- h = h -((h -1) /4) + + return string.format("list[%s;%s;%f,%f;%d,%d;%s]", get(e, "location"), get(e, "list"), x or get(e, "x"), y or get(e, "y"), w, h, get(e, "start")) + end +} +fs_list.__index = fs_list +setmetatable(fs_list, { + __call = function(_, x, y, w, h, location, list, start) + local e = {x = x, y = y, w = w, h = h, location = location or "current_player", list = list or "main", start = start or ""} + e.__id = new_id() + setmetatable(e, fs_list) + table.insert(ctx, e) + return e + end +}) + +local fs_inventory = fs_list + +local fs_listring = { + render = function(e) + if e.location then + return string.format("listring[%s;%s]", e.location, e.list) + else + return "listring[]" + end + end, +} +fs_listring.__index = fs_listring +setmetatable(fs_listring, { + __call = function(_, location, list) + local e = location and list and {location = location, list = list} or {} + setmetatable(e, fs_listring) + table.insert(ctx, e) + return e + end +}) + +local fs_field = { + render = function(e, x, y, w, h) + local out = {} + for _, x in pairs(e._styles) do + out[#out +1] = x:render() + end + + x = x or get(e, "x") + y = y or get(e, "y") + w = w or get(e, "w") + h = h or get(e, "h") + + if e._password then + out[#out +1] = string.format("pwdfield[%f,%f;%f,%f;%s;%s]", x, y, w, h, e.__id, get(e, "label")) + + if not e._close_on_enter then + out[#out +1] = string.format("field_close_on_enter[%s;false]", e.__id) + end + else + out[#out +1] = string.format("%s[%f,%f;%f,%f;%s;%s;%s]", e._textarea and "textarea" or "field", x, y, w, h, e.__id, get(e, "label"), get(e, "value")) + + if not e._textarea and not e._close_on_enter then + out[#out +1] = string.format("field_close_on_enter[%s;false]", e.__id) + end + end + + return table.concat(out) + end, + onenter = function(e, fn) + ctx._events.on_enter[e.__id] = fn + return e + end, + onchange = function(e, fn) + ctx._events.on_change[e.__id] = fn + return e + end, + close_on_enter = function(e) + e._close_on_enter = true + return e + end, + multiline = function(e) + e._textarea = true + return e + end, + password = function(e) + e._password = true + return e + end, + style = add_elem_style, + tooltip = add_elem_tooltip, +} +fs_field.__index = fs_field +setmetatable(fs_field, { + __call = function(_, x, y, w, h, label, value) + local e = {x = x, y = y, w = w, h = h, label = label or "", value = value or label or "", _styles = {}} + e.__id = new_id() + setmetatable(e, fs_field) + table.insert(ctx, e) + return e + end +}) + +local function fs_textarea(...) + return fs_field(...) + :multiline() +end + +local fs_scrollbar = { + render = function(e, x, y, w, h) + local out = {} + if e._options then + out[#out +1] = "scrollbaroptions[" + local first = true + for k, v in pairs(options) do + if not first then + out[#out +1] = ";" + end + out[#out +1] = k + out[#out +1] = "=" + out[#out +1] = v + first = nil + end + out[#out +1] = "]" + end + + out[#out +1] = string.format("scrollbar[%f,%f;%f,%f;%s;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "orientation"), e.__id, get(e, "value")) + + return table.concat(out) + end, + options = function(e, props) + e._options = props + return e + end, + onchange = function(e, fn) + ctx._events.on_scrollbar_event[e.__id] = fn + return e + end, + style = add_elem_style, + tooltip = add_elem_tooltip, +} +fs_scrollbar.__index = fs_scrollbar +setmetatable(fs_scrollbar, { + __call = function(_, x, y, w, h, orientation, value) + local e = {x = x, y = y, w = w, h = h, orientation = orientation or "vertical", value = value or "", _styles = {}} + e.__id = "_"..minetest.get_us_time().."_"..math.random(1, 100000) + setmetatable(e, fs_scrollbar) + table.insert(ctx, e) + return e + end +}) + +local fs_scroll_container = { + render = function(e, x, y, w, h, orient, fac, pad) + x = x or get(e, "x") + y = y or get(e, "y") + w = w or get(e, "w") + h = h or get(e, "h") + + local out = { + string.format("scroll_container[%f,%f;%f,%f;%s;%s;%s;%s]", + x, y, + w, h, + e.__id, + orient or get(e, "orientation"), + fac or get(e, "factor"), + pad or get(e, "padding") + ) + } + for i = 1, #e do + local c = e[i] + local cx = resolve_layout_units(get(c, "x"), w) + local cy = resolve_layout_units(get(c, "y"), h) + + local cw, ch + if c.w then + cw = resolve_layout_units(get(c, "w"), w) + ch = resolve_layout_units(get(c, "h"), h) + end + + out[#out +1] = c:render(cx, cy, cw, ch) + end + out[#out +1] = "scroll_container_end[]" + + if e._scrollbar then + e._scrollbar.value = e._scroll_pos or "" + out[#out +1] = e._scrollbar:render() + else + local v = e.__fs._ctx.fields[e.__id] + out[#out +1] = string.format("scrollbar[-800,-800;0,0;%s;%s;%s]", get(e, "orientation"), e.__id, v and v:sub(5) or "") + end + + return table.concat(out) + end, + scrollbar = function(e, fn, ...) + if type(fn) == "function" then + e._scrollbar = fn() + else + e._scrollbar = fs_scrollbar(fn, ...) + end + e._scrollbar.__id = e.__id + return e + end, + -- A non-transient name is required in order to preserve scroll position upon a rebuild. + named = function(e, name) + e.__id = name + return e + end +} +fs_scroll_container.__index = fs_scroll_container +setmetatable(fs_scroll_container, { + __call = function(_, x, y, w, h, orientation, factor, padding) + local e = {x = x, y = y, w = w, h = h, orientation = orientation or "vertical", factor = factor or "", padding = padding or "0", _styles = {}, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx} + e.__id = new_id() + setmetatable(e, fs_scroll_container) + table.insert(ctx, e) + ctx = e + return e + end +}) + +local function fs_scroll_container_end() + if getmetatable(ctx) ~= fs_scroll_container then + minetest.log("warn", "`scroll_container_end` has no scroll container to end; it will be ignored.") + return + end + ctx = ctx.__parent +end + + +-- MARK: Builtin layouting helpers + +local fs_group = { + render = function(e, x, y, w, h) + local out = {} + for i = 1, #e do + local c = e[i] + local cx = resolve_layout_units(get(c, "x"), e.width) +(x or get(e, "x")) + local cy = resolve_layout_units(get(c, "y"), e.height) +(y or get(e, "y")) + + local cw, ch + if c.w then + cw = resolve_layout_units(get(c, "w"), e.width) + ch = resolve_layout_units(get(c, "h"), e.height) + end + + out[#out +1] = c:render(cx, cy, cw, ch) + end + + return table.concat(out) + end, +} +fs_group.__index = fs_group +setmetatable(fs_group, { + __call = function(_, x, y, w, h) + local e = {x = x, y = y, w = w, h = h, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx} + setmetatable(e, fs_group) + table.insert(ctx, e) + ctx = e + return e + end +}) + +local function fs_group_end() + if getmetatable(ctx) ~= fs_group then + minetest.log("warn", "`group_end` has no group to end; it will be ignored.") + return + end + ctx = ctx.__parent +end + + +local fs_row = { + render = function(e, x, y, w, h) + x = x or get(e, "x") + y = y or get(e, "y") + w = w or get(e, "w") + h = h or get(e, "h") + + local axis = e._direction == "column" and y or x + local axis_size = e._direction == "column" and h or w + + local out = {} + + local total_grow = 0 + local used_space = 0 + local flex_found + + -- Pass 1: Collect total sizing information to allow layout computation. + for i = 1, #e do + local c = e[i] + local ca, ca_flex = resolve_flex_layout_units(get(c, e._direction == "column" and "h" or "w")) + if c.w then + if ca_flex then + flex_found = true + c.__flex = ca + total_grow = total_grow +ca + else + used_space = used_space +(ca or 0) + end + end + end + + used_space = used_space +(e._gap *math.max(0, #e -1)) + local grow_basis = total_grow > 0 and (math.max(0, axis_size -used_space) /total_grow) or 0 + + -- If any flex element exists, it will take up all unused space, so the total width is guaranteed to be 100%. + -- Otherwise, we can simply use the size we already calculated for fixed elements. + local total_width = flex_found and axis_size or used_space + + -- Pass 2: Assign element positions based on flex ratios. + local current = 0 + for i = 1, #e do + local c = e[i] + c.__flex_offset = current + if c.__flex then + c.__flex = c.__flex *grow_basis + current = current +c.__flex +e._gap + else + current = current +resolve_layout_units(get(c, e._direction == "column" and "h" or "w")) +e._gap + end + end + + -- Pass 3: Justify and render. + local base = axis + if e._align == "center" then + base = axis +((axis_size -total_width) /2) + elseif e._align == "right" then + base = axis +axis_size -total_width + end + + for i = 1, #e do + local c = e[i] + if c.w then + if e._direction == "column" then + out[#out +1] = c:render(resolve_layout_units(get(c, "x")), base +c.__flex_offset, resolve_layout_units(get(c, "w")), c.__flex) + else + out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y")), c.__flex, resolve_layout_units(get(c, "h"))) + end + else + if e._direction == "column" then + out[#out +1] = c:render(resolve_layout_units(get(c, "x")), base +c.__flex_offset) + else + out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y"))) + end + end + end + + return table.concat(out) + end, + direction = function(e, dir) + e._direction = dir + return e + end, + gap = function(e, gap) + e._gap = gap + return e + end, + align = function(e, align) + e._align = align + return e + end, +} +fs_row.__index = fs_row +setmetatable(fs_row, { + __call = function(_, x, y, w, h) + local e = {x = x, y = y, w = w, h = h, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx} + setmetatable(e, fs_row) + table.insert(ctx, e) + ctx = e + return e + end +}) + + +local function fs_row_end() + if getmetatable(ctx) ~= fs_row then + minetest.log("warn", "`row_end` has no row to end; it will be ignored.") + return + end + ctx = ctx.__parent +end + +local function fs_column(...) + return fs_row(...) + :direction "column" +end + +local fs_column_end = fs_row_end + +-- MARK: Building + +local Window = { + render = function(e) + local out = { + "formspec_version[10]", + string.format("size[%f,%f]", e.width, e.height) + } + + if e._no_prepend then + out[#out +1] = "no_prepend[]" + end + + if e._position then + out[#out +1] = string.format("position[%f,%f]", e._position.x, e._position.y) + end + + if e._anchor then + out[#out +1] = string.format("anchor[%f,%f]", e._anchor.x, e._anchor.y) + end + + if e._padding then + out[#out +1] = string.format("padding[%f,%f]", e._padding.x, e._padding.y) + end + + if e._modal then + out[#out +1] = "allow_close[false]" + end + + for i = 1, #e do + local c = e[i] + local cx = resolve_layout_units(get(c, "x"), e.width) + local cy = resolve_layout_units(get(c, "y"), e.height) + + local cw, ch + if c.w then + cw = resolve_layout_units(get(c, "w"), e.width) + ch = resolve_layout_units(get(c, "h"), e.height) + end + + out[#out +1] = c:render(cx, cy, cw, ch) + end + return table.concat(out) + end, + no_prepend = function(e) + e._no_prepend = true + return e + end, + position = function(e, x, y) + e._position = {x = x, y = y} + return e + end, + anchor = function(e, x, y) + e._anchor = {x = x, y = y} + return e + end, + padding = function(e, x, y) + e._padding = {x = x, y = y} + return e + end, + modal = function(e, modal) + e._modal = modal ~= false and true or false + return e + end, + onclose = function(e, fn) + e._onclose = fn + return e + end +} +Window.__index = Window + +local function fs_begin(w, h) + ctx = {_events = {on_click = {}, on_change = {}, on_scrollbar_event = {}, on_enter = {}}, width = w or 12, height = h or 10} + setmetatable(ctx, Window) + return ctx +end + +local function fs_end() + local _ctx = ctx + ctx = nil + return _ctx +end + +local Context = { + update = function(e) + -- `_inert` should be set when many state updates may take place at once, to avoid a rapid succession of rebuilds. + -- After these updates have taken place, the user should call `rebuild()` manually if `_dirty` is true. + if e._inert then + e._dirty = true + else + e:rebuild() + end + end, + rebuild = function(e) + e._dirty = nil + e:clear_state_bindings() + + local tracker = e._linked_states + table.insert(observers, tracker) + + local fs = type(e.formspec) == "function" and e.formspec(e.args) or e.formspec + + table.remove(observers) + + fs._ctx = e + + for _, el in ipairs(fs) do + for _, x in pairs(el) do + local mt = getmetatable(x) + if mt == state or mt == DerivedState then + x._getters[e.id] = e + e._linked_states[x] = true + end + end + end + + e._events = fs._events + + local str = fs:render() + if e._is_inventory then + e._player:set_inventory_formspec(str) + else + minetest.show_formspec(e.target, e.id, str) + end + end, + clear_state_bindings = function(e) + for x in pairs(e._linked_states) do + x._getters[e.id] = nil + end + end, + deinit = function(e) + e:clear_state_bindings() + end +} +Context.__index = Context + +local function fs_show(target, fs, args) + local id = "form"..new_id() + local ctx = setmetatable({ + formspec = fs, + fields = {}, + target = type(target) == "string" and target or target:get_player_name(), + id = id, + _linked_states = {}, + args = args or {} + }, Context) + + contexts[id] = ctx + + ctx:rebuild() + + return ctx +end + +local function fs_set_inventory(p, fs, args) + local id = "form"..new_id() + local name = type(p) == "string" and p or p:get_player_name() + local ctx = setmetatable({ + _is_inventory = true, + _player = type(p) == "string" and minetest.get_player_by_name(p) or p, + formspec = fs, + fields = {}, + target = name, + id = id, + _linked_states = {}, + args = args or {} + }, Context) + + inventories[name] = ctx + + ctx:rebuild() + + return ctx +end + +local function fs_remove_inventory(p) + local name = p:get_player_name() + local inv = inventories[name] + if inv then + inv:deinit() + inventories[name] = nil + end +end + +-- MARK: Callback handling + +minetest.register_on_player_receive_fields(function(p, form, fields) + local name = p:get_player_name() + local ctx = contexts[form] + -- If this is an event from a player inventory, check if we have a context managing that inventory. + -- No other special handling is needed, because the context already knows that it's an inventory and will react appropriately. + if form == "" then + ctx = inventories[name] + end + + -- Ensure that a) players can only trigger effects of formspecs that were opened legitimately, and b) only the player who opened it may interact with a given formspec instance. + if ctx and ctx.target == name then + ctx._inert = true + + if fields.key_enter_field and ctx._events.on_enter[fields.key_enter_field] then + ctx._events.on_enter[fields.key_enter_field](fields[fields.key_enter_field]) + end + + for k, v in pairs(fields) do + if ctx._events.on_click[k] then + ctx._events.on_click[k](v) + elseif ctx._events.on_scrollbar_event[k] then + local ev = minetest.explode_scrollbar_event(v) + ctx._events.on_scrollbar_event[k](ev.type, ev.value) + elseif (not ctx.fields[k] or ctx.fields[k] ~= v) and ctx._events.on_change[k] then + ctx._events.on_change[k](v) + end + end + + ctx.fields = fields + + ctx._inert = nil + + if fields.quit then + ctx:deinit() + contexts[form] = nil + else + if ctx._dirty then + ctx:rebuild() + ctx._dirty = nil + end + end + end +end) + +-- Remove all contexts tied to a leaving player. +minetest.register_on_leaveplayer(function(p) + fs_remove_inventory(p) +end) + +-- MARK: API exposure + +imfs = { + _contexts = contexts, + _inventories = inventories, + + state = state, + derive = derive, + get_field = get, + begin = fs_begin, + end_ = fs_end, + show = fs_show, + set_inventory = fs_set_inventory, + remove_inventory = fs_remove_inventory, + resolve_layout_units = resolve_layout_units, + resolve_flex_layout_units = resolve_flex_layout_units, + container_start = function() + local container = {__parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx} + table.insert(ctx, container) + ctx = container + end, + container_end = function() + ctx = ctx.__parent + end, + add_to_context = function(container) + table.insert(ctx, container) + end, + + theme = theme, + + style = fs_style, + tooltip = fs_tooltip, + _named_tooltip = fs_named_tooltip, + label = fs_label, + arealabel = fs_arealabel, + box = fs_box, + image = fs_image, + item_image = fs_item_image, + model = fs_model, + button = fs_button, + list = fs_list, + inventory = fs_inventory, + listring = fs_listring, + field = fs_field, + textarea = fs_textarea, + checkbox = fs_checkbox, + scrollbar = fs_scrollbar, + scroll_container = fs_scroll_container, + scroll_container_end = fs_scroll_container_end, + group = fs_group, + group_end = fs_group_end, + row = fs_row, + row_end = fs_row_end, + column = fs_column, + column_end = fs_column_end, + + -- Can be called in a game's base mod to globalize imfs for brevity. + export = function() + for k, v in pairs(imfs) do + local key + if k == "state" or k == "derive" then + key = k + elseif k == "end_" then + key = "fs_end" + else + key = "fs_"..k + end + _G[key] = v + end + end +} diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..27c6d04 --- /dev/null +++ b/mod.conf @@ -0,0 +1 @@ +name = imfs \ No newline at end of file