Skip to content

SUI: SMODS/Simple UI framework#1356

Draft
SleepyG11 wants to merge 9 commits into
Steamodded:mainfrom
SleepyG11:sui
Draft

SUI: SMODS/Simple UI framework#1356
SleepyG11 wants to merge 9 commits into
Steamodded:mainfrom
SleepyG11:sui

Conversation

@SleepyG11

@SleepyG11 SleepyG11 commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

This PR is WIP implementation for new advanced way for defining UI nodes, with additional quality-of-life features and extendable design which allows to create complex components.

Further explanation, examples, caveats etc. will be included in PR text later.

Summary

Quick example
SUI.ROOT({
	colour = G.C.CLEAR,
	outline = 1,
	outline_colour = G.C.WHITE,

	SUI.C({
		align = "cm",

		SUI.R({ colour = G.C.MULT }),
		SUI.R({ colour = G.C.CHIPS }),
	}),
	SUI.C({
		align = "cm",

		SUI.R({ colour = G.C.ORANGE }),
		SUI.R({ colour = G.C.GREEN }),
	}),
})

-- Using lua syntax for function call with table
SUI.ROOT{
	colour = G.C.CLEAR,
	SUI.C{
		align = "cm",
		SUI.R{ colour = G.C.MULT },
	},
	SUI.C{
		align = "cm",
		SUI.R{ colour = G.C.ORANGE },
	},
}

Syntax

Element creation
-- Preferred

SUI.ROOT({ ... }) -- chosen type
SUI("ROOT", { ... }) -- variable type

-- All

-- single argument, any type
-- as `n` can be key or value from `G.UIT`
SUI({ n = G.UIT.ROOT, ... })
SUI({ n = "ROOT", ... })
-- two arguments, any type
SUI(G.UIT.ROOT, { ... })
SUI("ROOT", { ... })
-- single argument, chosen type
SUI.ROOT({ ... })
SUI["ROOT"]({ ... })
Adding child nodes
-- Preferred

SUI.ROOT({ child_1, child_2 }) -- general case

local content = { child_2, child_3 }
SUI.ROOT({ child_1, content, child_4 }) -- inserting multiple at once

SUI.ROOT(child_1) -- shorthand for covering element into other element

-- All

-- vanilla way
SUI.ROOT({ nodes = { child_1, child_2 } })
-- as arguments
SUI.ROOT(child_1, child_2)
-- as array
SUI.ROOT({ child_1, child_2 })
-- as array of arrays
SUI.ROOT({ child_1, { child_2, child_3 }, child_4 })
-- multiple arguments, each argument extends previous
SUI.ROOT({ child_1, child_2 }, { child_3, child_4 })
-- chained
SUI.ROOT({ child_1, child_2 })({ child_3, child_4 })
-- combined (.nodes first, then rest)
SUI.ROOT({ nodes = { child_1 }, child_2 })

-- extreme example: all listed examples for adding children
-- can be used at the same time in any order
SUI.ROOT(
	{
		nodes = { child_1, child_2 },
		child_3,
		{ child_4, child_5 },
	},
	child_6,
	{
		nodes = { child_7 },
		child_8,
		{ child_9 },
	}
)({
	child_10,
})
-- result element will be equivavelt of this element
SUI.ROOT({
	child_1,
	child_2,
	child_3,
	child_4,
	child_5,
	child_6,
	child_7,
	child_8,
	child_9,
	child_10,
})
Config definition
-- vanilla way
SUI.ROOT({ config = { colour = G.C.CLEAR, padding = 0.1 } })
-- simple way
SUI.ROOT({ colour = G.C.CLEAR, padding = 0.1 })
-- multiple arguments, each argument extends previous
SUI.ROOT({ colour = G.C.CLEAR }, { padding = 0.1 })
-- chained, each call extends previous
SUI.ROOT({ colour = G.C.CLEAR })({ padding = 0.1 })
-- combined (.config first, then rest)
SUI.ROOT({ config = { colour = G.C.CLEAR }, padding = 0.1 })

-- extreme example: all listed examples for adding config
-- can be used at the same time in any order
SUI.ROOT({
	config = { colour = G.C.CLEAR, outline_colour = G.C.WHITE },
	padding = 0.1,
}, { h = 3, w = 5, colour = G.C.BLACK })({
	config = { outline = 1, padding = 0.25 },
	align = "cm",
})
-- result element will be equivavelt of this element
SUI.ROOT({
	colour = G.C.BLACK,
	outline = 1,
	outline_colour = G.C.WHITE,
	padding = 0.25,
	h = 3,
	w = 5,
	align = "cm",
})

Features

