mirror of
https://github.com/markqvist/NomadNet.git
synced 2025-04-18 14:26:06 -04:00
Merge c280e36a84c5cb3ac6bb60e285b9c53676533a1a into 03d1b22b8f537667e45e0380b45210e16651fd5b
This commit is contained in:
commit
cf82b322c1
@ -51,7 +51,15 @@ THEMES = {
|
||||
("browser_controls", "light gray", "default", "default", "#bbb", "default"),
|
||||
("progress_full", "black", "light gray", "standout", "#111", "#bbb"),
|
||||
("progress_empty", "light gray", "default", "default", "#ddd", "default"),
|
||||
],
|
||||
("interface_title", "", "", "default", "", ""),
|
||||
("interface_title_selected", "bold", "", "bold", "", ""),
|
||||
("connected_status", "dark green", "default", "default", "dark green", "default"),
|
||||
("disconnected_status", "dark red", "default", "default", "dark red", "default"),
|
||||
("placeholder", "dark gray", "default", "default", "dark gray", "default"),
|
||||
("placeholder_text", "dark gray", "default", "default", "dark gray", "default"),
|
||||
("error", "light red,blink", "default", "blink", "#f44,blink", "default"),
|
||||
|
||||
],
|
||||
},
|
||||
THEME_LIGHT: {
|
||||
"urwid_theme": [
|
||||
@ -69,6 +77,7 @@ THEMES = {
|
||||
("msg_header_ok", "black", "dark green", "standout", "#111", "#6b2"),
|
||||
("msg_header_caution", "black", "yellow", "standout", "#111", "#fd3"),
|
||||
("msg_header_sent", "black", "dark gray", "standout", "#111", "#ddd"),
|
||||
("msg_header_propagated", "black", "light blue", "standout", "#111", "#28b"),
|
||||
("msg_header_delivered", "black", "light blue", "standout", "#111", "#28b"),
|
||||
("msg_header_failed", "black", "dark gray", "standout", "#000", "#777"),
|
||||
("msg_warning_untrusted", "black", "dark red", "standout", "#111", "dark red"),
|
||||
@ -86,6 +95,13 @@ THEMES = {
|
||||
("browser_controls", "dark gray", "default", "default", "#444", "default"),
|
||||
("progress_full", "black", "dark gray", "standout", "#111", "#bbb"),
|
||||
("progress_empty", "dark gray", "default", "default", "#ddd", "default"),
|
||||
("interface_title", "dark gray", "default", "default", "#444", "default"),
|
||||
("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"),
|
||||
("connected_status", "dark green", "default", "default", "#4a0", "default"),
|
||||
("disconnected_status", "dark red", "default", "default", "#a22", "default"),
|
||||
("placeholder", "light gray", "default", "default", "#999", "default"),
|
||||
("placeholder_text", "light gray", "default", "default", "#999", "default"),
|
||||
("error", "dark red,blink", "default", "blink", "#a22,blink", "default"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -128,6 +144,8 @@ GLYPHS = {
|
||||
("sent", "/\\", "\u2191", "\U000f0cd8"),
|
||||
("papermsg", "P", "\u25a4", "\uf719"),
|
||||
("qrcode", "QR", "\u25a4", "\uf029"),
|
||||
("selected", "[*] ", "\u25CF", "\u25CF"),
|
||||
("unselected", "[ ] ", "\u25CB", "\u25CB"),
|
||||
}
|
||||
|
||||
class TextUI:
|
||||
@ -163,7 +181,7 @@ class TextUI:
|
||||
|
||||
if self.app.config["textui"]["glyphs"] == "plain":
|
||||
glyphset = "plain"
|
||||
elif self.app.config["textui"]["glyphs"] == "unicoode":
|
||||
elif self.app.config["textui"]["glyphs"] == "unicode":
|
||||
glyphset = "unicode"
|
||||
elif self.app.config["textui"]["glyphs"] == "nerdfont":
|
||||
glyphset = "nerdfont"
|
||||
|
@ -109,6 +109,7 @@ class TopicList(urwid.WidgetWrap):
|
||||
self.topic_list = [
|
||||
GuideEntry(self.app, self, guide_display, "Introduction"),
|
||||
GuideEntry(self.app, self, guide_display, "Concepts & Terminology"),
|
||||
GuideEntry(self.app, self, guide_display, "Interfaces"),
|
||||
GuideEntry(self.app, self, guide_display, "Hosting a Node"),
|
||||
GuideEntry(self.app, self, guide_display, "Configuration Options"),
|
||||
GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"),
|
||||
@ -386,6 +387,10 @@ Links can be inserted into micron documents. See the `*Markup`* section of this
|
||||
|
||||
'''
|
||||
|
||||
TOPIC_INTERFACES = '''
|
||||
>TODO
|
||||
'''
|
||||
|
||||
TOPIC_CONVERSATIONS = '''>Conversations
|
||||
|
||||
Conversations in Nomad Network
|
||||
@ -1247,6 +1252,7 @@ TOPICS = {
|
||||
"Introduction": TOPIC_INTRODUCTION,
|
||||
"Concepts & Terminology": TOPIC_CONCEPTS,
|
||||
"Conversations": TOPIC_CONVERSATIONS,
|
||||
"Interfaces": TOPIC_INTERFACES,
|
||||
"Hosting a Node": TOPIC_HOSTING,
|
||||
"Configuration Options": TOPIC_CONFIG,
|
||||
"Keyboard Shortcuts": TOPIC_SHORTCUTS,
|
||||
|
2865
nomadnet/ui/textui/Interfaces.py
Normal file
2865
nomadnet/ui/textui/Interfaces.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ from .Network import *
|
||||
from .Conversations import *
|
||||
from .Directory import *
|
||||
from .Config import *
|
||||
from .Interfaces import *
|
||||
from .Map import *
|
||||
from .Log import *
|
||||
from .Guide import *
|
||||
@ -16,6 +17,7 @@ class SubDisplays():
|
||||
self.conversations_display = ConversationsDisplay(self.app)
|
||||
self.directory_display = DirectoryDisplay(self.app)
|
||||
self.config_display = ConfigDisplay(self.app)
|
||||
self.interface_display = InterfaceDisplay(self.app)
|
||||
self.map_display = MapDisplay(self.app)
|
||||
self.log_display = LogDisplay(self.app)
|
||||
self.guide_display = GuideDisplay(self.app)
|
||||
@ -113,6 +115,11 @@ class MainDisplay():
|
||||
self.sub_displays.active_display = self.sub_displays.config_display
|
||||
self.update_active_sub_display()
|
||||
|
||||
def show_interfaces(self, user_data):
|
||||
self.sub_displays.active_display = self.sub_displays.interface_display
|
||||
self.update_active_sub_display()
|
||||
self.sub_displays.interface_display.start()
|
||||
|
||||
def show_log(self, user_data):
|
||||
self.sub_displays.active_display = self.sub_displays.log_display
|
||||
self.sub_displays.log_display.show()
|
||||
@ -171,21 +178,22 @@ class MenuDisplay():
|
||||
|
||||
self.menu_indicator = urwid.Text("")
|
||||
|
||||
menu_text = (urwid.PACK, self.menu_indicator)
|
||||
button_network = (11, MenuButton("Network", on_press=handler.show_network))
|
||||
button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations))
|
||||
button_directory = (13, MenuButton("Directory", on_press=handler.show_directory))
|
||||
button_map = (7, MenuButton("Map", on_press=handler.show_map))
|
||||
button_log = (7, MenuButton("Log", on_press=handler.show_log))
|
||||
button_config = (10, MenuButton("Config", on_press=handler.show_config))
|
||||
button_guide = (9, MenuButton("Guide", on_press=handler.show_guide))
|
||||
button_quit = (8, MenuButton("Quit", on_press=handler.quit))
|
||||
menu_text = (urwid.PACK, self.menu_indicator)
|
||||
button_network = (11, MenuButton("Network", on_press=handler.show_network))
|
||||
button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations))
|
||||
button_directory = (13, MenuButton("Directory", on_press=handler.show_directory))
|
||||
button_map = (7, MenuButton("Map", on_press=handler.show_map))
|
||||
button_log = (7, MenuButton("Log", on_press=handler.show_log))
|
||||
button_config = (10, MenuButton("Config", on_press=handler.show_config))
|
||||
button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces))
|
||||
button_guide = (9, MenuButton("Guide", on_press=handler.show_guide))
|
||||
button_quit = (8, MenuButton("Quit", on_press=handler.quit))
|
||||
|
||||
# buttons = [menu_text, button_conversations, button_node, button_directory, button_map]
|
||||
if self.app.config["textui"]["hide_guide"]:
|
||||
buttons = [menu_text, button_conversations, button_network, button_log, button_config, button_quit]
|
||||
buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit]
|
||||
else:
|
||||
buttons = [menu_text, button_conversations, button_network, button_log, button_config, button_guide, button_quit]
|
||||
buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit]
|
||||
|
||||
columns = MenuColumns(buttons, dividechars=1)
|
||||
columns.handler = handler
|
||||
|
87
nomadnet/vendor/AsciiChart.py
vendored
Normal file
87
nomadnet/vendor/AsciiChart.py
vendored
Normal file
@ -0,0 +1,87 @@
|
||||
from __future__ import division
|
||||
from math import ceil, floor, isnan
|
||||
# Derived from asciichartpy | https://github.com/kroitor/asciichart/blob/master/asciichartpy/__init__.py
|
||||
class AsciiChart:
|
||||
def __init__(self, glyphset="unicode"):
|
||||
self.symbols = ['┼', '┤', '╶', '╴', '─', '╰', '╭', '╮', '╯', '│']
|
||||
if glyphset == "plain":
|
||||
self.symbols = ['+', '|', '-', '-', '-', '\'', ',', '.', '`', '|']
|
||||
def plot(self, series, cfg=None):
|
||||
if len(series) == 0:
|
||||
return ''
|
||||
if not isinstance(series[0], list):
|
||||
if all(isnan(n) for n in series):
|
||||
return ''
|
||||
else:
|
||||
series = [series]
|
||||
cfg = cfg or {}
|
||||
minimum = cfg.get('min', min(filter(lambda n: not isnan(n), [j for i in series for j in i])))
|
||||
maximum = cfg.get('max', max(filter(lambda n: not isnan(n), [j for i in series for j in i])))
|
||||
symbols = cfg.get('symbols', self.symbols)
|
||||
if minimum > maximum:
|
||||
raise ValueError('The min value cannot exceed the max value.')
|
||||
interval = maximum - minimum
|
||||
offset = cfg.get('offset', 3)
|
||||
height = cfg.get('height', interval)
|
||||
ratio = height / interval if interval > 0 else 1
|
||||
|
||||
min2 = int(floor(minimum * ratio))
|
||||
max2 = int(ceil(maximum * ratio))
|
||||
|
||||
def clamp(n):
|
||||
return min(max(n, minimum), maximum)
|
||||
|
||||
def scaled(y):
|
||||
return int(round(clamp(y) * ratio) - min2)
|
||||
|
||||
rows = max2 - min2
|
||||
|
||||
width = 0
|
||||
for i in range(0, len(series)):
|
||||
width = max(width, len(series[i]))
|
||||
width += offset
|
||||
|
||||
placeholder = cfg.get('format', '{:8.2f} ')
|
||||
|
||||
result = [[' '] * width for i in range(rows + 1)]
|
||||
|
||||
for y in range(min2, max2 + 1):
|
||||
label = placeholder.format(maximum - ((y - min2) * interval / (rows if rows else 1)))
|
||||
result[y - min2][max(offset - len(label), 0)] = label
|
||||
result[y - min2][offset - 1] = symbols[0] if y == 0 else symbols[1]
|
||||
|
||||
d0 = series[0][0]
|
||||
if not isnan(d0):
|
||||
result[rows - scaled(d0)][offset - 1] = symbols[0]
|
||||
|
||||
for i in range(0, len(series)):
|
||||
for x in range(0, len(series[i]) - 1):
|
||||
d0 = series[i][x + 0]
|
||||
d1 = series[i][x + 1]
|
||||
|
||||
if isnan(d0) and isnan(d1):
|
||||
continue
|
||||
|
||||
if isnan(d0) and not isnan(d1):
|
||||
result[rows - scaled(d1)][x + offset] = symbols[2]
|
||||
continue
|
||||
|
||||
if not isnan(d0) and isnan(d1):
|
||||
result[rows - scaled(d0)][x + offset] = symbols[3]
|
||||
continue
|
||||
|
||||
y0 = scaled(d0)
|
||||
y1 = scaled(d1)
|
||||
if y0 == y1:
|
||||
result[rows - y0][x + offset] = symbols[4]
|
||||
continue
|
||||
|
||||
result[rows - y1][x + offset] = symbols[5] if y0 > y1 else symbols[6]
|
||||
result[rows - y0][x + offset] = symbols[7] if y0 > y1 else symbols[8]
|
||||
|
||||
start = min(y0, y1) + 1
|
||||
end = max(y0, y1)
|
||||
for y in range(start, end):
|
||||
result[rows - y][x + offset] = symbols[9]
|
||||
|
||||
return '\n'.join([''.join(row).rstrip() for row in result])
|
283
nomadnet/vendor/additional_urwid_widgets/FormWidgets.py
vendored
Normal file
283
nomadnet/vendor/additional_urwid_widgets/FormWidgets.py
vendored
Normal file
@ -0,0 +1,283 @@
|
||||
import urwid
|
||||
|
||||
class DialogLineBox(urwid.LineBox):
|
||||
def __init__(self, body, parent=None, title="?"):
|
||||
super().__init__(body, title=title)
|
||||
self.parent = parent
|
||||
|
||||
def keypress(self, size, key):
|
||||
if key == "esc":
|
||||
if self.parent and hasattr(self.parent, "dismiss_dialog"):
|
||||
self.parent.dismiss_dialog()
|
||||
return None
|
||||
return super().keypress(size, key)
|
||||
|
||||
class Placeholder(urwid.Edit):
|
||||
def __init__(self, caption="", edit_text="", placeholder="", **kwargs):
|
||||
super().__init__(caption, edit_text, **kwargs)
|
||||
self.placeholder = placeholder
|
||||
|
||||
def render(self, size, focus=False):
|
||||
if not self.edit_text and not focus:
|
||||
placeholder_widget = urwid.Text(("placeholder", self.placeholder))
|
||||
return placeholder_widget.render(size, focus)
|
||||
else:
|
||||
return super().render(size, focus)
|
||||
|
||||
class Dropdown(urwid.WidgetWrap):
|
||||
signals = ['change'] # emit for urwid.connect_signal fn
|
||||
|
||||
def __init__(self, label, options, default=None):
|
||||
self.label = label
|
||||
self.options = options
|
||||
self.selected = default if default is not None else options[0]
|
||||
|
||||
self.main_text = f"{self.selected}"
|
||||
self.main_button = urwid.SelectableIcon(self.main_text, 0)
|
||||
self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus")
|
||||
|
||||
self.option_widgets = []
|
||||
for opt in options:
|
||||
icon = urwid.SelectableIcon(opt, 0)
|
||||
icon = urwid.AttrMap(icon, "list_normal", "list_focus")
|
||||
self.option_widgets.append(icon)
|
||||
|
||||
self.options_walker = urwid.SimpleFocusListWalker(self.option_widgets)
|
||||
self.options_listbox = urwid.ListBox(self.options_walker)
|
||||
self.dropdown_box = None # will be created on open_dropdown
|
||||
|
||||
self.pile = urwid.Pile([self.main_button])
|
||||
self.dropdown_visible = False
|
||||
|
||||
super().__init__(self.pile)
|
||||
|
||||
def open_dropdown(self):
|
||||
if not self.dropdown_visible:
|
||||
height = len(self.options)
|
||||
self.dropdown_box = urwid.BoxAdapter(self.options_listbox, height)
|
||||
self.pile.contents.append((self.dropdown_box, self.pile.options()))
|
||||
self.dropdown_visible = True
|
||||
self.pile.focus_position = 1
|
||||
self.options_walker.set_focus(0)
|
||||
|
||||
def close_dropdown(self):
|
||||
if self.dropdown_visible:
|
||||
self.pile.contents.pop() # remove the dropdown_box
|
||||
self.dropdown_visible = False
|
||||
self.pile.focus_position = 0
|
||||
self.dropdown_box = None
|
||||
|
||||
def keypress(self, size, key):
|
||||
if not self.dropdown_visible:
|
||||
if key == "enter":
|
||||
self.open_dropdown()
|
||||
return None
|
||||
return self.main_button.keypress(size, key)
|
||||
else:
|
||||
if key == "enter":
|
||||
focus_result = self.options_walker.get_focus()
|
||||
if focus_result is not None:
|
||||
focus_widget = focus_result[0]
|
||||
new_val = focus_widget.base_widget.text
|
||||
old_val = self.selected
|
||||
self.selected = new_val
|
||||
self.main_button.base_widget.set_text(f"{self.selected}")
|
||||
|
||||
if old_val != new_val:
|
||||
self._emit('change', new_val)
|
||||
|
||||
self.close_dropdown()
|
||||
return None
|
||||
return self.dropdown_box.keypress(size, key)
|
||||
|
||||
def get_value(self):
|
||||
return self.selected
|
||||
|
||||
class ValidationError(urwid.Text):
|
||||
def __init__(self, message=""):
|
||||
super().__init__(("error", message))
|
||||
|
||||
class FormField:
|
||||
def __init__(self, config_key, transform=None):
|
||||
self.config_key = config_key
|
||||
self.transform = transform or (lambda x: x)
|
||||
|
||||
class FormEdit(Placeholder, FormField):
|
||||
def __init__(self, config_key, caption="", edit_text="", placeholder="", validation_types=None, transform=None, **kwargs):
|
||||
Placeholder.__init__(self, caption, edit_text, placeholder, **kwargs)
|
||||
FormField.__init__(self, config_key, transform)
|
||||
self.validation_types = validation_types or []
|
||||
self.error_widget = urwid.Text("")
|
||||
self.error = None
|
||||
|
||||
def get_value(self):
|
||||
return self.transform(self.edit_text.strip())
|
||||
|
||||
def validate(self):
|
||||
value = self.edit_text.strip()
|
||||
self.error = None
|
||||
|
||||
for validation in self.validation_types:
|
||||
if validation == "required":
|
||||
if not value:
|
||||
self.error = "This field is required"
|
||||
break
|
||||
elif validation == "number":
|
||||
if value and not value.replace('-', '').replace('.', '').isdigit():
|
||||
self.error = "This field must be a number"
|
||||
break
|
||||
elif validation == "float":
|
||||
try:
|
||||
if value:
|
||||
float(value)
|
||||
except ValueError:
|
||||
self.error = "This field must be decimal number"
|
||||
break
|
||||
|
||||
self.error_widget.set_text(("error", self.error or ""))
|
||||
return self.error is None
|
||||
|
||||
class FormCheckbox(urwid.CheckBox, FormField):
|
||||
def __init__(self, config_key, label="", state=False, validation_types=None, transform=None, **kwargs):
|
||||
urwid.CheckBox.__init__(self, label, state, **kwargs)
|
||||
FormField.__init__(self, config_key, transform)
|
||||
self.validation_types = validation_types or []
|
||||
self.error_widget = urwid.Text("")
|
||||
self.error = None
|
||||
|
||||
def get_value(self):
|
||||
return self.transform(self.get_state())
|
||||
|
||||
def validate(self):
|
||||
|
||||
value = self.get_state()
|
||||
self.error = None
|
||||
|
||||
for validation in self.validation_types:
|
||||
if validation == "required":
|
||||
if not value:
|
||||
self.error = "This field is required"
|
||||
break
|
||||
|
||||
self.error_widget.set_text(("error", self.error or ""))
|
||||
return self.error is None
|
||||
|
||||
class FormDropdown(Dropdown, FormField):
|
||||
signals = ['change']
|
||||
|
||||
def __init__(self, config_key, label, options, default=None, validation_types=None, transform=None):
|
||||
self.options = [str(opt) for opt in options]
|
||||
|
||||
if default is not None:
|
||||
default_str = str(default)
|
||||
if default_str in self.options:
|
||||
default = default_str
|
||||
elif transform:
|
||||
try:
|
||||
default_transformed = transform(default_str)
|
||||
for opt in self.options:
|
||||
if transform(opt) == default_transformed:
|
||||
default = opt
|
||||
break
|
||||
except:
|
||||
default = self.options[0]
|
||||
else:
|
||||
default = self.options[0]
|
||||
else:
|
||||
default = self.options[0]
|
||||
|
||||
Dropdown.__init__(self, label, self.options, default)
|
||||
FormField.__init__(self, config_key, transform)
|
||||
|
||||
self.validation_types = validation_types or []
|
||||
self.error_widget = urwid.Text("")
|
||||
self.error = None
|
||||
|
||||
if hasattr(self, 'main_button'):
|
||||
self.main_button.base_widget.set_text(str(default))
|
||||
|
||||
def get_value(self):
|
||||
return self.transform(self.selected)
|
||||
|
||||
def validate(self):
|
||||
value = self.get_value()
|
||||
self.error = None
|
||||
|
||||
for validation in self.validation_types:
|
||||
if validation == "required":
|
||||
if not value:
|
||||
self.error = "This field is required"
|
||||
break
|
||||
|
||||
self.error_widget.set_text(("error", self.error or ""))
|
||||
return self.error is None
|
||||
|
||||
def open_dropdown(self):
|
||||
if not self.dropdown_visible:
|
||||
super().open_dropdown()
|
||||
try:
|
||||
current_index = self.options.index(self.selected)
|
||||
self.options_walker.set_focus(current_index)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
class FormMultiList(urwid.Pile, FormField):
|
||||
def __init__(self, config_key, placeholder="", validation_types=None, transform=None, **kwargs):
|
||||
self.entries = []
|
||||
self.error_widget = urwid.Text("")
|
||||
self.error = None
|
||||
self.placeholder = placeholder
|
||||
self.validation_types = validation_types or []
|
||||
|
||||
first_entry = self.create_entry_row()
|
||||
self.entries.append(first_entry)
|
||||
|
||||
self.add_button = urwid.Button("+ Add Another", on_press=self.add_entry)
|
||||
add_button_padded = urwid.Padding(self.add_button, left=2, right=2)
|
||||
|
||||
pile_widgets = [first_entry, add_button_padded]
|
||||
urwid.Pile.__init__(self, pile_widgets)
|
||||
FormField.__init__(self, config_key, transform)
|
||||
|
||||
def create_entry_row(self):
|
||||
edit = urwid.Edit("", "")
|
||||
entry_row = urwid.Columns([
|
||||
('weight', 1, edit),
|
||||
(3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))),
|
||||
])
|
||||
return entry_row
|
||||
|
||||
def remove_entry(self, button, entry_row):
|
||||
if len(self.entries) > 1:
|
||||
self.entries.remove(entry_row)
|
||||
self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
|
||||
|
||||
def add_entry(self, button):
|
||||
new_entry = self.create_entry_row()
|
||||
self.entries.append(new_entry)
|
||||
|
||||
self.contents = [(w, self.options()) for w in self.get_pile_widgets()]
|
||||
|
||||
def get_pile_widgets(self):
|
||||
return self.entries + [urwid.Padding(self.add_button, left=2, right=2)]
|
||||
|
||||
def get_value(self):
|
||||
values = []
|
||||
for entry in self.entries:
|
||||
edit_widget = entry.contents[0][0]
|
||||
value = edit_widget.edit_text.strip()
|
||||
if value:
|
||||
values.append(value)
|
||||
return self.transform(values)
|
||||
|
||||
def validate(self):
|
||||
values = self.get_value()
|
||||
self.error = None
|
||||
|
||||
for validation in self.validation_types:
|
||||
if validation == "required" and not values:
|
||||
self.error = "At least one entry is required"
|
||||
break
|
||||
|
||||
self.error_widget.set_text(("error", self.error or ""))
|
||||
return self.error is None
|
Loading…
x
Reference in New Issue
Block a user