mirror of
https://github.com/MikaylaFischler/cc-mek-scada.git
synced 2024-08-30 18:22:34 +00:00
#344 select all and improved input fields
This commit is contained in:
parent
09ab60f79d
commit
f9d0ef60b4
@ -111,4 +111,182 @@ function core.pipe(x1, y1, x2, y2, color, thin, align_tr)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Interactive Field Manager
|
||||||
|
|
||||||
|
---@param e graphics_base
|
||||||
|
---@param max_len any
|
||||||
|
---@param fg_bg any
|
||||||
|
---@param dis_fg_bg any
|
||||||
|
function core.new_ifield(e, max_len, fg_bg, dis_fg_bg)
|
||||||
|
local self = {
|
||||||
|
frame_start = 1,
|
||||||
|
visible_text = e.value,
|
||||||
|
cursor_pos = string.len(e.value) + 1,
|
||||||
|
selected_all = false
|
||||||
|
}
|
||||||
|
|
||||||
|
-- update visible text
|
||||||
|
local function _update_visible()
|
||||||
|
self.visible_text = string.sub(e.value, self.frame_start, self.frame_start + math.min(string.len(e.value), e.frame.w) - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- try shifting frame left
|
||||||
|
local function _try_lshift()
|
||||||
|
if self.frame_start > 1 then
|
||||||
|
self.frame_start = self.frame_start - 1
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- try shifting frame right
|
||||||
|
local function _try_rshift()
|
||||||
|
if (self.frame_start + e.frame.w - 1) < string.len(e.value) then
|
||||||
|
self.frame_start = self.frame_start + 1
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ifield
|
||||||
|
local public = {}
|
||||||
|
|
||||||
|
-- show the field
|
||||||
|
function public.show()
|
||||||
|
_update_visible()
|
||||||
|
|
||||||
|
if e.enabled then
|
||||||
|
e.w_set_bkg(fg_bg.bkg)
|
||||||
|
e.w_set_fgd(fg_bg.fgd)
|
||||||
|
else
|
||||||
|
e.w_set_bkg(dis_fg_bg.bkg)
|
||||||
|
e.w_set_fgd(dis_fg_bg.fgd)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- clear and print
|
||||||
|
e.w_set_cur(1, 1)
|
||||||
|
e.w_write(string.rep(" ", e.frame.w))
|
||||||
|
e.w_set_cur(1, 1)
|
||||||
|
|
||||||
|
if e.is_focused() and e.enabled then
|
||||||
|
-- write text with cursor
|
||||||
|
if self.selected_all then
|
||||||
|
e.w_set_bkg(fg_bg.fgd)
|
||||||
|
e.w_set_fgd(fg_bg.bkg)
|
||||||
|
e.w_write(self.visible_text)
|
||||||
|
elseif self.cursor_pos == (string.len(self.visible_text) + 1) then
|
||||||
|
-- write text with cursor at the end, no need to blit
|
||||||
|
e.w_write(self.visible_text)
|
||||||
|
e.w_set_fgd(colors.lightGray)
|
||||||
|
e.w_write("_")
|
||||||
|
else
|
||||||
|
local a, b = "", ""
|
||||||
|
|
||||||
|
if self.cursor_pos <= string.len(self.visible_text) then
|
||||||
|
a = fg_bg.blit_bkg
|
||||||
|
b = fg_bg.blit_fgd
|
||||||
|
end
|
||||||
|
|
||||||
|
local b_fgd = string.rep(fg_bg.blit_fgd, self.cursor_pos - 1) .. a .. string.rep(fg_bg.blit_fgd, string.len(self.visible_text) - self.cursor_pos)
|
||||||
|
local b_bkg = string.rep(fg_bg.blit_bkg, self.cursor_pos - 1) .. b .. string.rep(fg_bg.blit_bkg, string.len(self.visible_text) - self.cursor_pos)
|
||||||
|
|
||||||
|
e.w_blit(self.visible_text, b_fgd, b_bkg)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.selected_all = false
|
||||||
|
|
||||||
|
-- write text without cursor
|
||||||
|
e.w_write(self.visible_text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- move cursor to x
|
||||||
|
---@param x integer
|
||||||
|
function public.move_cursor(x)
|
||||||
|
self.selected_all = false
|
||||||
|
self.cursor_pos = math.min(x, string.len(self.visible_text) + 1)
|
||||||
|
public.show()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- select all text
|
||||||
|
function public.select_all()
|
||||||
|
self.selected_all = true
|
||||||
|
public.show()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- set field value
|
||||||
|
---@param val string
|
||||||
|
function public.set_value(val)
|
||||||
|
e.value = string.sub(val, 1, math.min(max_len, string.len(val)))
|
||||||
|
|
||||||
|
self.selected_all = false
|
||||||
|
self.frame_start = 1 + math.max(0, string.len(val) - e.frame.w)
|
||||||
|
|
||||||
|
_update_visible()
|
||||||
|
self.cursor_pos = string.len(self.visible_text) + 1
|
||||||
|
|
||||||
|
public.show()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- try to insert a character if there is space
|
||||||
|
---@param char string
|
||||||
|
function public.try_insert_char(char)
|
||||||
|
-- limit length
|
||||||
|
if string.len(e.value) >= max_len then return end
|
||||||
|
|
||||||
|
-- replace if selected all, insert otherwise
|
||||||
|
if self.selected_all then
|
||||||
|
self.selected_all = false
|
||||||
|
self.cursor_pos = 2
|
||||||
|
self.frame_start = 1
|
||||||
|
|
||||||
|
e.value = char
|
||||||
|
public.show()
|
||||||
|
else
|
||||||
|
e.value = string.sub(e.value, 1, self.frame_start + self.cursor_pos - 2) .. char .. string.sub(e.value, self.frame_start + self.cursor_pos - 1, string.len(e.value))
|
||||||
|
_update_visible()
|
||||||
|
|
||||||
|
if self.cursor_pos <= string.len(self.visible_text) then
|
||||||
|
self.cursor_pos = self.cursor_pos + 1
|
||||||
|
public.show()
|
||||||
|
elseif _try_rshift() then public.show() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- remove charcter before cursor if there is anything to remove, or delete all if selected all
|
||||||
|
function public.backspace()
|
||||||
|
if self.selected_all then
|
||||||
|
self.selected_all = false
|
||||||
|
e.value = ""
|
||||||
|
self.cursor_pos = 1
|
||||||
|
self.frame_start = 1
|
||||||
|
public.show()
|
||||||
|
else
|
||||||
|
if self.frame_start + self.cursor_pos > 2 then
|
||||||
|
e.value = string.sub(e.value, 1, self.frame_start + self.cursor_pos - 3) .. string.sub(e.value, self.frame_start + self.cursor_pos - 1, string.len(e.value))
|
||||||
|
if self.cursor_pos > 1 then
|
||||||
|
self.cursor_pos = self.cursor_pos - 1
|
||||||
|
public.show()
|
||||||
|
elseif _try_lshift() then public.show() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- move cursor left by one
|
||||||
|
function public.nav_left()
|
||||||
|
if self.cursor_pos > 1 then
|
||||||
|
self.cursor_pos = self.cursor_pos - 1
|
||||||
|
public.show()
|
||||||
|
elseif _try_lshift() then public.show() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- move cursor right by one
|
||||||
|
function public.nav_right()
|
||||||
|
if self.cursor_pos <= string.len(self.visible_text) then
|
||||||
|
self.cursor_pos = self.cursor_pos + 1
|
||||||
|
public.show()
|
||||||
|
elseif _try_rshift() then public.show() end
|
||||||
|
end
|
||||||
|
|
||||||
|
return public
|
||||||
|
end
|
||||||
|
|
||||||
return core
|
return core
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
-- Numeric Value Entry Graphics Element
|
-- Numeric Value Entry Graphics Element
|
||||||
|
|
||||||
local util = require("scada-common.util")
|
|
||||||
|
|
||||||
local core = require("graphics.core")
|
local core = require("graphics.core")
|
||||||
local element = require("graphics.element")
|
local element = require("graphics.element")
|
||||||
|
|
||||||
local KEY_CLICK = core.events.KEY_CLICK
|
local KEY_CLICK = core.events.KEY_CLICK
|
||||||
|
local MOUSE_CLICK = core.events.MOUSE_CLICK
|
||||||
|
|
||||||
---@class number_field_args
|
---@class number_field_args
|
||||||
---@field default? number default value, defaults to 0
|
---@field default? number default value, defaults to 0
|
||||||
@ -38,7 +37,11 @@ local function number_field(args)
|
|||||||
args.max_digits = args.max_digits or e.frame.w
|
args.max_digits = args.max_digits or e.frame.w
|
||||||
|
|
||||||
-- set initial value
|
-- set initial value
|
||||||
e.value = util.strval(args.default or 0)
|
e.value = "" .. (args.default or 0)
|
||||||
|
|
||||||
|
-- make an interactive field manager
|
||||||
|
local ifield = core.new_ifield(e, args.max_digits, args.fg_bg, args.dis_fg_bg)
|
||||||
|
|
||||||
|
|
||||||
-- draw input
|
-- draw input
|
||||||
local function show()
|
local function show()
|
||||||
@ -67,8 +70,16 @@ local function number_field(args)
|
|||||||
---@param event mouse_interaction mouse event
|
---@param event mouse_interaction mouse event
|
||||||
function e.handle_mouse(event)
|
function e.handle_mouse(event)
|
||||||
-- only handle if on an increment or decrement arrow
|
-- only handle if on an increment or decrement arrow
|
||||||
if e.enabled and core.events.was_clicked(event.type) then
|
if e.enabled then
|
||||||
|
if core.events.was_clicked(event.type) then
|
||||||
e.req_focus()
|
e.req_focus()
|
||||||
|
|
||||||
|
if event.type == MOUSE_CLICK.UP then
|
||||||
|
ifield.move_cursor(event.current.x)
|
||||||
|
end
|
||||||
|
elseif event.type == MOUSE_CLICK.DOUBLE_CLICK then
|
||||||
|
ifield.select_all()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -77,44 +88,52 @@ local function number_field(args)
|
|||||||
function e.handle_key(event)
|
function e.handle_key(event)
|
||||||
if event.type == KEY_CLICK.CHAR and string.len(e.value) < args.max_digits then
|
if event.type == KEY_CLICK.CHAR and string.len(e.value) < args.max_digits then
|
||||||
if tonumber(event.name) then
|
if tonumber(event.name) then
|
||||||
e.value = util.trinary(e.value == "0", "", e.value) .. tonumber(event.name)
|
if e.value == 0 then e.value = "" end
|
||||||
show()
|
ifield.try_insert_char(event.name)
|
||||||
end
|
end
|
||||||
elseif event.type == KEY_CLICK.DOWN then
|
elseif event.type == KEY_CLICK.DOWN then
|
||||||
if (event.key == keys.backspace or event.key == keys.delete) and (string.len(e.value) > 0) 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)
|
ifield.backspace()
|
||||||
has_decimal = string.find(e.value, "%.") ~= nil
|
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
|
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
|
has_decimal = true
|
||||||
show()
|
ifield.try_insert_char(".")
|
||||||
elseif (event.key == keys.minus or event.key == keys.numPadSubtract) and (string.len(e.value) == 0) and args.allow_negative then
|
elseif (event.key == keys.minus or event.key == keys.numPadSubtract) and (string.len(e.value) == 0) and args.allow_negative then
|
||||||
e.value = "-"
|
ifield.set_value("-")
|
||||||
show()
|
elseif event.key == keys.left then
|
||||||
|
ifield.nav_left()
|
||||||
|
elseif event.key == keys.right then
|
||||||
|
ifield.nav_right()
|
||||||
|
elseif event.key == keys.a and event.ctrl then
|
||||||
|
ifield.select_all()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- set the value
|
-- set the value (must be a number)
|
||||||
---@param val number number to show
|
---@param val number number to show
|
||||||
function e.set_value(val)
|
function e.set_value(val)
|
||||||
e.value = val
|
if tonumber(val) then
|
||||||
show()
|
ifield.set_value("" .. tonumber(val))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- set minimum input value
|
-- set minimum input value
|
||||||
---@param min integer minimum allowed value
|
---@param min integer minimum allowed value
|
||||||
function e.set_min(min)
|
function e.set_min(min) args.min = min end
|
||||||
args.min = min
|
|
||||||
show()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- set maximum input value
|
-- set maximum input value
|
||||||
---@param max integer maximum allowed value
|
---@param max integer maximum allowed value
|
||||||
function e.set_max(max)
|
function e.set_max(max) args.max = max end
|
||||||
args.max = max
|
|
||||||
show()
|
-- replace text with pasted text if its a number
|
||||||
|
---@param text string string pasted
|
||||||
|
function e.handle_paste(text)
|
||||||
|
if tonumber(text) then
|
||||||
|
ifield.set_value("" .. tonumber(text))
|
||||||
|
else
|
||||||
|
ifield.set_value("0")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- handle focused
|
-- handle focused
|
||||||
@ -136,15 +155,15 @@ local function number_field(args)
|
|||||||
e.value = ""
|
e.value = ""
|
||||||
end
|
end
|
||||||
|
|
||||||
show()
|
ifield.show()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- on enable/disable
|
-- on enable/disable
|
||||||
e.enable = show
|
e.enable = ifield.show
|
||||||
e.disable = show
|
e.disable = ifield.show
|
||||||
|
|
||||||
-- initial draw
|
-- initial draw
|
||||||
show()
|
ifield.show()
|
||||||
|
|
||||||
return e.complete()
|
return e.complete()
|
||||||
end
|
end
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
-- Text Value Entry Graphics Element
|
-- Text Value Entry Graphics Element
|
||||||
|
|
||||||
local util = require("scada-common.util")
|
|
||||||
local events = require("graphics.events")
|
|
||||||
|
|
||||||
local core = require("graphics.core")
|
local core = require("graphics.core")
|
||||||
local element = require("graphics.element")
|
local element = require("graphics.element")
|
||||||
|
|
||||||
@ -34,71 +31,8 @@ local function text_field(args)
|
|||||||
-- set initial value
|
-- set initial value
|
||||||
e.value = args.value or ""
|
e.value = args.value or ""
|
||||||
|
|
||||||
local max_len = args.max_len or e.frame.w
|
-- make an interactive field manager
|
||||||
local frame_start = 1
|
local ifield = core.new_ifield(e, args.max_len or e.frame.w, args.fg_bg, args.dis_fg_bg)
|
||||||
local visible_text = e.value
|
|
||||||
local cursor_pos = string.len(visible_text) + 1
|
|
||||||
|
|
||||||
local function frame__update_visible()
|
|
||||||
visible_text = string.sub(e.value, frame_start, frame_start + math.min(string.len(e.value), e.frame.w) - 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- draw input
|
|
||||||
local function show()
|
|
||||||
frame__update_visible()
|
|
||||||
|
|
||||||
if e.enabled then
|
|
||||||
e.w_set_bkg(args.fg_bg.bkg)
|
|
||||||
e.w_set_fgd(args.fg_bg.fgd)
|
|
||||||
else
|
|
||||||
e.w_set_bkg(args.dis_fg_bg.bkg)
|
|
||||||
e.w_set_fgd(args.dis_fg_bg.fgd)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- clear and print
|
|
||||||
e.w_set_cur(1, 1)
|
|
||||||
e.w_write(string.rep(" ", e.frame.w))
|
|
||||||
e.w_set_cur(1, 1)
|
|
||||||
|
|
||||||
if e.is_focused() and e.enabled then
|
|
||||||
-- write text with cursor
|
|
||||||
if cursor_pos == (string.len(visible_text) + 1) then
|
|
||||||
-- write text with cursor at the end, no need to blit
|
|
||||||
e.w_write(visible_text)
|
|
||||||
e.w_set_fgd(colors.lightGray)
|
|
||||||
e.w_write("_")
|
|
||||||
else
|
|
||||||
local a, b = "", ""
|
|
||||||
|
|
||||||
if cursor_pos <= string.len(visible_text) then
|
|
||||||
a = args.fg_bg.blit_bkg
|
|
||||||
b = args.fg_bg.blit_fgd
|
|
||||||
end
|
|
||||||
|
|
||||||
local b_fgd = string.rep(args.fg_bg.blit_fgd, cursor_pos - 1) .. a .. string.rep(args.fg_bg.blit_fgd, string.len(visible_text) - cursor_pos)
|
|
||||||
local b_bkg = string.rep(args.fg_bg.blit_bkg, cursor_pos - 1) .. b .. string.rep(args.fg_bg.blit_bkg, string.len(visible_text) - cursor_pos)
|
|
||||||
|
|
||||||
e.w_blit(visible_text, b_fgd, b_bkg)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
-- write text without cursor
|
|
||||||
e.w_write(visible_text)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function frame__try_lshift()
|
|
||||||
if frame_start > 1 then
|
|
||||||
frame_start = frame_start - 1
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function frame__try_rshift()
|
|
||||||
if (frame_start + e.frame.w - 1) < string.len(e.value) then
|
|
||||||
frame_start = frame_start + 1
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- handle mouse interaction
|
-- handle mouse interaction
|
||||||
---@param event mouse_interaction mouse event
|
---@param event mouse_interaction mouse event
|
||||||
@ -109,9 +43,10 @@ local function text_field(args)
|
|||||||
e.req_focus()
|
e.req_focus()
|
||||||
|
|
||||||
if event.type == MOUSE_CLICK.UP then
|
if event.type == MOUSE_CLICK.UP then
|
||||||
cursor_pos = math.min(event.current.x, string.len(visible_text) + 1)
|
ifield.move_cursor(event.current.x)
|
||||||
show()
|
|
||||||
end
|
end
|
||||||
|
elseif event.type == MOUSE_CLICK.DOUBLE_CLICK then
|
||||||
|
ifield.select_all()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -119,61 +54,43 @@ local function text_field(args)
|
|||||||
-- handle keyboard interaction
|
-- handle keyboard interaction
|
||||||
---@param event key_interaction key event
|
---@param event key_interaction key event
|
||||||
function e.handle_key(event)
|
function e.handle_key(event)
|
||||||
if event.type == KEY_CLICK.CHAR and string.len(e.value) < max_len then
|
if event.type == KEY_CLICK.CHAR then
|
||||||
e.value = string.sub(e.value, 1, frame_start + cursor_pos - 2) .. event.name .. string.sub(e.value, frame_start + cursor_pos - 1, string.len(e.value))
|
ifield.try_insert_char(event.name)
|
||||||
frame__update_visible()
|
|
||||||
if cursor_pos <= string.len(visible_text) then
|
|
||||||
cursor_pos = cursor_pos + 1
|
|
||||||
show()
|
|
||||||
elseif frame__try_rshift() then show() end
|
|
||||||
elseif event.type == KEY_CLICK.DOWN or event.type == KEY_CLICK.HELD then
|
elseif event.type == KEY_CLICK.DOWN or event.type == KEY_CLICK.HELD then
|
||||||
if (event.key == keys.backspace or event.key == keys.delete) then
|
if (event.key == keys.backspace or event.key == keys.delete) then
|
||||||
-- remove charcter at cursor if there is anything to remove
|
ifield.backspace()
|
||||||
if frame_start + cursor_pos > 2 then
|
|
||||||
e.value = string.sub(e.value, 1, frame_start + cursor_pos - 3) .. string.sub(e.value, frame_start + cursor_pos - 1, string.len(e.value))
|
|
||||||
if cursor_pos > 1 then
|
|
||||||
cursor_pos = cursor_pos - 1
|
|
||||||
show()
|
|
||||||
elseif frame__try_lshift() then show() end
|
|
||||||
end
|
|
||||||
elseif event.key == keys.left then
|
elseif event.key == keys.left then
|
||||||
if cursor_pos > 1 then
|
ifield.nav_left()
|
||||||
cursor_pos = cursor_pos - 1
|
|
||||||
show()
|
|
||||||
elseif frame__try_lshift() then show() end
|
|
||||||
elseif event.key == keys.right then
|
elseif event.key == keys.right then
|
||||||
if cursor_pos <= string.len(visible_text) then
|
ifield.nav_right()
|
||||||
cursor_pos = cursor_pos + 1
|
elseif event.key == keys.a and event.ctrl then
|
||||||
show()
|
ifield.select_all()
|
||||||
elseif frame__try_rshift() then show() end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- set the value
|
-- set the value
|
||||||
---@param val string string to show
|
---@param val string string to set
|
||||||
function e.set_value(val)
|
function e.set_value(val)
|
||||||
e.value = string.sub(val, 1, math.min(max_len, string.len(val)))
|
ifield.set_value(val)
|
||||||
frame_start = 1 + math.max(0, string.len(val) - e.frame.w)
|
|
||||||
frame__update_visible()
|
|
||||||
cursor_pos = string.len(visible_text) + 1
|
|
||||||
show()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- replace text with pasted text
|
||||||
|
---@param text string string to set
|
||||||
function e.handle_paste(text)
|
function e.handle_paste(text)
|
||||||
e.set_value(text)
|
ifield.set_value(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- handle focus
|
-- handle focus
|
||||||
e.on_focused = show
|
e.on_focused = ifield.show
|
||||||
e.on_unfocused = show
|
e.on_unfocused = ifield.show
|
||||||
|
|
||||||
-- on enable/disable
|
-- on enable/disable
|
||||||
e.enable = show
|
e.enable = ifield.show
|
||||||
e.disable = show
|
e.disable = ifield.show
|
||||||
|
|
||||||
-- initial draw
|
-- initial draw
|
||||||
show()
|
ifield.show()
|
||||||
|
|
||||||
return e.complete()
|
return e.complete()
|
||||||
end
|
end
|
||||||
|
Loading…
x
Reference in New Issue
Block a user