Feature: hooking UIElement methods via node definition
SUI.ROOT({
    -- init function which called when UIElement is ready to be used
    -- Important node: children is not ready in this moment, only element itself
    s_init = function(self)
        print("node created")
        self.states.collide.can = true
        self.states.click.can = true
    end,
    -- list of hooks to setup on UIElement
    s_hooks = {
        click = function(old_click, self, ...)
            print("node clicked")
            return old_click(self, ...)
        end,
        hover = function(old_hover, self, ...)
            print("node hovered")
            return old_hover(self, ...)
        end,
        update = function(old_update, self, dt, ...)
            print("node updated", dt)
            return old_update(self, dt, ...)
        end,
    },
})
Feature: auto-wrap Moveable into G.UIT.O
local cardarea = CardArea(0, 0, G.CARD_W, G.CARD_H, { type = "title" })
local card = SMODS.create_card({ key = "j_joker", area = cardarea })
cardarea:emplace(card)
-- In this example, instead of `CardArea` it be any object which extends `Moveable` such as `UIBox`, `Card`, `Sprite`, etc.

SUI.ROOT({ colour = G.C.CLEAR, cardarea }) -- general case
SUI.ROOT(cardarea) -- shorthand to wrap object into element

-- Vanilla way
SUI.ROOT({
	colour = G.C.CLEAR,
	SUI.O({ object = cardarea }),
})
-- Short way, SUI.O treat children as objects
SUI.ROOT({
	colour = G.C.CLEAR,
	SUI.O({ cardarea }),
})
-- Even shorter way: ROOT, R and C wraps
-- element in G.UIT.O if see Moveable
SUI.ROOT({ colour = G.C.CLEAR, cardarea })
SUI.ROOT({ colour = G.C.CLEAR }, cardarea )
Feature: auto-wrap string/number argument into G.UIT.T
-- If string or number passed as child node, it will be covered in `G.UIT.T` element
-- Default values: colour = G.C.UI.TEXT_LIGHT, scale = 0.32,

SUI.ROOT({ align = "cm", "Hello World!", child_2 })
SUI.ROOT({ align = "cm", 0.25, child_2 })

-- Is equivalent to

SUI.ROOT({ 
    align = "cm",

    SUI.T({
        colour = G.C.UI.TEXT_LIGHT,
        scale = 0.32,
        text = "Hello World!"
    }),
    child_2
})

-- Vanilla way
SUI.ROOT({
	colour = G.C.CLEAR,
	SUI.T({ text = "Hello World!", scale = 0.5, colour = G.C.MULT }),
})
-- Short way, SUI.T treat children as text
SUI.ROOT({
	colour = G.C.CLEAR,
	SUI.T({ "Hello World!", scale = 0.5, colour = G.C.MULT }),
})
-- Even shorter way: ROOT, R and C wraps
-- element in G.UIT.T if see text or number
-- In this case default values for colour and scale is applied
SUI.ROOT({ colour = G.C.CLEAR, "Hello World!" })
SUI.ROOT({ colour = G.C.CLEAR }, "Hello World!" )
Feature: fix G.UIT.R/G.UIT.C child nodes mismatch
-- In vanilla, this kind of UI is unpredictable
SUI.ROOT({
	SUI.R(),
	SUI.C(),
	SUI.R(),
	SUI.O(),
	SUI.R(),
})
-- ROOT, R, C applies automatic fix for layout based on first child
-- Example above will be converted to this.
-- First child is R so other elements will be converted to R too
-- O element behave similar to C in vanilla
SUI.ROOT({
	SUI.R(),
	SUI.R({ SUI.C() }),
	SUI.R(),
	SUI.R({ SUI.O() }),
	SUI.R(),
})

-- In this example...
SUI.ROOT({
	SUI.C(),
	SUI.R(),
	SUI.O(),
	SUI.R(),
	SUI.C(),
})
-- First child is C so other elements will be converted to C too
-- O element behave similar to C in vanilla
SUI.ROOT({
	SUI.C(),
	SUI.C({ SUI.R() }),
	SUI.O(),
	SUI.C({ SUI.R() }),
	SUI.C(),
})

-- Available options to adjust how R/C fix operates.
SUI.ROOT({
    s_config = {
        rc_fix_target = "C", -- specify target: "R" or "C", or false to disable entirely
        rc_fix_align = "cm", -- add align to all additional R/C created during fixing
    }
})
Feature: classes
-- Classes are way to define design for elements and reuse it in different places

local classes = {
    -- class to make element rounded and a bit of emboss
    pretty = { r = 0.1, emboss = 0.1 },
    -- class to specify sizes for small squares inside
    square = { minh = 0.5, minw = 0.5 },
    -- class with standartized padding
    -- instead of table, function can be specified for more custom logic
    padding = function(self)
        self.config.padding = 0.05
    end,
}

