mirror of
https://github.com/eried/portapack-mayhem.git
synced 2024-12-23 14:29:23 -05:00
Fileman icon toolbar (#975)
* Add cut/copy/paste menu with icons * Add icons, file limit to fileman
This commit is contained in:
parent
f9fdeb3419
commit
a832bd433d
@ -22,7 +22,6 @@
|
||||
|
||||
/* TODO:
|
||||
* - Paging menu items
|
||||
* - Copy/Move
|
||||
*/
|
||||
|
||||
#include <algorithm>
|
||||
@ -146,6 +145,22 @@ bool partner_file_prompt(
|
||||
return true;
|
||||
}
|
||||
|
||||
fs::path get_unique_filename(const fs::path& path, const fs::path& file) {
|
||||
auto stem = file.stem();
|
||||
auto ext = file.extension();
|
||||
auto serial = 1;
|
||||
fs::path new_path = file;
|
||||
|
||||
while (fs::file_exists(path / new_path)) {
|
||||
new_path = stem;
|
||||
new_path += fs::path{ u"_" };
|
||||
new_path += to_string_dec_int(serial++);
|
||||
new_path += ext;
|
||||
}
|
||||
|
||||
return new_path;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
namespace ui {
|
||||
@ -198,6 +213,7 @@ FileManBaseView::FileManBaseView(
|
||||
add_children({
|
||||
&labels,
|
||||
&text_current,
|
||||
&text_info,
|
||||
&button_exit
|
||||
});
|
||||
|
||||
@ -287,8 +303,13 @@ void FileManBaseView::refresh_list() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// HACK: Should page menu items instead of limiting the number.
|
||||
if (menu_view.item_count() >= max_items_shown)
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
text_info.set(menu_view.item_count() >= max_items_shown ? "Too many files!" : "");
|
||||
menu_view.set_highlighted(prev_highlight);
|
||||
}
|
||||
|
||||
@ -465,26 +486,30 @@ void FileManagerView::on_new_dir() {
|
||||
});
|
||||
}
|
||||
|
||||
void FileManagerView::on_new_file() {
|
||||
name_buffer = "";
|
||||
text_prompt(nav_, name_buffer, max_filename_length, [this](std::string& file_name) {
|
||||
make_new_file(current_path / file_name);
|
||||
reload_current();
|
||||
});
|
||||
}
|
||||
|
||||
void FileManagerView::on_paste() {
|
||||
auto stem = copy_path.stem();
|
||||
auto ext = copy_path.extension();
|
||||
auto serial = 1;
|
||||
fs::path new_path = copy_path.filename();
|
||||
|
||||
// Create a unique name.
|
||||
while (fs::file_exists(current_path / new_path)) {
|
||||
new_path = stem;
|
||||
new_path += fs::path{ u"_" };
|
||||
new_path += to_string_dec_int(serial++);
|
||||
new_path += ext;
|
||||
}
|
||||
|
||||
// TODO: handle partner file. Need to fix nav stack first.
|
||||
auto result = copy_file(copy_path, current_path / new_path);
|
||||
auto new_name = get_unique_filename(current_path, clipboard_path.filename());
|
||||
fs::filesystem_error result;
|
||||
|
||||
if (clipboard_mode == ClipboardMode::Cut)
|
||||
result = rename_file(clipboard_path, current_path / new_name);
|
||||
|
||||
else if (clipboard_mode == ClipboardMode::Copy)
|
||||
result = copy_file(clipboard_path, current_path / new_name);
|
||||
|
||||
if (result.code() != FR_OK)
|
||||
nav_.display_modal("Paste Failed", result.what());
|
||||
|
||||
copy_path = fs::path{ };
|
||||
clipboard_path = fs::path{ };
|
||||
clipboard_mode = ClipboardMode::None;
|
||||
menu_view.focus();
|
||||
reload_current();
|
||||
}
|
||||
@ -497,9 +522,12 @@ bool FileManagerView::selected_is_valid() const {
|
||||
void FileManagerView::refresh_widgets(const bool v) {
|
||||
button_rename.hidden(v);
|
||||
button_delete.hidden(v);
|
||||
button_new_dir.hidden(v);
|
||||
button_cut.hidden(v);
|
||||
button_copy.hidden(v);
|
||||
button_paste.hidden(v);
|
||||
button_new_dir.hidden(v);
|
||||
button_new_file.hidden(v);
|
||||
|
||||
set_dirty();
|
||||
}
|
||||
|
||||
@ -521,13 +549,14 @@ FileManagerView::FileManagerView(
|
||||
&text_date,
|
||||
&button_rename,
|
||||
&button_delete,
|
||||
&button_new_dir,
|
||||
&button_cut,
|
||||
&button_copy,
|
||||
&button_paste
|
||||
&button_paste,
|
||||
&button_new_dir,
|
||||
&button_new_file
|
||||
});
|
||||
|
||||
menu_view.on_highlight = [this]() {
|
||||
// TODO: enable/disable buttons.
|
||||
if (selected_is_valid())
|
||||
text_date.set(to_string_FAT_timestamp(file_created_date(get_selected_full_path())));
|
||||
else
|
||||
@ -544,32 +573,45 @@ FileManagerView::FileManagerView(
|
||||
}
|
||||
};
|
||||
|
||||
button_rename.on_select = [this](Button&) {
|
||||
button_rename.on_select = [this]() {
|
||||
if (selected_is_valid())
|
||||
on_rename();
|
||||
};
|
||||
|
||||
button_delete.on_select = [this](Button&) {
|
||||
button_delete.on_select = [this]() {
|
||||
if (selected_is_valid())
|
||||
on_delete();
|
||||
};
|
||||
|
||||
button_new_dir.on_select = [this](Button&) {
|
||||
on_new_dir();
|
||||
button_cut.on_select = [this]() {
|
||||
if (selected_is_valid() && !get_selected_entry().is_directory) {
|
||||
clipboard_path = get_selected_full_path();
|
||||
clipboard_mode = ClipboardMode::Cut;
|
||||
} else
|
||||
nav_.display_modal("Cut", "Can't cut that.");
|
||||
};
|
||||
|
||||
button_copy.on_select = [this](Button&) {
|
||||
if (selected_is_valid() && !get_selected_entry().is_directory)
|
||||
copy_path = get_selected_full_path();
|
||||
else
|
||||
button_copy.on_select = [this]() {
|
||||
if (selected_is_valid() && !get_selected_entry().is_directory) {
|
||||
clipboard_path = get_selected_full_path();
|
||||
clipboard_mode = ClipboardMode::Copy;
|
||||
} else
|
||||
nav_.display_modal("Copy", "Can't copy that.");
|
||||
};
|
||||
|
||||
button_paste.on_select = [this](Button&) {
|
||||
if (!copy_path.empty())
|
||||
button_paste.on_select = [this]() {
|
||||
if (clipboard_mode != ClipboardMode::None)
|
||||
on_paste();
|
||||
else
|
||||
nav_.display_modal("Paste", "Copy a file first.");
|
||||
nav_.display_modal("Paste", "Cut or copy a file first.");
|
||||
};
|
||||
|
||||
button_new_dir.on_select = [this]() {
|
||||
on_new_dir();
|
||||
};
|
||||
|
||||
button_new_file.on_select = [this]() {
|
||||
on_new_file();
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -42,6 +42,12 @@ enum class EmptyReason : uint8_t {
|
||||
NoSDC
|
||||
};
|
||||
|
||||
enum class ClipboardMode : uint8_t {
|
||||
None,
|
||||
Cut,
|
||||
Copy
|
||||
};
|
||||
|
||||
class FileManBaseView : public View {
|
||||
public:
|
||||
FileManBaseView(
|
||||
@ -56,6 +62,7 @@ public:
|
||||
|
||||
protected:
|
||||
static constexpr size_t max_filename_length = 64;
|
||||
static constexpr size_t max_items_shown = 128;
|
||||
|
||||
struct file_assoc_t {
|
||||
std::filesystem::path extension;
|
||||
@ -109,6 +116,12 @@ protected:
|
||||
{ 0, 2 * 8, 240, 26 * 8 },
|
||||
true
|
||||
};
|
||||
|
||||
// HACK: for item count limit.
|
||||
Text text_info {
|
||||
{ 1 * 8, 35 * 8, 15 * 8, 16 },
|
||||
""
|
||||
};
|
||||
|
||||
Button button_exit {
|
||||
{ 21 * 8, 34 * 8, 9 * 8, 32 },
|
||||
@ -194,13 +207,15 @@ public:
|
||||
private:
|
||||
// Passed by ref to other views needing lifetime extension.
|
||||
std::string name_buffer { };
|
||||
std::filesystem::path copy_path { };
|
||||
std::filesystem::path clipboard_path { };
|
||||
ClipboardMode clipboard_mode { ClipboardMode::None };
|
||||
|
||||
void refresh_widgets(const bool v);
|
||||
void on_rename();
|
||||
void on_delete();
|
||||
void on_new_dir();
|
||||
void on_paste();
|
||||
void on_new_dir();
|
||||
void on_new_file();
|
||||
|
||||
// True if the selected entry is a real file item.
|
||||
bool selected_is_valid() const;
|
||||
@ -214,29 +229,53 @@ private:
|
||||
""
|
||||
};
|
||||
|
||||
Button button_rename {
|
||||
{ 0 * 8, 29 * 8, 9 * 8, 32 },
|
||||
"Rename"
|
||||
NewButton button_rename {
|
||||
{ 0 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_rename,
|
||||
Color::dark_blue()
|
||||
};
|
||||
|
||||
Button button_delete {
|
||||
{ 21 * 4, 29 * 8, 9 * 8, 32 },
|
||||
"Delete"
|
||||
NewButton button_delete {
|
||||
{ 4 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_trash,
|
||||
Color::red()
|
||||
};
|
||||
|
||||
Button button_new_dir {
|
||||
{ 21 * 8, 29 * 8, 9 * 8, 32 },
|
||||
"New Dir"
|
||||
NewButton button_cut {
|
||||
{ 9 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_cut,
|
||||
Color::dark_grey()
|
||||
};
|
||||
|
||||
Button button_copy {
|
||||
{ 0 * 8, 34 * 8, 9 * 8, 32 },
|
||||
"Copy"
|
||||
NewButton button_copy {
|
||||
{ 13 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_copy,
|
||||
Color::dark_grey()
|
||||
};
|
||||
|
||||
Button button_paste {
|
||||
{ 21 * 4, 34 * 8, 9 * 8, 32 },
|
||||
"Paste"
|
||||
NewButton button_paste {
|
||||
{ 17 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_paste,
|
||||
Color::dark_grey()
|
||||
};
|
||||
|
||||
NewButton button_new_dir {
|
||||
{ 22 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_new_dir,
|
||||
Color::green()
|
||||
};
|
||||
|
||||
NewButton button_new_file {
|
||||
{ 26 * 8, 29 * 8, 4 * 8, 32 },
|
||||
{ },
|
||||
&bitmap_icon_new_file,
|
||||
Color::green()
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -446,7 +446,7 @@ namespace ui {
|
||||
else
|
||||
{
|
||||
auto result = delete_file( pmem_flag_file );
|
||||
if( result != 0 )
|
||||
if( result.code() != FR_OK )
|
||||
{
|
||||
text_pmem_status.set("!err. deleting pmem flagfile!");
|
||||
}
|
||||
|
@ -2543,6 +2543,137 @@ static constexpr Bitmap bitmap_target {
|
||||
{ 16, 16 }, bitmap_target_data
|
||||
};
|
||||
|
||||
static constexpr uint8_t bitmap_icon_trash_data[] = {
|
||||
0x00, 0x00,
|
||||
0xC0, 0x01,
|
||||
0x20, 0x02,
|
||||
0xFC, 0x1F,
|
||||
0x00, 0x00,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0xA8, 0x0A,
|
||||
0x08, 0x08,
|
||||
0xF0, 0x07,
|
||||
0x00, 0x00,
|
||||
};
|
||||
static constexpr Bitmap bitmap_icon_trash {
|
||||
{ 16, 16 }, bitmap_icon_trash_data
|
||||
};
|
||||
|
||||
static constexpr uint8_t bitmap_icon_copy_data[] = {
|
||||
0x00, 0x00,
|
||||
0xFC, 0x00,
|
||||
0x84, 0x01,
|
||||
0xC4, 0x0F,
|
||||
0x74, 0x18,
|
||||
0x44, 0x38,
|
||||
0x44, 0x78,
|
||||
0x74, 0x40,
|
||||
0x44, 0x44,
|
||||
0x44, 0x44,
|
||||
0x74, 0x5F,
|
||||
0x44, 0x44,
|
||||
0x44, 0x44,
|
||||
0x7C, 0x40,
|
||||
0xC0, 0x7F,
|
||||
0x00, 0x00,
|
||||
};
|
||||
static constexpr Bitmap bitmap_icon_copy {
|
||||
{ 16, 16 }, bitmap_icon_copy_data
|
||||
};
|
||||
|
||||
static constexpr uint8_t bitmap_icon_cut_data[] = {
|
||||
0x00, 0x00,
|
||||
0x10, 0x10,
|
||||
0x30, 0x18,
|
||||
0x20, 0x08,
|
||||
0x60, 0x0C,
|
||||
0x40, 0x04,
|
||||
0xC0, 0x06,
|
||||
0x80, 0x00,
|
||||
0x80, 0x01,
|
||||
0x80, 0x01,
|
||||
0xC0, 0x03,
|
||||
0x78, 0x1E,
|
||||
0x44, 0x22,
|
||||
0x44, 0x22,
|
||||
0x44, 0x22,
|
||||
0x38, 0x1C,
|
||||
};
|
||||
static constexpr Bitmap bitmap_icon_cut {
|
||||
{ 16, 16 }, bitmap_icon_cut_data
|
||||
};
|
||||
|
||||
static constexpr uint8_t bitmap_icon_paste_data[] = {
|
||||
0x00, 0x00,
|
||||
0xE0, 0x00,
|
||||
0x18, 0x03,
|
||||
0xE4, 0x04,
|
||||
0x04, 0x04,
|
||||
0x04, 0x04,
|
||||
0x84, 0x3F,
|
||||
0x84, 0x20,
|
||||
0x84, 0x2E,
|
||||
0x84, 0x20,
|
||||
0x84, 0x2E,
|
||||
0x84, 0x20,
|
||||
0x84, 0x2E,
|
||||
0xF8, 0x20,
|
||||
0x80, 0x3F,
|
||||
0x00, 0x00,
|
||||
};
|
||||
static constexpr Bitmap bitmap_icon_paste {
|
||||
{ 16, 16 }, bitmap_icon_paste_data
|
||||
};
|
||||
|
||||
static constexpr uint8_t bitmap_icon_new_dir_data[] = {
|
||||
0x00, 0x00,
|
||||
0x1E, 0x00,
|
||||
0x21, 0x00,
|
||||
0xE1, 0x7F,
|
||||
0x01, 0xC0,
|
||||
0x81, 0x81,
|
||||
0x81, 0x81,
|
||||
0x81, 0x81,
|
||||
0xF1, 0x8F,
|
||||
0xF1, 0x8F,
|
||||
0x81, 0x81,
|
||||
0x81, 0x81,
|
||||
0x81, 0x81,
|
||||
0x03, 0xC0,
|
||||
0xFE, 0x7F,
|
||||
0x00, 0x00,
|
||||
};
|
||||
static constexpr Bitmap bitmap_icon_new_dir {
|
||||
{ 16, 16 }, bitmap_icon_new_dir_data
|
||||
};
|
||||
|
||||
static constexpr uint8_t bitmap_icon_new_file_data[] = {
|
||||
0x00, 0x00,
|
||||
0xFC, 0x07,
|
||||
0x04, 0x0C,
|
||||
0x04, 0x1C,
|
||||
0x04, 0x3C,
|
||||
0x84, 0x21,
|
||||
0x84, 0x21,
|
||||
0x84, 0x21,
|
||||
0xF4, 0x2F,
|
||||
0xF4, 0x2F,
|
||||
0x84, 0x21,
|
||||
0x84, 0x21,
|
||||
0x84, 0x21,
|
||||
0x04, 0x20,
|
||||
0xFC, 0x3F,
|
||||
0x00, 0x00,
|
||||
};
|
||||
static constexpr Bitmap bitmap_icon_new_file {
|
||||
{ 16, 16 }, bitmap_icon_new_file_data
|
||||
};
|
||||
|
||||
} /* namespace ui */
|
||||
|
||||
|
@ -197,18 +197,21 @@ std::vector<std::filesystem::path> scan_root_directories(const std::filesystem::
|
||||
return directory_list;
|
||||
}
|
||||
|
||||
uint32_t delete_file(const std::filesystem::path& file_path) {
|
||||
return f_unlink(reinterpret_cast<const TCHAR*>(file_path.c_str()));
|
||||
std::filesystem::filesystem_error delete_file(const std::filesystem::path& file_path) {
|
||||
return { f_unlink(reinterpret_cast<const TCHAR*>(file_path.c_str())) };
|
||||
}
|
||||
|
||||
uint32_t rename_file(const std::filesystem::path& file_path, const std::filesystem::path& new_name) {
|
||||
return f_rename(reinterpret_cast<const TCHAR*>(file_path.c_str()), reinterpret_cast<const TCHAR*>(new_name.c_str()));
|
||||
std::filesystem::filesystem_error rename_file(
|
||||
const std::filesystem::path& file_path,
|
||||
const std::filesystem::path& new_name
|
||||
) {
|
||||
return { f_rename(reinterpret_cast<const TCHAR*>(file_path.c_str()), reinterpret_cast<const TCHAR*>(new_name.c_str())) };
|
||||
}
|
||||
|
||||
std::filesystem::filesystem_error copy_file(
|
||||
const std::filesystem::path& file_path,
|
||||
const std::filesystem::path& dest_path)
|
||||
{
|
||||
const std::filesystem::path& dest_path
|
||||
) {
|
||||
File src;
|
||||
File dst;
|
||||
constexpr size_t buffer_size = 512;
|
||||
@ -242,8 +245,20 @@ FATTimestamp file_created_date(const std::filesystem::path& file_path) {
|
||||
return { filinfo.fdate, filinfo.ftime };
|
||||
}
|
||||
|
||||
uint32_t make_new_directory(const std::filesystem::path& dir_path) {
|
||||
return f_mkdir(reinterpret_cast<const TCHAR*>(dir_path.c_str()));
|
||||
std::filesystem::filesystem_error make_new_file(
|
||||
const std::filesystem::path& file_path
|
||||
) {
|
||||
File f;
|
||||
auto result = f.create(file_path);
|
||||
return result.is_valid()
|
||||
? result.value()
|
||||
: std::filesystem::filesystem_error{ };
|
||||
}
|
||||
|
||||
std::filesystem::filesystem_error make_new_directory(
|
||||
const std::filesystem::path& dir_path
|
||||
) {
|
||||
return { f_mkdir(reinterpret_cast<const TCHAR*>(dir_path.c_str())) };
|
||||
}
|
||||
|
||||
namespace std {
|
||||
|
@ -252,11 +252,12 @@ struct FATTimestamp {
|
||||
uint16_t FAT_time;
|
||||
};
|
||||
|
||||
uint32_t delete_file(const std::filesystem::path& file_path);
|
||||
uint32_t rename_file(const std::filesystem::path& file_path, const std::filesystem::path& new_name);
|
||||
std::filesystem::filesystem_error delete_file(const std::filesystem::path& file_path);
|
||||
std::filesystem::filesystem_error rename_file(const std::filesystem::path& file_path, const std::filesystem::path& new_name);
|
||||
std::filesystem::filesystem_error copy_file(const std::filesystem::path& file_path, const std::filesystem::path& dest_path);
|
||||
FATTimestamp file_created_date(const std::filesystem::path& file_path);
|
||||
uint32_t make_new_directory(const std::filesystem::path& dir_path);
|
||||
std::filesystem::filesystem_error make_new_file(const std::filesystem::path& file_path);
|
||||
std::filesystem::filesystem_error make_new_directory(const std::filesystem::path& dir_path);
|
||||
|
||||
std::vector<std::filesystem::path> scan_root_files(const std::filesystem::path& directory, const std::filesystem::path& extension);
|
||||
std::vector<std::filesystem::path> scan_root_directories(const std::filesystem::path& directory);
|
||||
|
@ -167,6 +167,10 @@ void MenuView::clear() {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
size_t MenuView::item_count() const {
|
||||
return menu_items.size();
|
||||
}
|
||||
|
||||
void MenuView::add_item(MenuItem new_item) {
|
||||
menu_items.push_back(new_item);
|
||||
|
||||
|
@ -83,6 +83,7 @@ public:
|
||||
void add_item(MenuItem new_item);
|
||||
void add_items(std::initializer_list<MenuItem> new_items);
|
||||
void clear();
|
||||
size_t item_count() const;
|
||||
|
||||
MenuItemView* item_view(size_t index) const;
|
||||
|
||||
|
@ -1143,9 +1143,18 @@ NewButton::NewButton(
|
||||
Rect parent_rect,
|
||||
std::string text,
|
||||
const Bitmap* bitmap
|
||||
) : NewButton { parent_rect, text, bitmap, Color::dark_cyan() }
|
||||
{ }
|
||||
|
||||
NewButton::NewButton(
|
||||
Rect parent_rect,
|
||||
std::string text,
|
||||
const Bitmap* bitmap,
|
||||
Color color
|
||||
) : Widget { parent_rect },
|
||||
text_ { text },
|
||||
bitmap_ (bitmap)
|
||||
bitmap_ { bitmap },
|
||||
color_ { color }
|
||||
{
|
||||
set_focusable(true);
|
||||
}
|
||||
|
@ -466,6 +466,7 @@ public:
|
||||
NewButton(const NewButton&) = delete;
|
||||
NewButton& operator=(const NewButton&) = delete;
|
||||
NewButton(Rect parent_rect, std::string text, const Bitmap* bitmap);
|
||||
NewButton(Rect parent_rect, std::string text, const Bitmap* bitmap, Color color);
|
||||
NewButton(
|
||||
) : NewButton { { }, { }, { } }
|
||||
{
|
||||
@ -486,8 +487,8 @@ public:
|
||||
|
||||
private:
|
||||
std::string text_;
|
||||
Color color_ = Color::dark_cyan();
|
||||
const Bitmap* bitmap_;
|
||||
Color color_;
|
||||
};
|
||||
|
||||
class Image : public Widget {
|
||||
|
BIN
firmware/graphics/icon_copy.png
Normal file
BIN
firmware/graphics/icon_copy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 293 B |
BIN
firmware/graphics/icon_cut.png
Normal file
BIN
firmware/graphics/icon_cut.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 286 B |
BIN
firmware/graphics/icon_new_dir.png
Normal file
BIN
firmware/graphics/icon_new_dir.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 258 B |
BIN
firmware/graphics/icon_new_file.png
Normal file
BIN
firmware/graphics/icon_new_file.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 253 B |
BIN
firmware/graphics/icon_paste.png
Normal file
BIN
firmware/graphics/icon_paste.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 286 B |
BIN
firmware/graphics/icon_trash.png
Normal file
BIN
firmware/graphics/icon_trash.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 231 B |
Loading…
Reference in New Issue
Block a user