├── LICENSE ├── README.md ├── chui.lua ├── game-menu └── main.lua ├── media ├── game-menu-options.png ├── game-menu.png ├── nested-alignment.png ├── palette-designer.png ├── panel-nesting.png ├── row-alignment.png ├── sequencer.png ├── showcase.png └── vqwerty.png ├── nested-alignment └── main.lua ├── palette-designer ├── coloring.lua └── main.lua ├── sequencer ├── conf.lua ├── dm-bass.ogg ├── dm-hatclosed.ogg ├── dm-hatopen.ogg ├── dm-snare.ogg └── main.lua ├── showcase └── main.lua └── vkeyboard ├── conf.lua ├── main.lua └── vqwerty.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chui - immersive 3D UI library 2 | 3 | `chui` is a library for 3D user interfaces within the [LÖVR](https://github.com/bjornbytes/lovr) 4 | framework for both desktop and VR environments. Moving beyond the flat UIs and laser-pointer VR 5 | interaction, chui widgets are interacted with by pressing and pushing with natural movements. This 6 | immersive approach is elsewhere known as diegetic UI or Direct Touch. 7 | 8 | ![showcase screenshot](media/showcase.png) 9 | 10 | The main library goal is for simple user code to build attractive interfaces that can serve a wide 11 | range of applications: main menu selections, configuration screens, toolbars, as well as interactive 12 | elements within virtual worlds. chui stands for **c**ompact **h**aptic **u**tility **i**nterlink. 13 | 14 | chui uses retained mode for UI state - you create panels & add widgets only once during UI 15 | initialization. Stored references to widgets and panels can be modified in runtime as needed. If you 16 | are instead interested in an immediate mode UI library for LÖVR, check out the 17 | [lovr-ui](https://github.com/immortalx74/lovr-ui). 18 | 19 | ```lua 20 | chui = require'chui' 21 | 22 | panel = chui.panel() -- panel is a container for widgets which are arranged in rows 23 | panel.pose:set(0, 1.5, -2):scale(0.2) -- position the panel in 3D space (see lovr's mat4) 24 | 25 | panel:label{ text = 'Hello!' } 26 | panel:row() -- finishes one row and starts a new row of widgets 27 | panel:button{ text = 'click me', 28 | callback = function(self) 29 | self.text = 'clicked' 30 | end} 31 | 32 | panel:layout() -- needs to be called after all the widgets are inserted 33 | 34 | function lovr.update(dt) 35 | chui.update(dt) -- let UI process the pointer interactions 36 | end 37 | 38 | function lovr.draw(pass) 39 | chui.draw(pass) -- render to screen 40 | end 41 | ``` 42 | 43 | In VR the UI elements are controlled with left & right controller, or with your hand-tracked index 44 | finger tips. On desktop, the UI interaction uses right mouse button. This is because the LÖVR VR 45 | simulator already binds the left mouse button for camera rotation. 46 | 47 | ## Layout mechanics 48 | 49 | Panel acts as a container for one or more widgets, arranged in horizontal rows. The panel structure 50 | manages the 3D position/scale/orientation through its pose, takes care of arranging widgets with a 51 | flexible layout mechanism, handles pointer world-local transitions, assigns the color scheme to 52 | the contained widgets, and optionally renders a rounded back-panel frame to enhance visibility and 53 | grounding of the contained widgets within the 3D scene. 54 | 55 | The panel stores a list of top-to-down rows, and each row is filled with widgets in left-to-right 56 | order. 57 | 58 | ```lua 59 | panel = chui.panel() 60 | panel:label{ text = 'button >' } 61 | panel:button() 62 | panel:row() -- start of new row 63 | panel:label{ text = 'toggle >' } 64 | panel:toggle() 65 | panel:layout() 66 | ``` 67 | 68 | At the end of panel definition, the `layout()` function is called to arrange widgets. 69 | 70 | The UI widgets are organized in neat rows. Rows expand horizontally to fit all the widgets. Each row 71 | also adjusts its height to accommodate the tallest element. By default, rows are centered 72 | horizontally and widgets are centered vertically within their rows. This alignment can be controlled 73 | through parameters to panel layout function. 74 | 75 | ![layouting](media/row-alignment.png) 76 | 77 | Widgets can request more or less space using the `span = {horizontal, vertical}` parameter in their 78 | initialization table. This can also be changed later, but remember to manually re-layout the panel 79 | afterwards. Note that increasing vertical span won't make the widget overflow into next row; instead 80 | the row hight will be increased. The **nested-alignment** interactive example may help with 81 | understanding how the row-based alignment works. 82 | 83 | Unlike more complex and less predictable layout methods, the implementation uses manually defined 84 | widget dimensions that do not automatically adjust to content size. Users are responsible for 85 | ensuring sufficient space is allocated for text within UI elements. 86 | 87 | In case the built-in layout mechanism is not flexible enough, widgets can be positioned manually 88 | by specifying relative offsets to the panel center in each widget's `.pose` matrix. All widgets 89 | should be oriented in +Z direction so they face outwards from the panel. It is also necessary to 90 | specify panel's dimensions in `.span` table, if the back-panel is rendered or if the panel is 91 | intended to be nested. 92 | 93 | #### Nesting of panels 94 | 95 | The panels can be nested within each other. A nested panel behaves like any other widget in the 96 | parent panel. Parent will use the dimensions (specified by `span`) of nested panel during the layout 97 | to place it next to other elements. 98 | 99 | ![panel nesting](media/panel-nesting.png) 100 | 101 | This is an advanced feature that can be used to compose complex components that act as a single 102 | widget. For example, a checkbox can be constructed from a small toggle button and a label next to 103 | it; this combination can then be nested in other panels, behaving like a single built-in widget. 104 | 105 | Nesting panels can also be used for fine control over the layout. Each sub-panel can have its own 106 | horizontal/vertical alignment settings and the parent panel has its own settings. This enables more 107 | precise positioning for column-based layouts, with each column being a nested panel. Furthermore, 108 | the nested panel can have a custom `pose` scale to make some parts of UI bigger or smaller. 109 | 110 | The nested panels can specify their own color palettes, enabling diverse and interesting visual 111 | designs of user interfaces. 112 | 113 | ## chui API 114 | 115 | `chui.draw(pass, draw_pointers)` renders all created panels in the 3D scene. If `draw_pointers` is 116 | `true`, a small sphere will be rendered for each pointer device, with its position projected onto 117 | the panel surface. User can also call the `panel:draw(pass)` method on individual panels for finer 118 | control over rendering. 119 | 120 | `chui.update(dt)` processes pointer interactions across all panels. User can also call the 121 | `panel:update(dt)` method on individual panels. 122 | 123 | `chui.setFont(nil or font_path or font_object)` changes the font used for all text rendered by the 124 | library. 125 | 126 | `chui.reset()` releases references to created panels. 127 | 128 | `chui.initWidgetType(widget_name, widget_proto)` is an advanced feature to register a new widget for 129 | usage with chui. The `widget_name` is a string that is later used to create new widgets (e.g. 130 | `panel:widget_name{ }`). `widget_proto` is a table containing implementations of custom widget's 131 | `:init(options)`, `:draw(pass)` and `:update(dt)` methods. 132 | 133 | ### Panel API 134 | 135 | Both panels and widgets accept an options table to customize their appearance or behavior. Below, 136 | each entry includes the default values (equivalent to using an empty table `{}`). 137 | 138 | ```lua 139 | panel = chui.panel{ pose = mat4(), frame = 'backpanel', palette = chui.palettes[1] } 140 | ``` 141 | * the pose sets the position, scaling and orientation of the panel in 3D space; for nested panels 142 | only the scale is preserved during the parent panel layout process 143 | * use `frame = false` to prevent rendering of the rounded back panel frame 144 | * chui library includes a number of built-in palettes, and color scheme can be further customized 145 | with the included *palette-designer* app 146 | 147 | `panel:draw(pass, draw_pointers)` renders a single panel at position assigned by its `pose`. Only 148 | top-level (non-nested) panels should be manually drawn; nested panels are automatically rendered as 149 | part of their parent's draw process. 150 | 151 | `panel:update(dt)` updates interactive widget states within the panel, using any active pointers 152 | for input. Only top-level (non-nested) panels should be manually updated; nested panels 153 | will be automatically update as part of their parent update process. 154 | 155 | `panel:reset()` removes all added widgets, allowing the same panel instance be populated with a new 156 | set of widgets. 157 | 158 | `panel:row()` ends the current row and adds a fresh row at the bottom of the panel. New widgets and 159 | nested panels are added to this new row. 160 | 161 | `panel:nest(child_panel)` embeds a child panel within the current panel, treating it like any other 162 | widget. Just like adding any other widget, the child panel gets added to the end of last row. 163 | 164 | `panel:layout(horizontal_alignment | nil, vertical_alignment | nil)` arranges widgets and nested 165 | panels to prevent overlap, calculating the panel's overall dimensions span. It is typically called 166 | at the end of the panel initialization, after all widgets are added, but can also be invoked 167 | whenever the panel's arrangement changes. 168 | 169 | The `horizontal_alignment` parameter (`left`, `right`, or default `center`) dictates the alignment 170 | of multiple rows within the panel. Similarly, `vertical_alignment` (`top`, `bottom`, or default 171 | `center`) controls the alignment of widgets within a single row. 172 | 173 | The horizontal and vertical alignment also influence the panel's positioning relative to its 174 | assigned pose. For instance, a top-aligned panel is positioned relative to its top edge and 175 | extends downwards. Conversely a bottom-right aligned panel extends upwards and leftwards from its 176 | fixed bottom-right corner. 177 | 178 | The panel object provides constructor methods for each widget type, that are used to add widgets. 179 | The widgets always get added to the end of last row. A list of these constructor methods follows. 180 | 181 | ### Widgets API 182 | 183 | In addition to the specific widget options listed below, each widget can accept a 184 | `span = {horizontal, vertical}` which controls the allocated space during the layout operation. 185 | The default span for all widgets is `{1, 1}`. You can also specify only the horizontal span (e.g. 186 | `span = 2`). 187 | 188 | **Spacer** is an invisible non-interactive element used to create empty space or push other widgets 189 | apart, affecting the panel size. It has no additional options beyond the mentioned `span`. 190 | 191 | ```lua 192 | panel:spacer{} 193 | ``` 194 | 195 | **Label** is a simple non-interactive text, with controllable font size. 196 | 197 | ```lua 198 | panel:label{ text = '', text_scale = 1 } 199 | ``` 200 | 201 | **Button** is a momentary push-button widget. The optional callback function is called with 202 | `(self)`, where `self` is a reference to the pressed button widget. Besides using callback, users 203 | can call `my_button:get()` to poll the current state of the button. 204 | 205 | ```lua 206 | panel:button{ text = '', thickness = 0.3, callback = nil } 207 | ``` 208 | 209 | **Toggle** is a latching toggle button widget. The optional callback function is called with 210 | `(self, state)`. Besides interacting with widget and assigning the callback, users can call 211 | `toggle:set(true_or_false)` and `current_state = toggle:get()` to set or poll the binary state. 212 | 213 | ```lua 214 | panel:toggle{ text = '', thickness = 0.3, state = false, callback = nil } 215 | ``` 216 | 217 | **Glow** is a non-interactive indicator of its boolean state, being lit up when true. The widget has 218 | `:get()` and `:set(true_or_false)` methods. 219 | 220 | ```lua 221 | panel:glow{ text = '', thickness = 0.1, state = false } 222 | ``` 223 | 224 | **Progress** is a non-interactive visualizer of a numerical value in range from 0 to 1. It has the 225 | `:get()` method, and a `:set(normalized_value)` method which will also clamp the input value to 226 | supported range. 227 | 228 | ```lua 229 | panel:progress{ text = '', value = 0 } 230 | ``` 231 | 232 | **Slider** is visual component for modifying a numerical value. The `step` option (for example `step=0.25`) specifies granularity; use `step=1` for integer slider. The `format` string is an 233 | advanced option to customize the appearance of slider label; mainly to control the number of digits 234 | displayed. 235 | 236 | The optional callback function is called with `(self, value)` parameters, when the user stops interacing with the widget. Widget also has `:get()` and `:set(value)` methods. 237 | 238 | ```lua 239 | panel:slider{ text = '', format = '%s %.2f', min = 0, max = 1, value = 0, step = nil, thickness = 0.15, callback = nil } 240 | ``` 241 | 242 | More custom widgets can be implemented outside the chui source and integrated into the lib with the 243 | `initWidgetType` function. 244 | 245 | ## Demos & utilities 246 | 247 | ##### showcase app 248 | 249 | A collection of all the built-in widgets, for testing the library and as learning reference. 250 | 251 | ##### nested-alignment 252 | 253 | ![nested-alignment screenshot](media/nested-alignment.png) 254 | 255 | The sample app constructs 5x5 randomly sized toggle-buttons, and offers 9 ways to align them. It 256 | demonstrates the nesting of a panel within another, and the alignment mechanics. Note how the top 257 | and bottom alignment works per-row, while left and right options align the entire rows without 258 | changing the relative positions of widgets within the row. 259 | 260 | ##### palette-designer 261 | 262 | A color palette designer for previewing and modifying chui color palettes. Select one of built-in 263 | color palettes, see the preview for all built-in widgets, dynamically edit colors with HSL sliders, 264 | and print out the palette table to the console output. 265 | 266 | ![palette-designer screenshot](media/palette-designer.png) 267 | 268 | 269 | The included `colorizer.lua` code also has some useful color conversion utilities between HSL, RGB table and hexcode formats for storing color. 270 | 271 | ##### vkeyboard 272 | 273 | A full 3D virtual keyboard and a basic text edit box. 274 | 275 | ![vqwerty screenshot](media/vqwerty.png) 276 | 277 | The `vqwerty.lua` is reusable module that creates the virtual keyboard panel. The pressed keys are 278 | registered as key events in LÖVR, so they should work out-of-the-box with any LÖVR project that 279 | consumes the hardware keyboard events. The layout is also easily adaptable to a numpad or any other 280 | keyboard arrangement. 281 | 282 | ##### sequencer 283 | 284 | An example of complete app in LÖVR + chui, a basic music drum sequencer with 4 tracks and per-track 285 | volume & pitch control. The VR mode is disabled and the UI is configured to work in 2D desktop mode 286 | with orthographic projection, dynamically adaptating when the window resizes. 287 | 288 | ![sampler screenshot](media/sequencer.png) 289 | 290 | ## Contributing 291 | 292 | Let me know what could be made simpler and what's missing in your usage. 293 | 294 | Issues & code contributions are always welcome! 295 | 296 | ## License 297 | 298 | The project falls under MIT license. 299 | -------------------------------------------------------------------------------- /chui.lua: -------------------------------------------------------------------------------- 1 | -- chui: a set of VR UI push-to-operate components (no laser pointers) 2 | 3 | local m = {} 4 | 5 | local function vibrate(device, strength, duration, frequency) 6 | if device ~= 'mouse' and lovr.headset then 7 | lovr.headset.vibrate(device, strength, duration, frequency) 8 | end 9 | end 10 | 11 | local Q = 0.02 -- quant; all paddings and margins are its multiples 12 | local S = 0.05 -- size of widget actuators 13 | local text_scale = 0.3 14 | local button_roundness = 0.3 15 | local slider_roundness = 0.1 16 | 17 | m.palettes = { -- a built-in collection of UI color palettes 18 | --widget body color for OFF color for ON highlight color them letters back-panel color 19 | { cap = 0xf0f0fb, inactive = 0xa2a6c1, active = 0xa479c7, hover = 0xb9aecd, text = 0x41486c, panel = 0xf6f7fe }, 20 | { cap = 0x291d22, inactive = 0x3b3235, active = 0xf9b18e, hover = 0x9d5550, text = 0xfae8bc, panel = 0x374549 }, 21 | { cap = 0x3b3149, inactive = 0x5c6181, active = 0xd47563, hover = 0xecc197, text = 0xecece0, panel = 0x191822 }, 22 | { cap = 0x313131, inactive = 0x6f564c, active = 0xff9300, hover = 0xfdc484, text = 0x827d6d, panel = 0xf6b511 }, 23 | { cap = 0xffeecc, inactive = 0x00b9be, active = 0xf57d7d, hover = 0xffb0a3, text = 0x15788c, panel = 0x264452 }, 24 | { cap = 0x413a42, inactive = 0x1f1f29, active = 0xe68056, hover = 0x596070, text = 0xeaf0d8, panel = 0x16181b }, 25 | { cap = 0x392b35, inactive = 0x7a9c96, active = 0xffab53, hover = 0x486b7f, text = 0xdac1c1, panel = 0x5e747e }, 26 | { cap = 0x100f13, inactive = 0x372437, active = 0xa05642, hover = 0x693540, text = 0xc7955c, panel = 0x1a0d1e }, 27 | { cap = 0x2a2a2b, inactive = 0x454a4d, active = 0x5a9470, hover = 0x2f7571, text = 0x81b071, panel = 0x202020 }, 28 | { cap = 0x212124, inactive = 0x464c54, active = 0x76add8, hover = 0x5b8087, text = 0xa3e7f0, panel = 0x2b3a49 }, 29 | { cap = 0x2e3b43, inactive = 0x619094, active = 0xdcfdcb, hover = 0x5a9e89, text = 0x5fa6ac, panel = 0x9ac0ba }, 30 | { cap = 0xdddddd, inactive = 0x566063, active = 0x8caab5, hover = 0xfdfaf8, text = 0x073336, panel = 0xf4f4f3 }, 31 | { cap = 0xa5c09d, inactive = 0x82a775, active = 0x7fe2e5, hover = 0xedf4f2, text = 0x165c44, panel = 0x308e7f }, 32 | { cap = 0xd7417f, inactive = 0x3b3235, active = 0x785ea0, hover = 0x74509d, text = 0xfcd8d8, panel = 0xfd6193 }, 33 | { cap = 0xecf1e6, inactive = 0xa8a9b9, active = 0x67bdc8, hover = 0x7fdd8e, text = 0x2d2614, panel = 0xf4fefe }, 34 | { cap = 0xf5fffc, inactive = 0xa6a6a6, active = 0x5dd276, hover = 0x78cfd0, text = 0x173b4e, panel = 0xf0f4f3 }, 35 | { cap = 0x46425e, inactive = 0xb28e7c, active = 0xdd9e43, hover = 0x72677b, text = 0xddc2bd, panel = 0x81828e }, 36 | } 37 | 38 | m.mouse_available = (not lovr.headset) or (lovr.headset.getName() == 'Simulator') 39 | m.segments = 7 -- amount of geometry for roundrects and cylinders 40 | 41 | m.panels = {} 42 | m.widget_types = {} 43 | 44 | -- SPACER --------------------------------------------------------------------- 45 | m.spacer = {} 46 | m.spacer.defaults = {} 47 | table.insert(m.widget_types, 'spacer') 48 | 49 | function m.spacer:init() 50 | end 51 | 52 | 53 | function m.spacer:draw(pass, pose) 54 | end 55 | 56 | 57 | function m.spacer:update(dt, pointer, pointer_name) 58 | end 59 | 60 | 61 | -- LABEL ---------------------------------------------------------------------- 62 | m.label = {} 63 | m.label.defaults = { text = '', text_scale = 1 } 64 | table.insert(m.widget_types, 'label') 65 | 66 | function m.label:init(options) 67 | self.text = options.text 68 | self.text_scale = options.text_scale 69 | end 70 | 71 | 72 | function m.label:draw(pass, pose) 73 | -- text 74 | pass:setColor(self.parent.palette.text) 75 | pass:text(self.text, 0, 0, Q, 0.2 * self.text_scale) 76 | end 77 | 78 | 79 | function m.label:update(dt, pointer, pointer_name) 80 | end 81 | 82 | 83 | -- BUTTON --------------------------------------------------------------------- 84 | m.button = {} 85 | m.button.defaults = { text = '', thickness = 0.3, callback = nil, held = nil } 86 | table.insert(m.widget_types, 'button') 87 | 88 | function m.button:init(options) 89 | self.interactive = true 90 | self.hovered = false 91 | self.text = options.text 92 | self.callback = options.callback 93 | self.held = options.held 94 | self.thickness = options.thickness 95 | self.depth = self.thickness 96 | end 97 | 98 | 99 | function m.button:draw(pass) 100 | -- body 101 | pass:setColor( 102 | (self.depth < self.thickness / 2 and self.parent.palette.active) or 103 | (self.hovered and self.parent.palette.hover) or 104 | self.parent.palette.cap) 105 | pass:roundrect(0, 0, self.depth / 2, 106 | self.span[1] - 2 * Q, self.span[2] - 2 * Q, self.depth - Q, 107 | 0, 0,1,0, 108 | button_roundness * 0.75, m.segments) 109 | -- frame 110 | pass:setColor(self.parent.palette.inactive) 111 | pass:roundrect(0, 0, Q / 2, 112 | self.span[1], self.span[2], Q, 113 | 0, 0,1,0, 114 | button_roundness * 0.75, m.segments) 115 | -- text 116 | pass:setColor(self.parent.palette.text) 117 | pass:text(self.text, 0, 0, self.depth + Q, text_scale * self.span[2]) 118 | end 119 | 120 | 121 | function m.button:update(dt, pointer, pointer_name) 122 | local new_depth = self.depth 123 | if pointer_name then -- pressing the button inward 124 | new_depth = math.min(self.thickness, math.max(2 * Q, pointer.z)) 125 | end 126 | if pointer_name and self.hovered and -- button passed the threshold 127 | new_depth < self.thickness / 2 then 128 | if self.held then 129 | self.held(self) 130 | end 131 | if self.depth > self.thickness / 2 then 132 | vibrate(pointer_name, 0.2, 0.1) 133 | if self.callback then 134 | self.callback(self) 135 | end 136 | end 137 | end 138 | self.depth = new_depth 139 | self.hovered = pointer_name and true or false 140 | if not pointer_name then -- slowly rebound to above-hover depth when pointer leaves the widget 141 | self.depth = math.min(self.thickness, self.depth + 4 * dt) 142 | end 143 | end 144 | 145 | 146 | function m.button:get() 147 | return self.depth < self.thickness / 2 148 | end 149 | 150 | 151 | -- TOGGLE --------------------------------------------------------------------- 152 | m.toggle = {} 153 | m.toggle.defaults = { text = '', thickness = 0.3, state = false, callback = nil } 154 | table.insert(m.widget_types, 'toggle') 155 | 156 | function m.toggle:init(options) 157 | self.interactive = true 158 | self.state = options.state 159 | self.hovered = false 160 | self.text = options.text 161 | self.callback = options.callback 162 | self.thickness = options.thickness 163 | self.depth = self.thickness 164 | end 165 | 166 | 167 | function m.toggle:draw(pass) 168 | -- body 169 | pass:setColor( 170 | (self.state and self.parent.palette.active) or 171 | (self.hovered and self.parent.palette.hover) or 172 | self.parent.palette.cap) 173 | pass:roundrect(0, 0, self.depth / 2, 174 | self.span[1] - 2 * Q, self.span[2] - 2 * Q, self.depth - Q, 175 | 0, 0,1,0, 176 | button_roundness, m.segments) 177 | -- frame 178 | pass:setColor(self.parent.palette.inactive) 179 | pass:roundrect(0, 0, Q / 2, 180 | self.span[1], self.span[2], Q, 181 | 0, 0,1,0, 182 | button_roundness, m.segments) 183 | -- text 184 | pass:setColor(self.parent.palette.text) 185 | pass:text(self.text, 0, 0, self.depth + Q, text_scale * self.span[2]) 186 | end 187 | 188 | 189 | function m.toggle:update(dt, pointer, pointer_name) 190 | local new_depth = self.depth 191 | if pointer_name then -- pressing the toggle inward 192 | new_depth = math.min(self.thickness, math.max(2 * Q, pointer.z)) 193 | end 194 | if pointer_name and self.hovered and -- toggle button passed the threshold 195 | new_depth < self.thickness / 2 and 196 | self.depth > self.thickness / 2 then 197 | vibrate(pointer_name, 0.2, 0.1) 198 | self.state = not self.state 199 | if self.callback then 200 | self.callback(self, self.state) 201 | end 202 | end 203 | self.depth = new_depth 204 | self.hovered = pointer_name and true or false 205 | if not pointer_name then -- rebound 206 | self.depth = math.min(self.thickness, self.depth + 4 * dt) 207 | end 208 | end 209 | 210 | 211 | function m.toggle:get() 212 | return self.state 213 | end 214 | 215 | 216 | function m.toggle:set(state) 217 | self.state = state and true or false 218 | if self.callback then 219 | self.callback(self, self.state) 220 | end 221 | end 222 | 223 | 224 | -- GLOW ------------------------------------------------------------------------- 225 | m.glow = {} 226 | m.glow.defaults = { text = '', thickness = 0.1, state = false } 227 | table.insert(m.widget_types, 'glow') 228 | 229 | function m.glow:init(options) 230 | self.state = options.state 231 | self.text = options.text 232 | self.thickness = options.thickness 233 | end 234 | 235 | 236 | function m.glow:draw(pass) 237 | -- body 238 | pass:setColor( 239 | (self.state and self.parent.palette.active) or 240 | self.parent.palette.inactive) 241 | pass:cylinder(0, 0, self.thickness / 2, 242 | 0.5, self.thickness, 243 | 0, 0,1,0, true, nil, nil, m.segments * 6) 244 | -- frame 245 | pass:setColor(self.parent.palette.inactive) 246 | pass:cylinder(0, 0, Q / 2, 247 | 0.5 + Q, Q, 248 | 0, 0,1,0, true, nil, nil, m.segments * 6) 249 | -- text 250 | pass:setColor(self.parent.palette.text) 251 | pass:text(self.text, 0, 0, self.thickness + Q, text_scale) 252 | end 253 | 254 | 255 | function m.glow:update(dt, pointer, pointer_name) 256 | end 257 | 258 | 259 | function m.glow:get() 260 | return self.state 261 | end 262 | 263 | 264 | function m.glow:set(state) 265 | self.state = state and true or false 266 | end 267 | 268 | 269 | -- PROGRESS --------------------------------------------------------------------- 270 | m.progress = {} 271 | m.progress.defaults = { text = '', value = 0 } 272 | table.insert(m.widget_types, 'progress') 273 | 274 | function m.progress:init(options) 275 | self.text = options.text 276 | self:set(options.value) 277 | end 278 | 279 | 280 | function m.progress:draw(pass) 281 | -- value as horizontal bar 282 | local y = -0.15 283 | local aw = self.span[1] - S - 2 * Q -- available width 284 | local w = self.value * aw 285 | pass:setColor(self.parent.palette.text) 286 | pass:box(0, y, 2 * Q, aw - 2 * Q, 2 * S, S / 2) 287 | pass:setColor(self.parent.palette.active) 288 | pass:roundrect(-aw / 2 + w / 2, y, 4 * Q, 289 | w, 4 * S, 2 * S, 290 | 0, 0,1,0, 291 | 2 * Q, m.segments) 292 | -- text 293 | pass:setColor(self.parent.palette.text) 294 | pass:text(self.text, 0, 0.2, 2 * Q, text_scale) 295 | end 296 | 297 | 298 | function m.progress:get() 299 | return self.value 300 | end 301 | 302 | 303 | function m.progress:set(value) 304 | self.value = math.max(0, math.min(1, value)) 305 | end 306 | 307 | 308 | function m.progress:update(dt, pointer, pointer_name) 309 | end 310 | 311 | 312 | -- SLIDER --------------------------------------------------------------------- 313 | m.slider = {} 314 | m.slider.__index = m.slider 315 | m.slider.defaults = { text = '', format = '%s %.2f', 316 | min = 0, max = 1, value = 0, step = nil, thickness = 0.15, callback = nil, live_update = true } 317 | table.insert(m.widget_types, 'slider') 318 | 319 | local function roundBy(value, step) 320 | local quant, frac = math.modf(value / step) 321 | return step * (quant + (frac > 0.5 and 1 or 0)) 322 | end 323 | 324 | 325 | function m.slider:init(options) 326 | self.interactive = true 327 | self.text = options.text 328 | self.min = options.min 329 | self.max = options.max 330 | self.thickness = options.thickness 331 | self.callback = options.callback 332 | self.step = options.step 333 | self.format = options.format 334 | self.live_update = options.live_update 335 | self.altered = false 336 | if not options.format and self.step then 337 | local digits = math.max(0, math.ceil(-math.log(self.step, 10))) 338 | self.format = string.format('%%s %%.%df', digits) 339 | end 340 | local value = options.value 341 | if self.step then 342 | value = roundBy(value, self.step) 343 | end 344 | self.value = math.max(self.min, math.min(self.max, value)) 345 | end 346 | 347 | 348 | function m.slider:draw(pass) 349 | -- value knob 350 | local y = -0.15 351 | local aw = self.span[1] - S - 2 * Q -- available width 352 | local pos = (self.value - self.min) / (self.max - self.min) * aw 353 | pass:setColor(self.parent.palette.text) 354 | pass:box(0, y, 2 * Q, aw, 2 * S, S / 2) 355 | pass:setColor(self.parent.palette.active) 356 | pass:roundrect(-aw / 2 + pos, y, 2 * Q + self.thickness / 2, 357 | 2 * S, 6 * S, self.thickness, 358 | 0, 0,1,0, 359 | S, m.segments) 360 | -- frame 361 | pass:setColor( 362 | (self.altered and self.parent.palette.hover) or 363 | self.parent.palette.cap) 364 | pass:roundrect(0, 0, Q / 2, 365 | self.span[1], 1, Q, 366 | 0, 0,1,0, 367 | slider_roundness, m.segments) 368 | -- text 369 | pass:setColor(self.parent.palette.text) 370 | pass:text(string.format(self.format, self.text, self.value), 371 | 0, 0.2, 2 * Q, text_scale) 372 | end 373 | 374 | 375 | function m.slider:update(dt, pointer, pointer_name) 376 | local hovered = pointer_name and true or false 377 | local altered_next = pointer.z < self.thickness 378 | if hovered and altered_next then 379 | local aw = self.span[1] - 16 * Q -- available width 380 | local value = self.min + (aw / 2 + pointer.x) / aw * (self.max - self.min) 381 | self:set(value) 382 | vibrate(pointer_name, 0.2, dt) 383 | end 384 | if not altered_next and self.altered and self.callback then 385 | self.callback(self, self.value) 386 | end 387 | self.altered = altered_next 388 | end 389 | 390 | 391 | function m.slider:get() 392 | return self.value 393 | end 394 | 395 | 396 | function m.slider:set(value) 397 | if self.step then 398 | value = roundBy(value, self.step) 399 | end 400 | self.value = math.max(self.min, math.min(self.max, value)) 401 | if self.callback and self.live_update then 402 | self.callback(self, self.value) 403 | end 404 | end 405 | 406 | 407 | -- PANEL ---------------------------------------------------------------------- 408 | local panel = {} 409 | panel.__index = panel 410 | 411 | local panel_defaults = { 412 | frame = 'backpanel', 413 | palette = m.palettes[1], 414 | } 415 | 416 | function m.panel(options) 417 | options = options or {} 418 | local self = setmetatable({}, panel) 419 | self.is_panel = true 420 | self.frame = options.frame == nil and panel_defaults.frame or options.frame 421 | self.pose = Mat4(options.pose) -- the options.pose is allowed to be nil 422 | self.world_from_screen = Mat4() 423 | self.widgets = {} 424 | self.rows = {{}} 425 | self.span = {1, 1} 426 | self.palette = options.palette or panel_defaults.palette 427 | self.visible = true 428 | self.align_offset = Vec3() 429 | self.layout_options = {'center', 'center'} 430 | table.insert(m.panels, self) 431 | return self 432 | end 433 | 434 | 435 | function panel:reset() 436 | self.widgets = {} 437 | self.rows = {{}} 438 | self.align_offset:set(0, 0) 439 | end 440 | 441 | 442 | function panel:row() 443 | table.insert(self.rows, {}) 444 | end 445 | 446 | 447 | function panel:nest(child_panel) 448 | assert(child_panel) 449 | child_panel.parent = self 450 | self.widget_name = 'nested panel' 451 | self:appendWidget(child_panel) 452 | end 453 | 454 | 455 | local function scaledSpan(widget) 456 | local scale = 1 457 | if widget.is_panel then 458 | if widget.visible then 459 | scale = select(4, widget.pose:unpack()) 460 | else 461 | scale = 0 462 | end 463 | end 464 | return widget.span[1] * scale, widget.span[2] * scale 465 | end 466 | 467 | 468 | -- set poses of contained widgets and calculate own span 469 | function panel:layout(horizontal_alignment, vertical_alignment) 470 | horizontal_alignment = horizontal_alignment or self.layout_options[1] 471 | vertical_alignment = vertical_alignment or self.layout_options[2] 472 | self.layout_options = {horizontal_alignment, vertical_alignment} 473 | local margin = 8 * Q -- margin between rows and widgets in row 474 | self.span[1] = 0 475 | self.span[2] = 0 476 | -- calculate total dimensions 477 | local row_heights = {} 478 | local row_widths = {} 479 | for r, row in ipairs(self.rows) do 480 | local max_height = 0 481 | local row_width = 0 482 | for c, widget in ipairs(row) do 483 | local hspan, vspan = scaledSpan(widget) 484 | max_height = math.max(max_height, vspan) 485 | row_width = row_width + hspan + (c < #row and margin or 0) 486 | end 487 | row_widths[r] = row_width 488 | self.span[1] = math.max(self.span[1], row_width) 489 | table.insert(row_heights, max_height) 490 | self.span[2] = self.span[2] + max_height + (r < #self.rows and margin or 0) 491 | end 492 | -- lay out all widgets across all rows 493 | local x_row, y_row 494 | y_row = self.span[2] / 2 495 | 496 | for r, row in ipairs(self.rows) do 497 | local max_height = row_heights[r] 498 | local row_width = row_widths[r] 499 | if horizontal_alignment == 'left' then 500 | x_row = -self.span[1] / 2 501 | elseif horizontal_alignment == 'right' then 502 | x_row = self.span[1] / 2 - row_width 503 | else 504 | x_row = -row_width / 2 505 | end 506 | local x = x_row 507 | for _, widget in ipairs(row) do 508 | local hspan, vspan = scaledSpan(widget) 509 | local y = y_row 510 | if vertical_alignment == 'top' then 511 | y = y - vspan / 2 512 | elseif vertical_alignment == 'bottom' then 513 | y = y - max_height + vspan / 2 514 | else 515 | y = y - max_height / 2 516 | end 517 | local scale = widget.is_panel and select(4, widget.pose:unpack()) or 1 518 | widget.pose = Mat4(x + hspan / 2, y, 0):scale(scale) 519 | x = x + hspan + margin 520 | end 521 | y_row = y_row - max_height - margin 522 | end 523 | -- calculate offset from the panel's pose to align to edge or corner of the panel 524 | self.align_offset:set(0, 0) 525 | if horizontal_alignment == 'left' then 526 | self.align_offset:add(self.span[1] / 2, 0, 0) 527 | elseif horizontal_alignment == 'right' then 528 | self.align_offset:add(-self.span[1] / 2, 0, 0) 529 | end 530 | if vertical_alignment == 'top' then 531 | self.align_offset:add(0, -self.span[2] / 2, 0) 532 | elseif vertical_alignment == "bottom" then 533 | self.align_offset:add(0, self.span[2] / 2, 0) 534 | end 535 | end 536 | 537 | 538 | function panel:updateWidgets(dt, pointers) 539 | if not self.visible then return end 540 | local z_front, z_back = 1.5, -0.3 -- z boundaries of widget AABB 541 | local panel_pose_inv = self:getWorldPose():invert() 542 | for _, widget in ipairs(self.widgets) do 543 | local closest_pos 544 | local closest_name 545 | if widget.interactive then 546 | closest_pos = vec3(math.huge) 547 | for _, pointer in ipairs(pointers) do -- process each pointer 548 | local pos = vec3() 549 | local is_hovered = false 550 | -- reproject pointer onto panel coordinate system and check widget's AABB 551 | local pos_panel = panel_pose_inv:mul(vec3(pointer[2])) 552 | pos = mat4(widget.pose):invert():mul(pos_panel) -- in panel's coordinate system 553 | is_hovered = pos.x > -widget.span[1] / 2 and pos.x < widget.span[1] / 2 and 554 | pos.y > -widget.span[2] / 2 and pos.y < widget.span[2] / 2 and 555 | pos.z < z_front and pos.z > z_back 556 | if is_hovered and math.abs(pos.z) < math.abs(closest_pos.z) then 557 | closest_pos:set(pos) 558 | closest_name = pointer[1] 559 | end 560 | end 561 | end 562 | widget:update(dt, closest_pos, closest_name) 563 | end 564 | end 565 | 566 | 567 | function panel:getHeadsetPointers(pointers) 568 | for _, hand in ipairs(lovr.headset.getHands()) do 569 | local skeleton = lovr.headset.getSkeleton(hand) 570 | if skeleton then 571 | table.insert(pointers, {hand, vec3(unpack(skeleton[11]))}) 572 | else 573 | table.insert(pointers, {hand, vec3(lovr.headset.getPosition(hand .. '/point'))}) 574 | end 575 | end 576 | end 577 | 578 | 579 | function panel:getMousePointer(pointers, click_offset) 580 | -- flatten pose with parent poses 581 | local pose = self:getWorldPose() 582 | local scale = select(4, pose:unpack()) 583 | -- overwrite hand/left in desktop VR sim, or make a new pointer for 3d desktop 584 | local mouse_pointer = pointers[1] or {'mouse', vec3()} 585 | -- make a ray in 3D space extending from underneath the mouse cursor to -Z 586 | local x, y = lovr.system.getMousePosition() 587 | local ray_origin = vec3(self.world_from_screen:mul(x, y, 1)) 588 | local ray_target = vec3(self.world_from_screen:mul(x, y, 0.001)) 589 | local ray_direction = (ray_target - ray_origin):normalize() 590 | -- intersect the ray onto panel plane and see if it lands within panel 591 | local plane_direction = quat(pose):direction() 592 | local dot = ray_direction:dot(plane_direction) 593 | if math.abs(dot) > 1e-5 then 594 | local plane_pos = vec3(pose) 595 | local ray_length = (plane_pos - ray_origin):dot(plane_direction) / dot 596 | local hit_spot = ray_origin + ray_direction * ray_length 597 | if click_offset then 598 | if lovr.system.isMouseDown(2) then 599 | mouse_pointer[2]:set(hit_spot) 600 | else -- back off the mouse pointer away from panel to emulate the hovering 601 | mouse_pointer[2]:set(hit_spot + plane_direction * -(0.25 * scale)) 602 | end 603 | else 604 | mouse_pointer[2]:set(hit_spot) 605 | end 606 | end 607 | pointers[1] = mouse_pointer 608 | end 609 | 610 | 611 | function panel:getPointers(click_offset) 612 | local pointers = {} 613 | if lovr.headset then 614 | self:getHeadsetPointers(pointers) 615 | end 616 | if m.mouse_available then 617 | self:getMousePointer(pointers, click_offset) 618 | end 619 | return pointers 620 | end 621 | 622 | 623 | function panel:getScreenToWorldTransform(pass) 624 | local w, h = pass:getDimensions() 625 | local clip_from_screen = mat4(-1, -1, 0):scale(2 / w, 2 / h, 1) 626 | local view_pose = mat4(pass:getViewPose(1)) 627 | local view_proj = pass:getProjection(1, mat4()) 628 | -- m.is_orthographic = view_proj[16] == 1 629 | local world_from_screen = view_pose:mul(view_proj:invert()):mul(clip_from_screen) 630 | self.world_from_screen:set(world_from_screen) 631 | end 632 | 633 | 634 | function panel:getWorldPose() 635 | -- for nested panels, collect all transforms up to parentless root 636 | local stacked_pose = mat4() 637 | local parent = self.parent 638 | local child = self 639 | while parent do 640 | stacked_pose = child.pose * stacked_pose 641 | child = parent 642 | parent = parent.parent 643 | end 644 | -- for root apply both alignment translation and its world pose 645 | stacked_pose = mat4(child.pose):translate(child.align_offset) * stacked_pose 646 | return stacked_pose 647 | end 648 | 649 | 650 | function panel:update(dt) 651 | if not self.visible then return end 652 | local pointers = self:getPointers(true) 653 | -- TODO: skip update if outside the panel's AABB 654 | self:updateWidgets(dt, pointers) 655 | end 656 | 657 | 658 | function panel:draw(pass, draw_pointers) 659 | if not self.visible then return end 660 | if m.mouse_available then 661 | self:getScreenToWorldTransform(pass) 662 | end 663 | pass:push() 664 | if not self.parent then 665 | pass:transform(self.pose) 666 | pass:transform(vec3(self.align_offset)) 667 | else 668 | pass:transform(0, 0, Q) 669 | end 670 | pass:setColor(0.820, 0.816, 0.808) 671 | if self.frame == 'backpanel' then 672 | pass:setColor(self.palette.panel) 673 | pass:roundrect(0, 0, -Q / 2, 674 | self.span[1] + 0.5, self.span[2] + 0.5, Q , 675 | 0, 0,1,0, 0.4) 676 | end 677 | pass:setFont(m.font) 678 | for _, w in ipairs(self.widgets) do 679 | pass:push() 680 | pass:transform(w.pose) 681 | w:draw(pass) 682 | pass:pop() 683 | end 684 | pass:pop() 685 | if draw_pointers then 686 | local pointers = pointers or self:getPointers(false) 687 | pass:setColor(0x404040) 688 | local radius = 0.01 689 | for _, pointer in ipairs(pointers) do 690 | pass:sphere(mat4(pointer[2]):scale(radius), m.segments, m.segments) 691 | end 692 | end 693 | end 694 | 695 | 696 | function panel:setVisible(is_visible) 697 | if self.visible and not is_visible then 698 | -- complete any ongoing interactions (hovered pointers) 699 | local dt = lovr.timer.getDelta() 700 | for _, widget in ipairs(self.widgets) do 701 | widget:update(dt, vec3(math.huge), nil) 702 | end 703 | end 704 | self.visible = is_visible 705 | end 706 | 707 | 708 | function panel:appendWidget(widget) 709 | table.insert(self.widgets, widget) 710 | table.insert(self.rows[#self.rows], widget) 711 | end 712 | 713 | 714 | -- creates panel methods for constructing widgets with light OOP based on metatables 715 | function m.initWidgetType(widget_name, widget_proto) 716 | widget_proto.__index = widget_proto 717 | -- define constructor, for example panel:button{text = 'click'} adds new button to the panel 718 | panel[widget_name] = function(self, options) 719 | options = options or {} 720 | setmetatable(options, widget_proto.defaults) 721 | widget_proto.defaults.__index = widget_proto.defaults 722 | local widget = setmetatable({}, widget_proto) 723 | if type(options.span) == 'number' then 724 | widget.span = {options.span, 1} 725 | elseif type(options.span) == 'table' and #options.span == 2 then 726 | widget.span = {options.span[1], options.span[2]} 727 | elseif not options.span then 728 | widget.span = {1, 1} 729 | else 730 | assert(false, "unsupported widget span value") 731 | end 732 | widget.widget_type = widget_name 733 | widget.parent = self 734 | self:appendWidget(widget) 735 | widget:init(options) 736 | return widget 737 | end 738 | end 739 | 740 | 741 | local function initAllWidgets() 742 | for _, widget_name in ipairs(m.widget_types) do 743 | local widget_proto = m[widget_name] 744 | m.initWidgetType(widget_name, widget_proto) 745 | end 746 | end 747 | 748 | initAllWidgets() 749 | 750 | 751 | -- CHUI HELPERS --------------------------------------------------------------- 752 | 753 | function m.setFont(font) -- accepts path to file or loaded font instance 754 | if type(font) == 'string' then -- path to font file 755 | local ok, res = pcall(lovr.graphics.newFont, font, 32, 4) 756 | if ok then 757 | m.font = res 758 | else 759 | print('could not load \'' .. font .. '\', defaulting to built-in Varela Round') 760 | m.font = lovr.graphics.getDefaultFont() 761 | end 762 | elseif tostring(font):match('Font') then -- a font instance used as-is 763 | m.font = font 764 | else 765 | m.font = lovr.graphics.getDefaultFont() 766 | end 767 | end 768 | 769 | -- convenience functions for multiple panels, user can also just call :update & :draw on the panel 770 | 771 | function m.update(dt) -- neccessary for UI interactions 772 | for _, pnl in ipairs(m.panels) do 773 | if not pnl.parent then 774 | pnl:update(dt) 775 | end 776 | end 777 | end 778 | 779 | 780 | function m.draw(pass, draw_pointers) 781 | for _, pnl in ipairs(m.panels) do 782 | if not pnl.parent then 783 | pnl:draw(pass, draw_pointers) 784 | end 785 | end 786 | end 787 | 788 | 789 | function m.reset() -- forget the collected panels 790 | m.panels = {} 791 | end 792 | 793 | return m 794 | -------------------------------------------------------------------------------- /game-menu/main.lua: -------------------------------------------------------------------------------- 1 | local chui = require'chui' 2 | 3 | local main_panel, options_panel 4 | local is_wireframe = false 5 | local rgb_filter = {true, true, true} 6 | 7 | function lovr.load() 8 | local pose = mat4(-0.2, 1.7, -0.4):scale(0.1) 9 | -- Main Menu Buttons 10 | main_panel = chui.panel{ pose=pose, palette=chui.palettes[3] } 11 | playbutton_panel = chui.panel{ frame='none', palette=chui.palettes[1] } 12 | playbutton_panel:button{ text='CONTINUE', thickness=0.3, span={3, 1.4}, callback = 13 | function() 14 | main_panel.visible = false 15 | end } 16 | playbutton_panel:layout() 17 | main_panel:nest(playbutton_panel) 18 | main_panel:row() 19 | main_panel:button{ text='OPTIONS', thickness=0.2, span=3, callback = 20 | function() 21 | main_panel.visible = false 22 | options_panel.visible = true 23 | end } 24 | main_panel:row() 25 | main_panel:row() 26 | main_panel:button{ thickness=0.2, span={3, 0.8}, text='HELP' } 27 | main_panel:row() 28 | main_panel:button{ thickness=0.2, span={3, 0.8}, text='CREDITS' } 29 | main_panel:row() 30 | main_panel:row() 31 | main_panel:button{ thickness=0.2, span={3, 0.6}, text='EXIT »', callback=function() lovr.event.quit() end } 32 | main_panel:layout('left') 33 | 34 | -- Options Panel (initially hidden) 35 | options_panel = chui.panel{ pose=mat4(pose):scale(0.6), palette=chui.palettes[3] } 36 | options_panel:label{ text='Video', text_scale=2 } 37 | options_panel:row() 38 | options_panel:label{ text='Wireframe' } 39 | options_panel:toggle{ span={0.8, 0.8}, thickness=0.15, state=false, callback = 40 | function(_, state) 41 | is_wireframe = state 42 | end } 43 | options_panel:layout('left') 44 | options_panel:row() 45 | options_panel:label{ text='Color filtering' } 46 | options_panel:toggle{ span={0.8, 0.8}, thickness=0.15, state=true, text='R', callback=function(_,s) rgb_filter[1] = s end } 47 | options_panel:toggle{ span={0.8, 0.8}, thickness=0.15, state=true, text='G', callback=function(_,s) rgb_filter[2] = s end } 48 | options_panel:toggle{ span={0.8, 0.8}, thickness=0.15, state=true, text='B', callback=function(_,s) rgb_filter[3] = s end } 49 | options_panel:row() 50 | options_panel:label{ text='Audio', text_scale=2 } 51 | options_panel:row() 52 | options_panel:toggle{ span={0.8, 0.8}, thickness=0.15, state=true } 53 | options_panel:slider{ text='Sound', step=1, min=0, max=100, value=80, format='%s %d', span=4 } 54 | options_panel:row() 55 | options_panel:toggle{ span={0.8, 0.8}, thickness=0.15, state=true } 56 | options_panel:slider{ text='Music', step=1, min=0, max=100, value=75, format='%s %d', span=4 } 57 | options_panel:row() 58 | options_panel:row() 59 | options_panel:button{ text='« BACK', thickness=0.15, span={1.4, 0.8}, callback = 60 | function() 61 | main_panel.visible = true 62 | options_panel.visible = false 63 | end } 64 | options_panel:layout('left') 65 | options_panel.visible = false 66 | end 67 | 68 | 69 | -- a 3D scene placeholder 70 | lovr.graphics.setBackgroundColor(0.059, 0.165, 0.247) 71 | local palette = {{0.031, 0.078, 0.118}, {0.125, 0.224, 0.310}, {0.965, 0.839, 0.741}, {0.765, 0.639, 0.541}, {0.600, 0.459, 0.467}, {0.506, 0.384, 0.443}, {0.306, 0.286, 0.373}} 72 | 73 | function sceneDraw(pass) 74 | local t = lovr.timer.getTime() 75 | for x = -64, 64, 4 do 76 | for z = -64, 64, 4 do 77 | z = z + (x % 8) * 0.5 78 | local h = lovr.math.noise(x, z) * 3 + (x*x + z*z) * 5e-3 79 | pass:setColor(palette[1 + math.floor(h * 7) % #palette]) 80 | h = h * (1 + 0.05 * math.sin(t * 0.2 + h)) 81 | pass:cylinder(x, -3 + h / 2, z, 2.2, h, math.pi/2, 1,0,0, true, nil, nil, 6) 82 | end 83 | end 84 | end 85 | 86 | 87 | function lovr.draw(pass) 88 | main_panel.pose:rotate(math.sin(lovr.timer.getTime() * 4) * 0.002, 0, 1, 0) 89 | options_panel.pose:rotate(math.sin(lovr.timer.getTime() * 4) * 0.002, 0, 1, 0) 90 | pass:setColorWrite(unpack(rgb_filter)) 91 | pass:setWireframe(is_wireframe) 92 | sceneDraw(pass) 93 | chui.draw(pass, true) 94 | end 95 | 96 | 97 | function lovr.update(dt) 98 | chui.update(dt) 99 | end 100 | 101 | 102 | function lovr.keypressed(key) 103 | if key == 'escape' then 104 | main_panel.visible = true 105 | options_panel.visible = false 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /media/game-menu-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/game-menu-options.png -------------------------------------------------------------------------------- /media/game-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/game-menu.png -------------------------------------------------------------------------------- /media/nested-alignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/nested-alignment.png -------------------------------------------------------------------------------- /media/palette-designer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/palette-designer.png -------------------------------------------------------------------------------- /media/panel-nesting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/panel-nesting.png -------------------------------------------------------------------------------- /media/row-alignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/row-alignment.png -------------------------------------------------------------------------------- /media/sequencer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/sequencer.png -------------------------------------------------------------------------------- /media/showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/showcase.png -------------------------------------------------------------------------------- /media/vqwerty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/media/vqwerty.png -------------------------------------------------------------------------------- /nested-alignment/main.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";../?.lua" 2 | 3 | local chui = require'chui' 4 | 5 | -- panel with 5x5 randomly sized toggle buttons 6 | local inner_panel = chui.panel{ pose=mat4():scale(1.3), palette=chui.palettes[5] } 7 | lovr.math.setRandomSeed(0) 8 | for i = 1, 5 do 9 | for j = 1, 5 do 10 | inner_panel:toggle{ span = {0.1 + lovr.math.random(), 0.1 + lovr.math.random()}, thickness = 0.2 } 11 | end 12 | inner_panel:row() 13 | end 14 | inner_panel:layout() 15 | 16 | -- main panel with nested panel and alignment control buttons 17 | local main_panel = chui.panel{ pose = mat4():translate(0, 1.7, -0.4):scale(0.05), palette=chui.palettes[13] } 18 | main_panel:spacer{ span = {0, 0.1} } 19 | main_panel:row() 20 | main_panel:nest(inner_panel) 21 | main_panel:row() 22 | main_panel:spacer{ span = {0, 0.5} } 23 | main_panel:row() 24 | main_panel:button{ text='top-left', callback=function(_, state) inner_panel:layout('left', 'top') end, span=2 } 25 | main_panel:button{ text='top-center', callback=function(_, state) inner_panel:layout('center', 'top') end, span=2 } 26 | main_panel:button{ text='top-right', callback=function(_, state) inner_panel:layout('right', 'top') end, span=2 } 27 | main_panel:row() 28 | main_panel:button{ text='center-left', callback=function(_, state) inner_panel:layout('left', 'center') end, span=2 } 29 | main_panel:button{ text='center-center', callback=function(_, state) inner_panel:layout('center', 'center') end, span=2 } 30 | main_panel:button{ text='center-right', callback=function(_, state) inner_panel:layout('right', 'center') end, span=2 } 31 | main_panel:row() 32 | main_panel:button{ text='bottom-left', callback=function(_, state) inner_panel:layout('left', 'bottom') end, span=2 } 33 | main_panel:button{ text='bottom-center', callback=function(_, state) inner_panel:layout('center', 'bottom') end, span=2 } 34 | main_panel:button{ text='bottom-right', callback=function(_, state) inner_panel:layout('right', 'bottom') end, span=2 } 35 | main_panel:layout() 36 | 37 | lovr.graphics.setBackgroundColor(1,1,1) 38 | 39 | function lovr.draw(pass) 40 | chui.draw(pass) 41 | end 42 | 43 | function lovr.update(dt) 44 | chui.update(dt) 45 | end 46 | 47 | 48 | -------------------------------------------------------------------------------- /palette-designer/coloring.lua: -------------------------------------------------------------------------------- 1 | -- color manipulation functions 2 | local m = {} 3 | 4 | -- compute red/green/blue table from the hue/satureation/lightness table 5 | function m.fromHSL(hsla) -- hsla is table array 6 | local h, s, l, a = unpack(hsla) 7 | a = a or 1 8 | -- hsl to rgb, input and output range: 0 - 1 9 | if s < 0 then 10 | return {l,l,l,a} 11 | end 12 | h = h * 6 13 | local c = (1 - math.abs(2 * l - 1)) * s 14 | local x = (1 - math.abs(h % 2 - 1)) * c 15 | local md = (l - 0.5 * c) 16 | local r, g, b 17 | if h < 1 then r, g, b = c, x, 0 18 | elseif h < 2 then r, g, b = x, c, 0 19 | elseif h < 3 then r, g, b = 0, c, x 20 | elseif h < 4 then r, g, b = 0, x, c 21 | elseif h < 5 then r, g, b = x, 0, c 22 | else r, g, b = c, 0, x 23 | end 24 | return {r + md, g + md, b + md, a} 25 | end 26 | 27 | 28 | -- compute red/green/blue table from the hexcode 29 | function m.fromHexcode(hexcode) 30 | if type(hexcode) == 'table' then return {unpack(hexcode)} end 31 | local r = bit.band(bit.rshift(hexcode, 16), 0xff) / 255 32 | local g = bit.band(bit.rshift(hexcode, 8), 0xff) / 255 33 | local b = bit.band(bit.rshift(hexcode, 0), 0xff) / 255 34 | return {r, g, b, 1} 35 | end 36 | 37 | 38 | function m.toHexcode(colorTable) 39 | local r, g, b, _ = unpack(colorTable) 40 | r, g, b = math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5) 41 | local num = bit.lshift(r, 16) + bit.lshift(g, 8) + bit.lshift(b, 0) 42 | return string.format('0x%06x', num) 43 | end 44 | 45 | 46 | -- compute hue/saturation/lightness table from the red/green/blue table, or hexcode 47 | function m.toHSL(rgba) -- rgba is table array or hexcode 48 | local r, g, b, a 49 | if type(rgba) == 'table' then 50 | r, g, b, a = unpack(rgba) 51 | else 52 | r, g, b, a = unpack(m.fromHexcode(rgba)) 53 | end 54 | a = a or 1 55 | local min, max = math.min(r, g, b), math.max(r, g, b) 56 | local h, s, l = 0, 0, (max + min) / 2 57 | if max ~= min then 58 | local d = max - min 59 | s = l > 0.5 and d / (2 - max - min) or d / (max + min) 60 | if max == r then 61 | local mod = 6 62 | if g > b then mod = 0 end 63 | h = (g - b) / d + mod 64 | elseif max == g then 65 | h = (b - r) / d + 2 66 | else 67 | h = (r - g) / d + 4 68 | end 69 | end 70 | h = h / 6 71 | return {h, s, l, a} 72 | end 73 | 74 | 75 | -- get the HSL string representation of a given color, or the edited color 76 | function m.toStringHSL(color) 77 | if color then 78 | return string.format('fromHSL(%1.2f, %1.2f, %1.2f)', unpack(m.toHSL(color))) 79 | else 80 | return string.format('fromHSL(%1.2f, %1.2f, %1.2f) -- %s', m.hsla[1], m.hsla[2], m.hsla[3], tostring(m.edited)) 81 | end 82 | end 83 | 84 | 85 | function m.chuiPaletteString(palette) 86 | local out = {} 87 | for _, name in ipairs({'cap', 'inactive', 'active', 'hover', 'text', 'panel'}) do 88 | color = palette[name] 89 | color = type(color) == 'table' and color or m.fromHexcode(color) 90 | table.insert(out, string.format('%s = %s', name, m.toHexcode(color))) 91 | end 92 | return '{ ' .. table.concat(out, ', ') .. ' },' 93 | end 94 | 95 | 96 | function m.info() 97 | local out = {} 98 | table.insert(out, 'local palette = {') 99 | for name, color in pairs(m.palette) do 100 | color = type(color) == 'table' and color or m.fromHexcode(color) 101 | if type(name) == 'string' then 102 | table.insert(out, string.format(' %s = {%1.3f, %1.3f, %1.3f}, -- %s', name, 103 | color[1], color[2], color[3], 104 | m.toHexcode(color))) 105 | else 106 | table.insert(out, string.format(' {%1.3f, %1.3f, %1.3f}, -- %s'), 107 | color[1], color[2], color[3], 108 | m.toHexcode(color)) 109 | end 110 | end 111 | table.insert(out, '}') 112 | table.insert(out, string.format('selected = %s', tostring(m.edited))) 113 | return table.concat(out, '\n') 114 | end 115 | 116 | 117 | return m 118 | -------------------------------------------------------------------------------- /palette-designer/main.lua: -------------------------------------------------------------------------------- 1 | -- chui palette designer for modifying and creating new color palettes 2 | package.path = package.path .. ";../?.lua" -- needed only if chui.lua is in parent directory 3 | 4 | local chui = require'chui' 5 | local coloring = require'coloring' 6 | 7 | lovr.graphics.setBackgroundColor(1,1,1) 8 | 9 | local palette = chui.palettes[1] 10 | local panel = chui.panel{ pose = mat4(0, 1.7, -0.4):scale(0.05), palette=palette } 11 | 12 | panel:slider{ text = 'edited palette', step=1, min=1, max=#chui.palettes, span=4, format = '%s %d', 13 | callback = function(_, value) 14 | panel.palette = chui.palettes[value] 15 | -- update existing sliders 16 | for _, panel in ipairs(panel.widgets) do 17 | if panel.color_name then 18 | local h,s,l = unpack(coloring.toHSL(panel.palette[panel.color_name])) 19 | panel.slider_h:set(h) 20 | panel.slider_s:set(s) 21 | panel.slider_l:set(l) 22 | end 23 | end 24 | end } 25 | panel:button{ text = 'dump', thickness=0.2, callback = 26 | function() 27 | print(coloring.chuiPaletteString(palette)) 28 | end } 29 | panel:row() 30 | 31 | local function createHSLpanel(color_name) 32 | local color_panel = chui.panel{ frame = 'none' } 33 | color_panel.palette = setmetatable({}, { 34 | __index = function(table, key) 35 | if key == 'inactive' then 36 | return panel.palette[color_name] 37 | else 38 | return panel.palette[key] 39 | end 40 | end, 41 | }) 42 | 43 | local h,s,l = unpack(coloring.toHSL(panel.palette[color_name])) 44 | local slider_h, slider_s, slider_l 45 | local sliderChange = function() 46 | local color = coloring.fromHSL{ 47 | color_panel.slider_h.value, 48 | color_panel.slider_s.value, 49 | color_panel.slider_l.value 50 | } 51 | panel.palette[color_name] = color 52 | end 53 | color_panel:label{ text = color_name:upper(), span = { 1, 0.2 }, text_scale = 1.6 } 54 | color_panel:glow() 55 | color_panel:row() 56 | 57 | color_panel.slider_h = color_panel:slider{ text='hue', value=h, span=2.5, callback=sliderChange }; color_panel:row() 58 | color_panel.slider_s = color_panel:slider{ text='saturation', value=s, span=2.5, callback=sliderChange }; color_panel:row() 59 | color_panel.slider_l = color_panel:slider{ text='lightness', value=l, span=2.5, callback=sliderChange }; color_panel:row() 60 | color_panel.color_name = color_name 61 | color_panel:layout() 62 | return color_panel 63 | end 64 | 65 | 66 | panel:nest(createHSLpanel('cap')) 67 | panel:spacer{ span= 0.2 } 68 | panel:nest(createHSLpanel('inactive')) 69 | panel:spacer{ span= 0.2 } 70 | panel:nest(createHSLpanel('active')) 71 | panel:row() 72 | panel:nest(createHSLpanel('hover')) 73 | panel:spacer{ span= 0.2 } 74 | panel:nest(createHSLpanel('text')) 75 | panel:spacer{ span= 0.2 } 76 | panel:nest(createHSLpanel('panel')) 77 | panel:row() 78 | 79 | panel:label{ text = 'previews:' } 80 | local led = panel:glow{ text = 'GLW' } 81 | local led = panel:glow{ text = 'GLW', state=true } 82 | panel:toggle{ text = 'TGL' } 83 | panel:toggle{ text = 'TGL', state=true } 84 | panel:progress{ text = 'PRG', value = 0.7 } 85 | panel:slider{ text = 'SLD', value = 0.3 } 86 | panel:layout() 87 | 88 | 89 | function lovr.update(dt) 90 | chui.update(dt) 91 | end 92 | 93 | function lovr.draw(pass) 94 | chui.draw(pass) 95 | end 96 | -------------------------------------------------------------------------------- /sequencer/conf.lua: -------------------------------------------------------------------------------- 1 | lovr.conf = function(t) 2 | t.window.resizable = true 3 | t.modules.headset = false 4 | t.window.width = 1000 5 | t.window.height = 600 6 | end 7 | -------------------------------------------------------------------------------- /sequencer/dm-bass.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/sequencer/dm-bass.ogg -------------------------------------------------------------------------------- /sequencer/dm-hatclosed.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/sequencer/dm-hatclosed.ogg -------------------------------------------------------------------------------- /sequencer/dm-hatopen.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/sequencer/dm-hatopen.ogg -------------------------------------------------------------------------------- /sequencer/dm-snare.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmiskovic/chui/e6864c8af6fef80acba759624c16028de42d15f7/sequencer/dm-snare.ogg -------------------------------------------------------------------------------- /sequencer/main.lua: -------------------------------------------------------------------------------- 1 | -- a toy drum sequencer working in 2D desktop mode 2 | package.path = package.path .. ";../?.lua" -- needed for chui.lua to be located in parent directory 3 | 4 | local chui = require'chui' 5 | 6 | local step_count = 12 7 | local bar_length = 4 * 60 / 120 8 | 9 | local instruments = { 10 | { pitch = 1.0, volume = 1, name='snare', sample_path='dm-snare.ogg' }, 11 | { pitch = 1.4, volume = 1, name='hh-open', sample_path='dm-hatopen.ogg' }, 12 | { pitch = 1.2, volume = 1, name='hh-closed', sample_path='dm-hatclosed.ogg' }, 13 | { pitch = 0.8, volume = 1, name='bass', sample_path='dm-bass.ogg' }, 14 | -- add more as desired 15 | } 16 | 17 | -- load in the samples; we later clone them to be able to play multiple samples at once 18 | for _, instrument in ipairs(instruments) do 19 | instrument.sample = lovr.audio.newSource(instrument.sample_path, {pitchable=true, spatial=false}) 20 | end 21 | 22 | 23 | local function defaultdict(default_value_factory) 24 | local t = {} 25 | local metatable = {} 26 | metatable.__index = function(tbl, key) 27 | if not rawget(tbl, key) then 28 | rawset(tbl, key, default_value_factory(key)) 29 | end 30 | return rawget(tbl, key) 31 | end 32 | return setmetatable(t, metatable) 33 | end 34 | local seq_table = defaultdict(function() return {} end) 35 | 36 | 37 | -- start building the UI; first the instrument lanes and then general controls 38 | 39 | local sequencer_panel = chui.panel{ palette=chui.palettes[6] } 40 | local volume_sliders = {} 41 | local pitch_sliders = {} 42 | 43 | for r, instrument in ipairs(instruments) do 44 | -- a trigger pushbutton to play the sample 45 | sequencer_panel:button{ span=0.3, callback=function() instrument.sample:clone():play() end } 46 | -- sample name 47 | sequencer_panel:label{ text=instrument.name } 48 | -- volume and pitch parameters 49 | volume_sliders[r] = sequencer_panel:slider{ span=1.5, text='vol', min=0, max=1, value= instrument.volume } 50 | pitch_sliders[r] = sequencer_panel:slider{ span=2, text='pitch', min=0.25, max=4, value= instrument.pitch } 51 | -- a row of toggle buttons that activate the sequencer 52 | for c = 1, step_count do 53 | sequencer_panel:toggle{span=0.6, callback = function(_, state) 54 | seq_table[r][c] = state 55 | end} 56 | end 57 | -- finish the row after each instrument's widgets to prepare for next 58 | sequencer_panel:row() 59 | end 60 | 61 | -- tempo and bar progress spans are manually adjusted to be aligned with previous widgets 62 | local tempo_slider = sequencer_panel:slider{ span=5.5, text='tempo', min=64, max=216, value=116, step=0.5 } 63 | local progress_bar = sequencer_panel:progress{ span=9.5, text='bar' } 64 | -- after adding the widgets, layout them in horizontally centered rows 65 | sequencer_panel:layout() 66 | 67 | 68 | local last_step = 7 69 | local time = 0 70 | lovr.graphics.setBackgroundColor(1,1,1) 71 | 72 | function lovr.update(dt) 73 | sequencer_panel:update(dt) -- allow ui to process interactions 74 | bar_length = 4 * 60 / tempo_slider:get() -- use slide to scale the tempo_slider 75 | time = time + dt / bar_length * step_count 76 | local bar_time = time % step_count -- bar time, normalized to [0, step_count] range 77 | progress_bar:set(bar_time / step_count) 78 | -- play the step notes if the bar timer beyond the previous step 79 | if math.floor(bar_time) ~= last_step then 80 | -- play any active toggle bar on this step 81 | last_step = math.floor(bar_time) 82 | for row, instrument in ipairs(instruments) do 83 | instrument.volume = volume_sliders[row]:get() 84 | instrument.pitch = pitch_sliders[row]:get() 85 | local tgl = seq_table[row][last_step + 1] 86 | if tgl then 87 | local sample = instrument.sample:clone() 88 | sample:setVolume(instrument.volume) 89 | sample:setPitch(instrument.pitch) 90 | sample:play() 91 | end 92 | end 93 | end 94 | end 95 | 96 | 97 | function lovr.draw(pass) 98 | -- use orthographic projection; dynamically adapt the panel size/position to the window dimensions 99 | local screen_width, screen_height = pass:getDimensions() 100 | local scale = screen_width / sequencer_panel.span[1] * 0.95 101 | sequencer_panel.pose:set(screen_width / 2, screen_height / 2, 0):scale(scale) 102 | pass:setProjection(1, mat4():orthographic( 103 | 0, screen_width, screen_height, 0, 104 | scale, -scale)) 105 | 106 | sequencer_panel:draw(pass, true) -- draw panel itself and the interacting pointer 107 | end 108 | -------------------------------------------------------------------------------- /showcase/main.lua: -------------------------------------------------------------------------------- 1 | -- widgets test app and palette editor 2 | package.path = package.path .. ";../?.lua" -- needed only if chui.lua is in parent directory 3 | 4 | local chui = require'chui' 5 | 6 | local pose = mat4() 7 | :translate(0, 1.6, -0.4) 8 | :rotate(-0.2, 1,0,0) 9 | :scale(0.06) 10 | 11 | local panel = chui.panel{ pose = pose } 12 | 13 | panel:label{ text='chui', span=1.4, text_scale=4 } 14 | panel:label{ text='testing app', span=1 } 15 | panel:spacer{ span=1.5 } 16 | panel:slider{ text = 'palette', step=1, min=1, max=#chui.palettes, span=3, format = '%s %d', 17 | callback = function(_, value) 18 | panel.palette = chui.palettes[value] 19 | end } 20 | panel:row() 21 | 22 | 23 | -- a zoo of built-in widgets 24 | local glow, progress 25 | panel:label{ text = 'spacer >', span = .5 } 26 | panel:spacer{ span = .2 } 27 | panel:label{ text = '<', span = .2 } 28 | panel:label{ text='|', span=0.2, text_scale=3 } 29 | panel:label{ text = 'label' } 30 | panel:label{ text='|', span=0.2, text_scale=3 } 31 | panel:button{ text='button', span=2, thickness=0.1, callback= 32 | function(self) 33 | glow:set(not glow:get()) 34 | end } 35 | panel:label{ text='|', span=0.2, text_scale=3 } 36 | glow = panel:glow{ text='glow', state=true } 37 | panel:row() 38 | panel:toggle{ text='toggle', span={1.5, 1.5} } 39 | panel:label{ text='|', span=0.2, text_scale=3 } 40 | progress = panel:progress{ text='progress', span = 2 } 41 | panel:label{ text='|', span=0.2, text_scale=3 } 42 | panel:slider{ text='slider', span=3, step = 0.25, min=1, max = 5, callback= 43 | function(self, value) 44 | local normalized = (value - self.min) / (self.max - self.min) 45 | progress:set(normalized) 46 | end } 47 | panel:layout() 48 | 49 | lovr.graphics.setBackgroundColor(1,1,1) 50 | 51 | function lovr.update(dt) 52 | panel:update(dt) 53 | end 54 | 55 | 56 | function lovr.draw(pass) 57 | pass:setWireframe(lovr.system.isKeyDown('tab')) -- x-vision 58 | chui.draw(pass) 59 | pass:setColor(0.8, 0.9, 0.5) 60 | end 61 | -------------------------------------------------------------------------------- /vkeyboard/conf.lua: -------------------------------------------------------------------------------- 1 | function lovr.conf(t) 2 | --t.headset.offset = 0.0 3 | t.window.resizable = true 4 | end -------------------------------------------------------------------------------- /vkeyboard/main.lua: -------------------------------------------------------------------------------- 1 | -- virtual keyboard is both a demo app for chui and a useable keyboard module 2 | package.path = package.path .. ";../?.lua" -- needed for chui.lua to be located in parent directory 3 | 4 | local chui = require'chui' 5 | 6 | local keyboard = require'vqwerty' -- pressed keys are received with lovr.textinput & lovr.keypressed 7 | keyboard.pose:set(mat4(0, 1.6, -0.35):scale(0.03)) 8 | 9 | 10 | -- chui lib doesn't have a rich input field, only the label widget 11 | -- we expand the lable with basic letter entry and backspace letter removal 12 | -- minimal text interactions, edit cursor fixed to the end 13 | 14 | local textbox_panel = chui.panel() 15 | textbox_panel.pose:set(mat4(0, 2, -2):scale(0.6)) 16 | local textbox = textbox_panel:label{ span=4 } -- centered text 17 | 18 | textbox.textinput = function(self, char) 19 | self.text = self.text .. char 20 | end 21 | 22 | textbox.keypressed = function(self, key) 23 | if key == 'backspace' then 24 | self.text = self.text:sub(1, math.max(0, #self.text - 1)) 25 | end 26 | end 27 | 28 | textbox_panel:layout() 29 | 30 | lovr.graphics.setBackgroundColor(1,1,1) 31 | 32 | function lovr.textinput(char) 33 | textbox:textinput(char) 34 | end 35 | 36 | 37 | function lovr.keypressed(key) 38 | textbox:keypressed(key) 39 | end 40 | 41 | 42 | function lovr.update(dt) 43 | keyboard:update(dt) -- allow keyboard panel to process interactions 44 | end 45 | 46 | 47 | function lovr.draw(pass) 48 | chui.draw(pass, true) -- draw all collected panels, as well as interaction pointer 49 | end 50 | -------------------------------------------------------------------------------- /vkeyboard/vqwerty.lua: -------------------------------------------------------------------------------- 1 | -- vqwerty: a panel with virtual keyboard; injects keys into lovr event queue 2 | local chui = require'chui' 3 | 4 | local panel = chui.panel{ 5 | palette = chui.palettes[9], 6 | } 7 | 8 | -- keyboard layout definition 9 | -- single-press special keys are in <>, long-hold special keys are in [] 10 | local layout_text_str = [[ 11 | 12 | ` 1 2 3 4 5 6 7 8 9 0 - = 13 | q w e r t y u i o p [ ] \ 14 | [capslock] a s d f g h j k l ; ' 15 | [lshift] z x c v b n m , . / [rshift] 16 | [lctrl] [lalt] space [ralt] [rctrl] 17 | ]] 18 | local layout_shift_str = [[ 19 | 20 | ~ ! @ # $ % ^ & * ( ) _ + 21 | Q W E R T Y U I O P < } | 22 | [capslock] A S D F G H J K L : " 23 | [lshift] Z X C V B N M < > ? [rshift] 24 | [lctrl] [lalt] space [ralt] [rctrl] 25 | ]] 26 | 27 | 28 | local function splitLayout(str) 29 | local tbl = {} 30 | local row = 1 31 | local col 32 | for row_str in str:gmatch('[^\r\n]+') do 33 | col = 1 34 | for key in row_str:gmatch('%S+') do 35 | tbl[row] = tbl[row] or {} 36 | tbl[row][col] = key 37 | col = col + 1 38 | end 39 | row = row + 1 40 | end 41 | return tbl 42 | end 43 | 44 | 45 | local layout_text = splitLayout(layout_text_str) 46 | local layout_shift = splitLayout(layout_shift_str) 47 | 48 | local modifiers = { 49 | ctrl = false, shift = false, alt = false, capslock = false 50 | } 51 | 52 | local function textinputCB(self) 53 | local text = self.data.text 54 | if modifiers.shift then 55 | text = self.data.shift 56 | elseif modifiers.capslock then 57 | text = text:upper() 58 | end 59 | lovr.event.push('textinput', text) 60 | lovr.event.push('keypressed', self.text) 61 | lovr.event.push('keyreleased', self.text) 62 | end 63 | 64 | 65 | local function keypressCB(self) 66 | lovr.event.push('keypressed', self.text) 67 | lovr.event.push('keyreleased', self.text) 68 | end 69 | 70 | 71 | local function toggleCB(self, state) 72 | -- remove l/r prefix of alt, shift, ctrl 73 | local modifier = self.text == 'capslock' and 'capslock' or self.text:sub(2, #self.text) 74 | modifiers[modifier] = state 75 | if state then 76 | lovr.event.push('keypressed', self.text) 77 | else 78 | lovr.event.push('keyreleased', self.text) 79 | end 80 | if modifier == 'shift' then -- update keyboard to alternative caps 81 | for _, widget in ipairs(panel.widgets) do 82 | widget.text = state and widget.data.shift or widget.data.text 83 | end 84 | end 85 | end 86 | 87 | 88 | -- roll out the keyboard keys from the layout definition 89 | for row = 1, #layout_text do 90 | for col = 1, #layout_text[row] do 91 | local text = layout_text[row][col] 92 | local shift = layout_shift[row][col] 93 | local btn 94 | if text:find('<%S+>') then -- special key like , send short press 95 | btn = panel:button{ text=text:sub(2, #text - 1), span=1.2, callback=keypressCB } 96 | shift = shift:sub(2, #text - 1) 97 | elseif text:find('%[%S+%]') then -- modifier keys like , toggle status 98 | btn = panel:toggle{ text=text:sub(2, #text - 1), span=2, callback=toggleCB } 99 | shift = shift:sub(2, #text - 1) 100 | elseif text == 'space' then -- space is a placeholder for ' ' 101 | btn = panel:button{ text=' ', span=9, callback=textinputCB, thickness=0.2 } 102 | shift = ' ' 103 | else -- normal letter-inserting button 104 | btn = panel:button{ text=text, span=1, callback=textinputCB } 105 | end 106 | -- the btn.data is unused in chui lib, we're free to use it for virutal keyboard 107 | btn.data = { text = btn.text, shift = shift } 108 | end 109 | if row < #layout_text then 110 | panel:row() 111 | end 112 | end 113 | 114 | panel:layout() 115 | 116 | return panel 117 | --------------------------------------------------------------------------------