From 29e910ba3c716f12526cd03125bc56936a661321 Mon Sep 17 00:00:00 2001 From: Mikayla Fischler Date: Sat, 16 Sep 2023 21:06:16 -0400 Subject: [PATCH] #342 added element focusing feature to graphics library --- .vscode/settings.json | 1 + graphics/core.lua | 18 +- graphics/element.lua | 454 ++++++++++++------- graphics/elements/controls/app.lua | 10 +- graphics/elements/controls/hazard_button.lua | 2 +- graphics/elements/controls/push_button.lua | 28 +- graphics/elements/controls/sidebar.lua | 10 +- graphics/elements/form/number_field.lua | 136 ++++++ graphics/elements/listbox.lua | 14 +- graphics/events.lua | 89 +++- 10 files changed, 540 insertions(+), 222 deletions(-) create mode 100644 graphics/elements/form/number_field.lua diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e81f80..b5e7976 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "colors", "fs", "http", + "keys", "parallel", "periphemu", "peripheral", diff --git a/graphics/core.lua b/graphics/core.lua index 5177b8c..2305990 100644 --- a/graphics/core.lua +++ b/graphics/core.lua @@ -7,7 +7,7 @@ local flasher = require("graphics.flasher") local core = {} -core.version = "1.1.3" +core.version = "2.0.0" core.flasher = flasher core.events = events @@ -15,11 +15,7 @@ core.events = events -- Core Types ---@enum TEXT_ALIGN -core.TEXT_ALIGN = { - LEFT = 1, - CENTER = 2, - RIGHT = 3 -} +core.TEXT_ALIGN = { LEFT = 1, CENTER = 2, RIGHT = 3 } ---@class graphics_border ---@field width integer @@ -73,15 +69,9 @@ end function core.cpair(a, b) return { -- color pairs - color_a = a, - color_b = b, - blit_a = colors.toBlit(a), - blit_b = colors.toBlit(b), + color_a = a, color_b = b, blit_a = colors.toBlit(a), blit_b = colors.toBlit(b), -- aliases - fgd = a, - bkg = b, - blit_fgd = colors.toBlit(a), - blit_bkg = colors.toBlit(b) + fgd = a, bkg = b, blit_fgd = colors.toBlit(a), blit_bkg = colors.toBlit(b) } end diff --git a/graphics/element.lua b/graphics/element.lua index 05fbb74..4399ac1 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -19,6 +19,7 @@ local element = {} ---@field gframe? graphics_frame frame instead of x/y/width/height ---@field fg_bg? cpair foreground/background colors ---@field hidden? boolean true to hide on initial draw +---@field can_focus? boolean true if this element can be focused, false by default ---@alias graphics_args graphics_args_generic ---|waiting_args @@ -32,6 +33,7 @@ local element = {} ---|spinbox_args ---|switch_button_args ---|tabbar_args +---|number_field_args ---|alarm_indicator_light ---|core_map_args ---|data_indicator_args @@ -69,6 +71,7 @@ local element = {} function element.new(args, child_offset_x, child_offset_y) local self = { id = nil, ---@type element_id|nil + is_root = args.parent == nil, elem_type = debug.getinfo(2).name, define_completed = false, p_window = nil, ---@type table @@ -78,6 +81,7 @@ function element.new(args, child_offset_x, child_offset_y) next_id = 0, -- next child ID subscriptions = {}, button_down = { events.new_coord_2d(-1, -1), events.new_coord_2d(-1, -1), events.new_coord_2d(-1, -1) }, + focused = false, mt = {} } @@ -89,7 +93,8 @@ function element.new(args, child_offset_x, child_offset_y) content_window = nil, ---@type table|nil fg_bg = core.cpair(colors.white, colors.black), frame = core.gframe(1, 1, 1, 1), - children = {} + children = {}, + child_id_map = {} } local name_brief = "graphics.element{" .. self.elem_type .. "}: " @@ -104,6 +109,69 @@ function element.new(args, child_offset_x, child_offset_y) setmetatable(public, self.mt) + ----------------------- + -- PRIVATE FUNCTIONS -- + ----------------------- + + -- use tab to jump to the next focusable field + ---@param reverse boolean + local function _tab_focusable(reverse) + local first_f = nil ---@type graphics_element|nil + local prev_f = nil ---@type graphics_element|nil + local cur_f = nil ---@type graphics_element|nil + local done = false + + ---@param elem graphics_element + local function handle_element(elem) + if elem.is_visible() and elem.is_focusable() and elem.is_enabled() then + if first_f == nil then first_f = elem end + + if cur_f == nil then + if elem.is_focused() then + cur_f = elem + if (not done) and (reverse and prev_f ~= nil) then + cur_f.unfocus() + prev_f.focus() + done = true + end + end + else + if elem.is_focused() then + elem.unfocus() + elseif not (reverse or done) then + cur_f.unfocus() + elem.focus() + done = true + end + end + + prev_f = elem + end + end + + ---@param children table + local function traverse(children) + for i = 1, #children do + local child = children[i] ---@type graphics_base + handle_element(child.get()) + if child.get().is_visible() then traverse(child.children) end + end + end + + traverse(protected.children) + + -- if no element was focused, wrap focus + if first_f ~= nil and not done then + if reverse then + if cur_f ~= nil then cur_f.unfocus() end + if prev_f ~= nil then prev_f.focus() end + else + if cur_f ~= nil then cur_f.unfocus() end + first_f.focus() + end + end + end + ------------------------- -- PROTECTED FUNCTIONS -- ------------------------- @@ -214,85 +282,6 @@ function element.new(args, child_offset_x, child_offset_y) return in_x and in_y end --- luacheck: push ignore ----@diagnostic disable: unused-local, unused-vararg - - -- handle a child element having been added - ---@param id element_id element identifier - ---@param child graphics_element child element - function protected.on_added(id, child) - end - - -- handle a child element having been removed - ---@param id element_id element identifier - function protected.on_removed(id) - end - - -- handle a mouse event - ---@param event mouse_interaction mouse interaction event - function protected.handle_mouse(event) - end - - -- handle data value changes - ---@vararg any value(s) - function protected.on_update(...) - end - - -- callback on control press responses - ---@param result any - function protected.response_callback(result) - end - - -- get value - ---@nodiscard - function protected.get_value() - return protected.value - end - - -- set value - ---@param value any value to set - function protected.set_value(value) - end - - -- set minimum input value - ---@param min integer minimum allowed value - function protected.set_min(min) - end - - -- set maximum input value - ---@param max integer maximum allowed value - function protected.set_max(max) - end - - -- enable the control - function protected.enable() - end - - -- disable the control - function protected.disable() - end - - -- custom recolor command, varies by element if implemented - ---@vararg cpair|color color(s) - function protected.recolor(...) - end - - -- custom resize command, varies by element if implemented - ---@vararg integer sizing - function protected.resize(...) - end - --- luacheck: pop ----@diagnostic enable: unused-local, unused-vararg - - -- start animations - function protected.start_anim() - end - - -- stop animations - function protected.stop_anim() - end - -- get public interface ---@nodiscard ---@return graphics_element element, element_id id @@ -306,6 +295,98 @@ function element.new(args, child_offset_x, child_offset_y) return public, self.id end + -- protected version of public is_focused() + ---@nodiscard + ---@return boolean is_focused + function protected.is_focused() return self.focused end + + -- defocus this element + function protected.defocus() public.unfocus_all() end + + -- request focus management to focus this element + function protected.req_focus() args.parent.__focus_child(public) end + + -- action handlers -- + +-- luacheck: push ignore +---@diagnostic disable: unused-local, unused-vararg + + -- handle a child element having been added + ---@param id element_id element identifier + ---@param child graphics_element child element + function protected.on_added(id, child) end + + -- handle a child element having been removed + ---@param id element_id element identifier + function protected.on_removed(id) end + + -- handle this element having been focused + function protected.on_focused() end + + -- handle this element having been unfocused + function protected.on_unfocused() end + + -- handle this element having been shown + function protected.on_shown() end + + -- handle this element having been hidden + function protected.on_hidden() end + + -- handle a mouse event + ---@param event mouse_interaction mouse interaction event + function protected.handle_mouse(event) end + + -- handle a keyboard event + ---@param event key_interaction key interaction event + function protected.handle_key(event) end + + -- handle data value changes + ---@vararg any value(s) + function protected.on_update(...) end + + -- callback on control press responses + ---@param result any + function protected.response_callback(result) end + + -- get value + ---@nodiscard + function protected.get_value() return protected.value end + + -- set value + ---@param value any value to set + function protected.set_value(value) end + + -- set minimum input value + ---@param min integer minimum allowed value + function protected.set_min(min) end + + -- set maximum input value + ---@param max integer maximum allowed value + function protected.set_max(max) end + + -- enable the control + function protected.enable() end + + -- disable the control + function protected.disable() end + + -- custom recolor command, varies by element if implemented + ---@vararg cpair|color color(s) + function protected.recolor(...) end + + -- custom resize command, varies by element if implemented + ---@vararg integer sizing + function protected.resize(...) end + +-- luacheck: pop +---@diagnostic enable: unused-local, unused-vararg + + -- start animations + function protected.start_anim() end + + -- stop animations + function protected.stop_anim() end + ----------- -- SETUP -- ----------- @@ -324,7 +405,7 @@ function element.new(args, child_offset_x, child_offset_y) self.id = args.id or "__ROOT__" protected.prepare_template(0, 0, 1) else - self.id = args.parent.__add_child(args.id, protected) + self.id, self.ordinal = args.parent.__add_child(args.id, protected) end ---------------------- @@ -358,7 +439,7 @@ function element.new(args, child_offset_x, child_offset_y) -- delete all children for k, v in pairs(protected.children) do - v.delete() + v.get().delete() protected.children[k] = nil end @@ -380,47 +461,59 @@ function element.new(args, child_offset_x, child_offset_y) self.next_y = child.frame.y + child.frame.h - local child_element = child.get() - local id = key ---@type string|integer|nil if id == nil then id = self.next_id self.next_id = self.next_id + 1 end - protected.children[id] = child_element + table.insert(protected.children, child) + + protected.child_id_map[id] = #protected.children + return id end -- remove a child element - ---@param key element_id id - function public.__remove_child(key) - if protected.children[key] ~= nil then - protected.on_removed(key) - protected.children[key] = nil + ---@param id element_id id + function public.__remove_child(id) + local index = protected.child_id_map[id] + if protected.children[index] ~= nil then + protected.on_removed(id) + protected.children[index] = nil + protected.child_id_map[id] = nil end end -- actions to take upon a child element becoming ready (initial draw/construction completed) ---@param key element_id id ---@param child graphics_element - function public.__child_ready(key, child) - protected.on_added(key, child) + function public.__child_ready(key, child) protected.on_added(key, child) end + + -- focus solely on this child + ---@param child graphics_element + function public.__focus_child(child) + if self.is_root then + public.unfocus_all() + child.focus() + else args.parent.__focus_child(child) end end -- get a child element ---@nodiscard ---@param id element_id ---@return graphics_element - function public.get_child(id) return protected.children[id] end + function public.get_child(id) return protected.children[protected.child_id_map[id]].get() end -- remove a child element ---@param id element_id function public.remove(id) - if protected.children[id] ~= nil then - protected.children[id].delete() + local index = protected.child_id_map[id] + if protected.children[index] ~= nil then + protected.children[index].get().delete() protected.on_removed(id) - protected.children[id] = nil + protected.children[index] = nil + protected.child_id_map[id] = nil end end @@ -429,16 +522,13 @@ function element.new(args, child_offset_x, child_offset_y) ---@param id element_id ---@return graphics_element|nil element function public.get_element_by_id(id) - if protected.children[id] == nil then + local index = protected.child_id_map[id] + if protected.children[index] == nil then for _, child in pairs(protected.children) do - local elem = child.get_element_by_id(id) + local elem = child.get().get_element_by_id(id) if elem ~= nil then return elem end end - else - return protected.children[id] - end - - return nil + else return protected.children[index].get() end end -- AUTO-PLACEMENT -- @@ -450,97 +540,113 @@ function element.new(args, child_offset_x, child_offset_y) -- PROPERTIES -- - -- get the foreground/background colors + -- get element id ---@nodiscard - ---@return cpair fg_bg - function public.get_fg_bg() - return protected.fg_bg - end + ---@return element_id + function public.get_id() return self.id end -- get element x ---@nodiscard ---@return integer x - function public.get_x() - return protected.frame.x - end + function public.get_x() return protected.frame.x end -- get element y ---@nodiscard ---@return integer y - function public.get_y() - return protected.frame.y - end + function public.get_y() return protected.frame.y end -- get element width ---@nodiscard ---@return integer width - function public.get_width() - return protected.frame.w - end + function public.get_width() return protected.frame.w end -- get element height ---@nodiscard ---@return integer height - function public.get_height() - return protected.frame.h - end + function public.get_height() return protected.frame.h end + + -- get the foreground/background colors + ---@nodiscard + ---@return cpair fg_bg + function public.get_fg_bg() return protected.fg_bg end -- get the element value ---@nodiscard ---@return any value - function public.get_value() - return protected.get_value() - end + function public.get_value() return protected.get_value() end -- set the element value ---@param value any new value - function public.set_value(value) - protected.set_value(value) - end + function public.set_value(value) protected.set_value(value) end -- set minimum input value ---@param min integer minimum allowed value - function public.set_min(min) - protected.set_min(min) - end + function public.set_min(min) protected.set_min(min) end -- set maximum input value ---@param max integer maximum allowed value - function public.set_max(max) - protected.set_max(max) - end + function public.set_max(max) protected.set_max(max) end + + -- check if this element is enabled + function public.is_enabled() return protected.enabled end -- enable the element function public.enable() - protected.enabled = true - protected.enable() + if not protected.enabled then + protected.enabled = true + protected.enable() + end end -- disable the element function public.disable() - protected.enabled = false - protected.disable() + if protected.enabled then + protected.enabled = false + protected.disable() + end + end + + -- can this element be focused + function public.is_focusable() return args.can_focus end + + -- is this element focused + function public.is_focused() return self.focused end + + -- focus the element + function public.focus() + if args.can_focus and not self.focused then + self.focused = true + protected.on_focused() + end + end + + -- unfocus this element + function public.unfocus() + if args.can_focus and self.focused then + self.focused = false + protected.on_unfocused() + end + end + + -- unfocus this element and all its children + function public.unfocus_all() + public.unfocus() + for _, child in pairs(protected.children) do child.get().unfocus() end end -- custom recolor command, varies by element if implemented ---@vararg cpair|color color(s) - function public.recolor(...) - protected.recolor(...) - end + function public.recolor(...) protected.recolor(...) end -- resize attributes of the element value if supported ---@vararg number dimensions (element specific) - function public.resize(...) - protected.resize(...) - end + function public.resize(...) protected.resize(...) end -- reposition the element window
-- offsets relative to parent frame are where (1, 1) would be on top of the parent's top left corner ---@param x integer x position relative to parent frame ---@param y integer y position relative to parent frame - function public.reposition(x, y) - protected.window.reposition(x, y) - end + function public.reposition(x, y) protected.window.reposition(x, y) end -- FUNCTION CALLBACKS -- @@ -553,12 +659,12 @@ function element.new(args, child_offset_x, child_offset_y) local ini_in = protected.in_window_bounds(x_ini, y_ini) if ini_in then - if event.type == events.CLICK_TYPE.UP or event.type == events.CLICK_TYPE.DRAG then + if event.type == events.MOUSE_CLICK.UP or event.type == events.MOUSE_CLICK.DRAG then -- make sure we don't handle mouse events that started before this element was made visible if (event.initial.x ~= self.button_down[event.button].x) or (event.initial.y ~= self.button_down[event.button].y) then return end - elseif event.type == events.CLICK_TYPE.DOWN then + elseif event.type == events.MOUSE_CLICK.DOWN then self.button_down[event.button] = event.initial end @@ -566,25 +672,39 @@ function element.new(args, child_offset_x, child_offset_y) -- handle the mouse event then pass to children protected.handle_mouse(event_T) - for _, child in pairs(protected.children) do child.handle_mouse(event_T) end + for _, child in pairs(protected.children) do child.get().handle_mouse(event_T) end + elseif event.type == events.MOUSE_CLICK.DOWN or event.type == events.MOUSE_CLICK.TAP then + -- clicked out, unfocus this element and children + public.unfocus_all() end - elseif event.type == events.CLICK_TYPE.DOWN then - -- don't track this click + else + -- don't track clicks while hidden self.button_down[event.button] = events.new_coord_2d(-1, -1) end end + -- handle a keyboard click if this element is visible and focused + ---@param event key_interaction keyboard interaction event + function public.handle_key(event) + if protected.window.isVisible() then + if self.is_root and (event.type == events.KEY_CLICK.DOWN) and (event.key == keys.tab) then + -- try to jump to the next/previous focusable field + _tab_focusable(event.shift) + else + -- handle the key event then pass to children + if self.focused then protected.handle_key(event) end + for _, child in pairs(protected.children) do child.get().handle_key(event) end + end + end + end + -- draw the element given new data ---@vararg any new data - function public.update(...) - protected.on_update(...) - end + function public.update(...) protected.on_update(...) end -- on a control request response ---@param result any - function public.on_response(result) - protected.response_callback(result) - end + function public.on_response(result) protected.response_callback(result) end -- register a callback with a PSIL, allowing for automatic unregister on delete
-- do not use graphics elements directly with PSIL subscribe() @@ -598,6 +718,9 @@ function element.new(args, child_offset_x, child_offset_y) -- VISIBILITY & ANIMATIONS -- + -- check if this element is visible + function public.is_visible() return protected.window.isVisible() end + -- show the element and enables animations by default ---@param animate? boolean true (default) to automatically resume animations function public.show(animate) @@ -610,44 +733,39 @@ function element.new(args, child_offset_x, child_offset_y) ---@see graphics_element.content_redraw function public.hide() public.freeze_all() -- stop animations for efficiency/performance + public.unfocus_all() protected.window.setVisible(false) end -- start/resume animation(s) - function public.animate() - protected.start_anim() - end + function public.animate() protected.start_anim() end -- start/resume animation(s) for this element and all its children
-- only animates if a window is visible function public.animate_all() if protected.window.isVisible() then public.animate() - for _, child in pairs(protected.children) do child.animate_all() end + for _, child in pairs(protected.children) do child.get().animate_all() end end end -- freeze animation(s) - function public.freeze() - protected.stop_anim() - end + function public.freeze() protected.stop_anim() end -- freeze animation(s) for this element and all its children function public.freeze_all() public.freeze() - for _, child in pairs(protected.children) do child.freeze_all() end + for _, child in pairs(protected.children) do child.get().freeze_all() end end -- re-draw the element - function public.redraw() - protected.window.redraw() - end + function public.redraw() protected.window.redraw() end -- if a content window is set, clears it then re-draws all children function public.content_redraw() if protected.content_window ~= nil then protected.content_window.clear() - for _, child in pairs(protected.children) do child.redraw() end + for _, child in pairs(protected.children) do child.get().redraw() end end end diff --git a/graphics/elements/controls/app.lua b/graphics/elements/controls/app.lua index 574be66..a0d5949 100644 --- a/graphics/elements/controls/app.lua +++ b/graphics/elements/controls/app.lua @@ -5,7 +5,7 @@ local tcd = require("scada-common.tcd") local core = require("graphics.core") local element = require("graphics.element") -local CLICK_TYPE = core.events.CLICK_TYPE +local MOUSE_CLICK = core.events.MOUSE_CLICK ---@class app_button_args ---@field text string app icon text @@ -98,14 +98,14 @@ local function app_button(args) ---@param event mouse_interaction mouse event function e.handle_mouse(event) if e.enabled then - if event.type == CLICK_TYPE.TAP then + if event.type == MOUSE_CLICK.TAP then show_pressed() -- show as unpressed in 0.25 seconds if args.active_fg_bg ~= nil then tcd.dispatch(0.25, show_unpressed) end args.callback() - elseif event.type == CLICK_TYPE.DOWN then + elseif event.type == MOUSE_CLICK.DOWN then show_pressed() - elseif event.type == CLICK_TYPE.UP then + elseif event.type == MOUSE_CLICK.UP then show_unpressed() if e.in_frame_bounds(event.current.x, event.current.y) then args.callback() @@ -117,7 +117,7 @@ local function app_button(args) -- set the value (true simulates pressing the app button) ---@param val boolean new value function e.set_value(val) - if val then e.handle_mouse(core.events.mouse_generic(core.events.CLICK_TYPE.UP, 1, 1)) end + if val then e.handle_mouse(core.events.mouse_generic(core.events.MOUSE_CLICK.UP, 1, 1)) end end -- initial draw diff --git a/graphics/elements/controls/hazard_button.lua b/graphics/elements/controls/hazard_button.lua index ac1b23d..f6ac2d3 100644 --- a/graphics/elements/controls/hazard_button.lua +++ b/graphics/elements/controls/hazard_button.lua @@ -174,7 +174,7 @@ local function hazard_button(args) -- set the value (true simulates pressing the button) ---@param val boolean new value function e.set_value(val) - if val then e.handle_mouse(core.events.mouse_generic(core.events.CLICK_TYPE.UP, 1, 1)) end + if val then e.handle_mouse(core.events.mouse_generic(core.events.MOUSE_CLICK.UP, 1, 1)) end end -- show the button as disabled diff --git a/graphics/elements/controls/push_button.lua b/graphics/elements/controls/push_button.lua index 36e1914..da555be 100644 --- a/graphics/elements/controls/push_button.lua +++ b/graphics/elements/controls/push_button.lua @@ -5,7 +5,8 @@ local tcd = require("scada-common.tcd") local core = require("graphics.core") local element = require("graphics.element") -local CLICK_TYPE = core.events.CLICK_TYPE +local MOUSE_CLICK = core.events.MOUSE_CLICK +local KEY_CLICK = core.events.KEY_CLICK ---@class push_button_args ---@field text string button text @@ -32,7 +33,8 @@ local function push_button(args) local text_width = string.len(args.text) - -- single line height, calculate width + -- set automatic settings + args.can_focus = true args.height = 1 args.min_width = args.min_width or 0 args.width = math.max(text_width, args.min_width) @@ -76,14 +78,14 @@ local function push_button(args) ---@param event mouse_interaction mouse event function e.handle_mouse(event) if e.enabled then - if event.type == CLICK_TYPE.TAP then + if event.type == MOUSE_CLICK.TAP then show_pressed() -- show as unpressed in 0.25 seconds if args.active_fg_bg ~= nil then tcd.dispatch(0.25, show_unpressed) end args.callback() - elseif event.type == CLICK_TYPE.DOWN then + elseif event.type == MOUSE_CLICK.DOWN then show_pressed() - elseif event.type == CLICK_TYPE.UP then + elseif event.type == MOUSE_CLICK.UP then show_unpressed() if e.in_frame_bounds(event.current.x, event.current.y) then args.callback() @@ -92,10 +94,21 @@ local function push_button(args) end end + -- handle keyboard interaction + ---@param event key_interaction key event + function e.handle_key(event) + if event.type == KEY_CLICK.DOWN then + if event.key == keys.space or event.key == keys.enter or event.key == keys.numPadEnter then + args.callback() + e.defocus() + end + end + end + -- set the value (true simulates pressing the button) ---@param val boolean new value function e.set_value(val) - if val then e.handle_mouse(core.events.mouse_generic(core.events.CLICK_TYPE.UP, 1, 1)) end + if val then e.handle_mouse(core.events.mouse_generic(core.events.MOUSE_CLICK.UP, 1, 1)) end end -- show butten as enabled @@ -118,6 +131,9 @@ local function push_button(args) end end + e.on_focused = show_pressed + e.on_unfocused = show_unpressed + -- initial draw draw() diff --git a/graphics/elements/controls/sidebar.lua b/graphics/elements/controls/sidebar.lua index 9185430..646c724 100644 --- a/graphics/elements/controls/sidebar.lua +++ b/graphics/elements/controls/sidebar.lua @@ -5,7 +5,7 @@ local tcd = require("scada-common.tcd") local core = require("graphics.core") local element = require("graphics.element") -local CLICK_TYPE = core.events.CLICK_TYPE +local MOUSE_CLICK = core.events.MOUSE_CLICK ---@class sidebar_tab ---@field char string character identifier @@ -85,22 +85,22 @@ local function sidebar(args) local ini_idx = math.ceil(event.initial.y / 3) if args.tabs[cur_idx] ~= nil then - if event.type == CLICK_TYPE.TAP then + if event.type == MOUSE_CLICK.TAP then e.value = cur_idx draw(true) -- show as unpressed in 0.25 seconds tcd.dispatch(0.25, function () draw(false) end) args.callback(e.value) - elseif event.type == CLICK_TYPE.DOWN then + elseif event.type == MOUSE_CLICK.DOWN then draw(true, cur_idx) - elseif event.type == CLICK_TYPE.UP then + elseif event.type == MOUSE_CLICK.UP then if cur_idx == ini_idx and e.in_frame_bounds(event.current.x, event.current.y) then e.value = cur_idx draw(false) args.callback(e.value) else draw(false) end end - elseif event.type == CLICK_TYPE.UP then + elseif event.type == MOUSE_CLICK.UP then draw(false) end end diff --git a/graphics/elements/form/number_field.lua b/graphics/elements/form/number_field.lua new file mode 100644 index 0000000..a32d43f --- /dev/null +++ b/graphics/elements/form/number_field.lua @@ -0,0 +1,136 @@ +-- Numeric Value Entry Graphics Element + +local util = require("scada-common.util") + +local core = require("graphics.core") +local element = require("graphics.element") + +local KEY_CLICK = core.events.KEY_CLICK + +---@class number_field_args +---@field default? number default value, defaults to 0 +---@field min? number minimum, forced on unfocus +---@field max? number maximum, forced on unfocus +---@field max_digits? integer maximum number of digits, defaults to width +---@field allow_decimal? boolean true to allow decimals +---@field allow_negative? boolean true to allow negative numbers +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer auto incremented if omitted +---@field width? integer parent width if omitted +---@field fg_bg? cpair foreground/background colors +---@field hidden? boolean true to hide on initial draw + +-- new numeric form field +---@param args number_field_args +---@return graphics_element element, element_id id +local function number_field(args) + args.height = 1 + args.can_focus = true + + -- create new graphics element base object + local e = element.new(args) + + local has_decimal = false + + args.max_digits = args.max_digits or e.frame.w + + -- set initial value + e.value = "" .. (args.default or 0) + + local function show() + -- clear and print + e.w_set_cur(1, 1) + e.w_write(string.rep(" ", e.frame.w)) + e.w_set_cur(1, 1) + e.w_set_fgd(colors.black) + e.w_write(e.value) + if e.is_focused() then + e.w_set_fgd(colors.lightGray) + e.w_write("_") + end + end + + -- handle mouse interaction + ---@param event mouse_interaction mouse event + function e.handle_mouse(event) + -- only handle if on an increment or decrement arrow + if e.enabled and core.events.was_clicked(event.type) then + e.req_focus() + end + end + + -- handle keyboard interaction + ---@param event key_interaction key event + function e.handle_key(event) + if event.type == KEY_CLICK.CHAR and string.len(e.value) < args.max_digits then + if tonumber(event.name) then + e.value = util.trinary(e.value == "0", "", e.value) .. tonumber(event.name) + end + + show() + elseif event.type == KEY_CLICK.DOWN then + if (event.key == keys.backspace or event.key == keys.delete) and (string.len(e.value) > 0) then + e.value = string.sub(e.value, 1, string.len(e.value) - 1) + has_decimal = string.find(e.value, "%.") ~= nil + show() + elseif (event.key == keys.period or event.key == keys.numPadDecimal) and (not has_decimal) and args.allow_decimal then + e.value = e.value .. "." + has_decimal = true + show() + elseif (event.key == keys.minus or event.key == keys.numPadSubtract) and (string.len(e.value) == 0) and args.allow_negative then + e.value = "-" + show() + end + end + end + + -- set the value + ---@param val number number to show + function e.set_value(val) e.value = val end + + -- set minimum input value + ---@param min integer minimum allowed value + function e.set_min(min) args.min = min end + + -- set maximum input value + ---@param max integer maximum allowed value + function e.set_max(max) args.max = max end + + -- handle focus change + e.on_focused = show + + function e.on_unfocused() + local val = tonumber(e.value) + local max = tonumber(args.max) + local min = tonumber(args.min) + + if type(val) == "number" then + if type(args.max) == "number" and val > max then + e.value = "" .. max + elseif type(args.min) == "number" and val < min then + e.value = "" .. min + end + else + e.value = "" + end + + show() + end + + -- enable this input + function e.enable() + end + + -- disable this input + function e.disable() + end + + -- initial draw + show() + + return e.complete() +end + +return number_field diff --git a/graphics/elements/listbox.lua b/graphics/elements/listbox.lua index 1bc3556..0268951 100644 --- a/graphics/elements/listbox.lua +++ b/graphics/elements/listbox.lua @@ -5,7 +5,7 @@ local tcd = require("scada-common.tcd") local core = require("graphics.core") local element = require("graphics.element") -local CLICK_TYPE = core.events.CLICK_TYPE +local MOUSE_CLICK = core.events.MOUSE_CLICK ---@class listbox_args ---@field scroll_height integer height of internal scrolling container (must fit all elements vertically tiled) @@ -223,7 +223,7 @@ local function listbox(args) ---@param event mouse_interaction mouse event function e.handle_mouse(event) if e.enabled then - if event.type == CLICK_TYPE.TAP then + if event.type == MOUSE_CLICK.TAP then if event.current.x == e.frame.w then if event.current.y == 1 or event.current.y < bar_bounds[1] then draw_arrows(1) @@ -235,7 +235,7 @@ local function listbox(args) if args.nav_active ~= nil then tcd.dispatch(0.25, function () draw_arrows(0) end) end end end - elseif event.type == CLICK_TYPE.DOWN then + elseif event.type == MOUSE_CLICK.DOWN then if event.current.x == e.frame.w then if event.current.y == 1 or event.current.y < bar_bounds[1] then draw_arrows(1) @@ -250,10 +250,10 @@ local function listbox(args) mouse_last_y = event.current.y end end - elseif event.type == CLICK_TYPE.UP then + elseif event.type == MOUSE_CLICK.UP then holding_bar = false draw_arrows(0) - elseif event.type == CLICK_TYPE.DRAG then + elseif event.type == MOUSE_CLICK.DRAG then if holding_bar then -- if mouse is within vertical frame, including the grip point if event.current.y > (1 + bar_grip_pos) and event.current.y <= ((e.frame.h - bar_height) + bar_grip_pos) then @@ -266,9 +266,9 @@ local function listbox(args) mouse_last_y = event.current.y end end - elseif event.type == CLICK_TYPE.SCROLL_DOWN then + elseif event.type == MOUSE_CLICK.SCROLL_DOWN then scroll_down() - elseif event.type == CLICK_TYPE.SCROLL_UP then + elseif event.type == MOUSE_CLICK.SCROLL_UP then scroll_up() end end diff --git a/graphics/events.lua b/graphics/events.lua index ca56588..df5f7eb 100644 --- a/graphics/events.lua +++ b/graphics/events.lua @@ -14,8 +14,8 @@ events.CLICK_BUTTON = { MID_BUTTON = 3 } ----@enum CLICK_TYPE -events.CLICK_TYPE = { +---@enum MOUSE_CLICK +local MOUSE_CLICK = { TAP = 1, -- screen tap (complete click) DOWN = 2, -- button down UP = 3, -- button up (completed a click) @@ -24,6 +24,18 @@ events.CLICK_TYPE = { SCROLL_UP = 6 -- scroll up } +events.MOUSE_CLICK = MOUSE_CLICK + +---@enum KEY_CLICK +local KEY_CLICK = { + DOWN = 1, + HELD = 2, + UP = 3, + CHAR = 4 +} + +events.KEY_CLICK = KEY_CLICK + -- create a new 2D coordinate ---@param x integer ---@param y integer @@ -35,13 +47,25 @@ events.new_coord_2d = _coord2d ---@class mouse_interaction ---@field monitor string ---@field button CLICK_BUTTON ----@field type CLICK_TYPE +---@field type MOUSE_CLICK ---@field initial coordinate_2d ---@field current coordinate_2d +---@class key_interaction +---@field type KEY_CLICK +---@field key number key code +---@field name string key character name +---@field shift boolean shift held +---@field ctrl boolean ctrl held +---@field alt boolean alt held + local handler = { -- left, right, middle button down tracking - button_down = { _coord2d(0, 0), _coord2d(0, 0), _coord2d(0, 0) } + button_down = { _coord2d(0, 0), _coord2d(0, 0), _coord2d(0, 0) }, + -- keyboard modifiers + shift = false, + alt = false, + ctrl = false } -- create a new monitor touch mouse interaction event @@ -54,7 +78,7 @@ local function _monitor_touch(monitor, x, y) return { monitor = monitor, button = events.CLICK_BUTTON.GENERIC, - type = events.CLICK_TYPE.TAP, + type = MOUSE_CLICK.TAP, initial = _coord2d(x, y), current = _coord2d(x, y) } @@ -63,7 +87,7 @@ end -- create a new mouse button mouse interaction event ---@nodiscard ---@param button CLICK_BUTTON mouse button ----@param type CLICK_TYPE click type +---@param type MOUSE_CLICK click type ---@param x1 integer initial x ---@param y1 integer initial y ---@param x2 integer current x @@ -81,7 +105,7 @@ end -- create a new generic mouse interaction event ---@nodiscard ----@param type CLICK_TYPE +---@param type MOUSE_CLICK ---@param x integer ---@param y integer ---@return mouse_interaction @@ -113,8 +137,8 @@ end -- check if an event qualifies as a click (tap or up) ---@nodiscard ----@param t CLICK_TYPE -function events.was_clicked(t) return t == events.CLICK_TYPE.TAP or t == events.CLICK_TYPE.UP end +---@param t MOUSE_CLICK +function events.was_clicked(t) return t == MOUSE_CLICK.TAP or t == MOUSE_CLICK.UP end -- create a new mouse event to pass onto graphics renderer
-- supports: mouse_click, mouse_up, mouse_drag, mouse_scroll, and monitor_touch @@ -127,32 +151,65 @@ function events.new_mouse_event(event_type, opt, x, y) if event_type == "mouse_click" then ---@cast opt 1|2|3 handler.button_down[opt] = _coord2d(x, y) - return _mouse_event(opt, events.CLICK_TYPE.DOWN, x, y, x, y) + return _mouse_event(opt, MOUSE_CLICK.DOWN, x, y, x, y) elseif event_type == "mouse_up" then ---@cast opt 1|2|3 local initial = handler.button_down[opt] ---@type coordinate_2d - return _mouse_event(opt, events.CLICK_TYPE.UP, initial.x, initial.y, x, y) + return _mouse_event(opt, MOUSE_CLICK.UP, initial.x, initial.y, x, y) elseif event_type == "monitor_touch" then ---@cast opt string return _monitor_touch(opt, x, y) elseif event_type == "mouse_drag" then ---@cast opt 1|2|3 local initial = handler.button_down[opt] ---@type coordinate_2d - return _mouse_event(opt, events.CLICK_TYPE.DRAG, initial.x, initial.y, x, y) + return _mouse_event(opt, MOUSE_CLICK.DRAG, initial.x, initial.y, x, y) elseif event_type == "mouse_scroll" then ---@cast opt 1|-1 - local scroll_direction = util.trinary(opt == 1, events.CLICK_TYPE.SCROLL_DOWN, events.CLICK_TYPE.SCROLL_UP) + local scroll_direction = util.trinary(opt == 1, MOUSE_CLICK.SCROLL_DOWN, MOUSE_CLICK.SCROLL_UP) return _mouse_event(events.CLICK_BUTTON.GENERIC, scroll_direction, x, y, x, y) end end --- create a new key event to pass onto graphics renderer
+-- create a new keyboard interaction event +---@nodiscard +---@param click_type KEY_CLICK key click type +---@param key integer|string keyboard key code or character for 'char' event +---@return key_interaction +local function _key_event(click_type, key) + local name = key + if type(key) == "number" then name = keys.getName(key) end + return { type = click_type, key = key, name = name, shift = handler.shift, ctrl = handler.ctrl, alt = handler.alt } +end + +-- create a new keyboard event to pass onto graphics renderer
-- supports: char, key, and key_up ----@param event_type os_event -function events.new_key_event(event_type) +---@param event_type os_event OS event to handle +---@param key integer keyboard key code +---@param held boolean? if the key is being held (for 'key' event) +---@return key_interaction|nil +function events.new_key_event(event_type, key, held) if event_type == "char" then + return _key_event(KEY_CLICK.CHAR, key) elseif event_type == "key" then + if key == keys.leftShift or key == keys.rightShift then + handler.shift = true + elseif key == keys.leftCtrl or key == keys.rightCtrl then + handler.ctrl = true + elseif key == keys.leftAlt or key == keys.rightAlt then + handler.alt = true + else + return _key_event(util.trinary(held, KEY_CLICK.HELD, KEY_CLICK.DOWN), key) + end elseif event_type == "key_up" then + if key == keys.leftShift or key == keys.rightShift then + handler.shift = false + elseif key == keys.leftCtrl or key == keys.rightCtrl then + handler.ctrl = false + elseif key == keys.leftAlt or key == keys.rightAlt then + handler.alt = false + else + return _key_event(KEY_CLICK.UP, key) + end end end