-- Optionally, global table can be used
SMODS.SUI.classes.pretty = { r = 0.1, emboss = 0.1 }
SMODS.SUI.classes.pretty = { minh = 0.5, minw = 0.5 }
SMODS.SUI.classes.pretty = { padding = 0.05 }

SUI.ROOT({
    s_config = {
        -- Specify a table of classes to select from
        -- This applied to children and their children (unless another table is specified)
        -- If not specified, global table `SMODS.SUI.classes` used instead
        classes = classes,
    },
    -- From here, we can use specified classes to extend element configs
    -- Separated by whitespaces, any amount of classes can be specified in any order
    -- if found, they will be applied from left to right
    s_class = "padding pretty"
    -- Element's config have higher priority than any class configs
    colour = G.C.WHITE,

    SUI.C({
        SUI.R({
            s_class = "padding",
            SUI.R({ s_class = "pretty square", colour = G.C.MULT })
        }),
        SUI.R({
            s_class = "padding",
            SUI.R({ s_class = "pretty square", colour = G.C.CHIPS })
        }),
    }),
    SUI.C({
        SUI.R({
            s_class = "padding",
            SUI.R({ s_class = "pretty square", colour = G.C.GREEN })
        }),
        SUI.R({
            s_class = "padding",
            SUI.R({ s_class = "pretty square", colour = G.C.ORANGE })
        }),
    }),
})

Additional Info:

  • I didn't modify api's or I've made a PR to the wiki repo.
  • I didn't modify api's or I've updated lsp definitions.
  • I didn't make new lovely files or all new lovely files have appropriate priority.

@SleepyG11 SleepyG11 changed the title SUI implementation SUI: SMODS/Simple UI framework Apr 29, 2026
@Oinite12

Oinite12 commented May 2, 2026

Copy link
Copy Markdown
Contributor

I'm curious to know as to why a lot of options for syntax are permitted by this system. I personally feel like (regarding coding conventions in general) this kind of permissiveness results in code that isn't the most quality (Wouldn't it also be inefficient, having to go through a lot of different checks?) so I'm curious and would love to hear about the design choices for this manner of syntax

@SleepyG11

Copy link
Copy Markdown
Collaborator Author

This syntax supposed to be short and idiot-proof.

To add nodes you don't need to guess are input you're passing are list of elements or one element; to add properties, you can specify them directly without wrapping to config object; R/C auto-fixing supposed to keep UI consistent; special cases for G.UIT.O and G.UIT.T are logical and makes sense and works as shortcuts.

Yes, it is not best practice to give full freedom of how things can be done. Just as SMODS be god-class.
But, I believe that this kind of synax may find their users and, hopefully, make people less afraid of Balatro UI.

@SleepyG11

Copy link
Copy Markdown
Collaborator Author

Some questions I have:

  • Is naming okay or need to be changed to smth easier to recognize?
  • Maybe R/C fix is not needed? Or should be disabled by default?
  • Maybe classes are not needed? It may be pretty complex concept for people who never worked with web.
  • Is shorthands for G.UIT.O and G.UIT.T is bad practice?
  • Shorthand for G.UIT.T should replace text or concatenate strings?
  • Maybe arguments needs to be more tight? Right now there's multiple ways how to add nodes or configs in different forms.

@Aurelius7309

Copy link
Copy Markdown
Member
  • I can't think of a different short name for a global, SUI doesn't seem bad. Could try to think of something else, but I don't think it should be longer for the sake of being more recognizable.
  • Ultimately, this is most usable if it's accessible and comes with redundancies. After all, a tight UI system already exists. Being as permissive as possible plays into that, so I think R/C fix and different forms for arguments work well in this setting.
  • Sure, classes are a more advanced feature, but they seem generally useful enough and allow for some basic deduplication of config options without things getting too complicated. With a global table acting as default, I would worry about namespacing though, since said table is prone to overlaps. On the flipside, it allows for changing UI style in a way that can be consistent across mods, which might be desirable.
  • Shorthands for object and text nodes could obscure the inner structure of the UI engine, which encourages doing things without knowing what you're doing. If this is meant to be idiot-proof and redundant, I think that's fine. Maybe it's bad practice, but at least for text, you're probably switching to a more verbose notation as soon as your text needs any config anyway.
  • The "short way" text shorthand should just concatenate text, I think. The alternative (replacement) means that providing multiple in this way is completely useless. For the full shortcut, this is meaningless.

Bad practice is really whatever we want it to be. With a feature that's meant to make UI more accessible, I don't think it's a huge concern.

One thing I'm not sure about is s_hooks. Being shaped like literal hooks despite just being put in a table might be unintuitive as well as being inconsistent with how such functions are handled elsewhere. The alternative is a system that allows less - I don't know if there's a reasonable use for hooking UIElement functions in this way other than simple post-hooking without modifying the original call flow, so I'm not fully decided on what's best in this regard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants