feat(tui): add bubbletea terminal UI #30
@@ -0,0 +1,136 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
type absorbModel struct {
|
||||
targetID string
|
||||
sources []*db.Entity
|
||||
cursor int
|
||||
offset int
|
||||
height int
|
||||
}
|
||||
|
||||
func newAbsorbModel(targetID string) absorbModel {
|
||||
return absorbModel{targetID: targetID}
|
||||
}
|
||||
|
||||
func (a *absorbModel) setSources(entities []*db.Entity) {
|
||||
a.sources = nil
|
||||
for _, e := range entities {
|
||||
if e.ID != a.targetID {
|
||||
a.sources = append(a.sources, e)
|
||||
}
|
||||
}
|
||||
a.cursor = 0
|
||||
a.offset = 0
|
||||
}
|
||||
|
||||
func (a *absorbModel) setHeight(h int) {
|
||||
a.height = h
|
||||
}
|
||||
|
||||
func (a absorbModel) selectedSource() *db.Entity {
|
||||
if len(a.sources) == 0 || a.cursor >= len(a.sources) {
|
||||
return nil
|
||||
}
|
||||
return a.sources[a.cursor]
|
||||
}
|
||||
|
||||
func (a absorbModel) update(msg tea.KeyMsg) absorbModel {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if a.cursor > 0 {
|
||||
a.cursor--
|
||||
if a.cursor < a.offset {
|
||||
a.offset = a.cursor
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if a.cursor < len(a.sources)-1 {
|
||||
a.cursor++
|
||||
visible := a.visibleCount()
|
||||
if a.cursor >= a.offset+visible {
|
||||
a.offset = a.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a absorbModel) view(width int) string {
|
||||
if len(a.sources) == 0 {
|
||||
return statusStyle.Render("no other entities")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("select source to merge"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
visible := a.visibleCount() - 4
|
||||
if visible <= 0 {
|
||||
visible = 10
|
||||
}
|
||||
end := min(a.offset+visible, len(a.sources))
|
||||
|
||||
for i := a.offset; i < end; i++ {
|
||||
e := a.sources[i]
|
||||
line := renderAbsorbSource(e, width-4)
|
||||
|
||||
if i == a.cursor {
|
||||
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||
} else {
|
||||
b.WriteString(listItemStyle.Render(line))
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a absorbModel) visibleCount() int {
|
||||
if a.height <= 0 {
|
||||
return 20
|
||||
}
|
||||
return a.height
|
||||
}
|
||||
|
||||
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
|
||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
|
||||
var tags string
|
||||
if len(e.Tags) > 0 {
|
||||
limit := min(2, len(e.Tags))
|
||||
tagParts := make([]string, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
tagParts[i] = tagStyle.Render("#" + e.Tags[i])
|
||||
}
|
||||
tags = " " + strings.Join(tagParts, " ")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-20)
|
||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
@@ -40,6 +40,15 @@ type entityDemotedMsg struct {
|
||||
|
||||
type entityCopiedMsg struct{}
|
||||
|
||||
type entityAbsorbedMsg struct {
|
||||
targetID string
|
||||
}
|
||||
|
||||
type absorbSourcesLoadedMsg struct {
|
||||
targetID string
|
||||
entities []*db.Entity
|
||||
}
|
||||
|
||||
type tagsLoadedMsg struct {
|
||||
tags []db.TagCount
|
||||
}
|
||||
@@ -207,3 +216,22 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
||||
return editorFinishedMsg{nil}
|
||||
})
|
||||
}
|
||||
|
||||
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entities, err := store.List(db.DefaultListParams())
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return absorbSourcesLoadedMsg{targetID, entities}
|
||||
}
|
||||
}
|
||||
|
||||
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := store.Absorb(targetID, sourceID); err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return entityAbsorbedMsg{targetID}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,12 @@ func renderHelp(width, height int) string {
|
||||
{"tab", "cycle intent (cards)"},
|
||||
}},
|
||||
{"Actions", [][2]string{
|
||||
{"a", "add entity"},
|
||||
{"a", "add entity (or ?query to search)"},
|
||||
{"d", "delete (with confirm)"},
|
||||
{"x", "toggle todo completion"},
|
||||
{"!", "toggle pin"},
|
||||
{"#", "filter by tag"},
|
||||
{"m", "absorb (merge into target)"},
|
||||
{"p", "promote to card"},
|
||||
}},
|
||||
{"Detail View", [][2]string{
|
||||
|
||||
+17
-2
@@ -8,6 +8,13 @@ import (
|
||||
"github.com/lerko/nib/internal/parse"
|
||||
)
|
||||
|
||||
type inputResult struct {
|
||||
entity *db.Entity
|
||||
query bool
|
||||
body string
|
||||
tags []string
|
||||
}
|
||||
|
||||
type inputModel struct {
|
||||
ti textinput.Model
|
||||
active bool
|
||||
@@ -32,7 +39,7 @@ func (i *inputModel) reset() {
|
||||
i.ti.Blur()
|
||||
}
|
||||
|
||||
func (i inputModel) submit() *db.Entity {
|
||||
func (i inputModel) submit() *inputResult {
|
||||
val := i.ti.Value()
|
||||
if val == "" {
|
||||
return nil
|
||||
@@ -43,6 +50,14 @@ func (i inputModel) submit() *db.Entity {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parsed.Query {
|
||||
return &inputResult{
|
||||
query: true,
|
||||
body: parsed.Body,
|
||||
tags: parsed.FilterTags,
|
||||
}
|
||||
}
|
||||
|
||||
e := &db.Entity{
|
||||
Body: parsed.Body,
|
||||
Title: parsed.Title,
|
||||
@@ -63,7 +78,7 @@ func (i inputModel) submit() *db.Entity {
|
||||
e.Description = parsed.Description
|
||||
}
|
||||
|
||||
return e
|
||||
return &inputResult{entity: e}
|
||||
}
|
||||
|
||||
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||
|
||||
@@ -26,6 +26,7 @@ type keyMap struct {
|
||||
Cards key.Binding
|
||||
Sort key.Binding
|
||||
Intent key.Binding
|
||||
Absorb key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
@@ -52,4 +53,5 @@ var keys = keyMap{
|
||||
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
||||
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
|
||||
}
|
||||
|
||||
+18
-7
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
type listModel struct {
|
||||
entities []*db.Entity
|
||||
filtered []*db.Entity
|
||||
cursor int
|
||||
offset int
|
||||
height int
|
||||
@@ -25,6 +26,7 @@ func newListModel() listModel {
|
||||
|
||||
func (l *listModel) setEntities(entities []*db.Entity) {
|
||||
l.entities = entities
|
||||
l.filtered = nil
|
||||
if l.cursor >= len(entities) {
|
||||
l.cursor = max(0, len(entities)-1)
|
||||
}
|
||||
@@ -35,11 +37,19 @@ func (l *listModel) setSize(width, height int) {
|
||||
l.height = height
|
||||
}
|
||||
|
||||
func (l listModel) displayEntities() []*db.Entity {
|
||||
if l.filtered != nil {
|
||||
return l.filtered
|
||||
}
|
||||
return l.entities
|
||||
}
|
||||
|
||||
func (l listModel) selected() *db.Entity {
|
||||
if len(l.entities) == 0 || l.cursor >= len(l.entities) {
|
||||
ents := l.displayEntities()
|
||||
if len(ents) == 0 || l.cursor >= len(ents) {
|
||||
return nil
|
||||
}
|
||||
return l.entities[l.cursor]
|
||||
return ents[l.cursor]
|
||||
}
|
||||
|
||||
func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
@@ -52,7 +62,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if l.cursor < len(l.entities)-1 {
|
||||
if l.cursor < len(l.displayEntities())-1 {
|
||||
l.cursor++
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= l.offset+visible {
|
||||
@@ -63,7 +73,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
l.cursor = 0
|
||||
l.offset = 0
|
||||
case "end", "G":
|
||||
l.cursor = max(0, len(l.entities)-1)
|
||||
l.cursor = max(0, len(l.displayEntities())-1)
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= visible {
|
||||
l.offset = l.cursor - visible + 1
|
||||
@@ -74,7 +84,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
l.offset = l.cursor
|
||||
}
|
||||
case "pgdown", "ctrl+d":
|
||||
l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount())
|
||||
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= l.offset+visible {
|
||||
l.offset = l.cursor - visible + 1
|
||||
@@ -84,11 +94,12 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
}
|
||||
|
||||
func (l listModel) view(width int) string {
|
||||
if len(l.entities) == 0 {
|
||||
ents := l.displayEntities()
|
||||
if len(ents) == 0 {
|
||||
return statusStyle.Render("no entities")
|
||||
}
|
||||
|
||||
groups := groupByDate(l.entities)
|
||||
groups := groupByDate(ents)
|
||||
|
||||
type displayLine struct {
|
||||
text string
|
||||
|
||||
+121
-5
@@ -17,6 +17,7 @@ const (
|
||||
stateTagFilter
|
||||
stateConfirm
|
||||
statePromote
|
||||
stateAbsorb
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
@@ -69,11 +70,14 @@ type model struct {
|
||||
input inputModel
|
||||
filter filterModel
|
||||
promote promoteModel
|
||||
absorb absorbModel
|
||||
showHelp bool
|
||||
|
||||
filterTag string
|
||||
confirmID string
|
||||
cardsSort cardsSort
|
||||
filterTag string
|
||||
confirmID string
|
||||
cardsSort cardsSort
|
||||
searchQuery string
|
||||
searchTags []string
|
||||
|
||||
status string
|
||||
err error
|
||||
@@ -118,6 +122,34 @@ func (m model) listParams() db.ListParams {
|
||||
return p
|
||||
}
|
||||
|
||||
func (m model) hasSearch() bool {
|
||||
return m.searchQuery != "" || len(m.searchTags) > 0
|
||||
}
|
||||
|
||||
func (m *model) applySearch() {
|
||||
if m.mode == modeCards {
|
||||
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
|
||||
m.cards.filtered = nil
|
||||
var pinned, rest []*db.Entity
|
||||
for _, e := range filtered {
|
||||
if !matchesIntent(e, m.cards.intent) {
|
||||
continue
|
||||
}
|
||||
if e.Pinned {
|
||||
pinned = append(pinned, e)
|
||||
} else {
|
||||
rest = append(rest, e)
|
||||
}
|
||||
}
|
||||
m.cards.filtered = append(pinned, rest...)
|
||||
if m.cards.cursor >= len(m.cards.filtered) {
|
||||
m.cards.cursor = max(0, len(m.cards.filtered)-1)
|
||||
}
|
||||
} else {
|
||||
m.list.filtered = filterEntities(m.list.entities, m.searchQuery, m.searchTags)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
@@ -135,6 +167,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.list.setEntities(msg.entities)
|
||||
}
|
||||
if m.hasSearch() {
|
||||
m.applySearch()
|
||||
}
|
||||
m.err = nil
|
||||
return m, nil
|
||||
|
||||
@@ -169,6 +204,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.status = "copied"
|
||||
return m, nil
|
||||
|
||||
case entityAbsorbedMsg:
|
||||
m.status = "absorbed"
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
|
||||
case absorbSourcesLoadedMsg:
|
||||
m.absorb = newAbsorbModel(msg.targetID)
|
||||
m.absorb.setSources(msg.entities)
|
||||
m.absorb.setHeight(m.contentHeight())
|
||||
m.state = stateAbsorb
|
||||
return m, nil
|
||||
|
||||
case tagsLoadedMsg:
|
||||
m.filter.setTags(msg.tags)
|
||||
m.state = stateTagFilter
|
||||
@@ -204,6 +251,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateConfirm(msg)
|
||||
case statePromote:
|
||||
return m.updatePromote(msg)
|
||||
case stateAbsorb:
|
||||
return m.updateAbsorb(msg)
|
||||
default:
|
||||
return m.updateKeys(msg)
|
||||
}
|
||||
@@ -263,6 +312,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "tab":
|
||||
if m.mode == modeCards && m.state == stateList {
|
||||
m.cards.setIntent(m.cards.intent.next())
|
||||
if m.hasSearch() {
|
||||
m.applySearch()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
@@ -279,6 +331,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
}
|
||||
if m.state == stateList && m.hasSearch() {
|
||||
m.searchQuery = ""
|
||||
m.searchTags = nil
|
||||
m.status = ""
|
||||
if m.mode == modeCards {
|
||||
m.cards.applyFilter()
|
||||
} else {
|
||||
m.list.filtered = nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.state == stateList && m.filterTag != "" {
|
||||
m.filterTag = ""
|
||||
m.status = ""
|
||||
@@ -331,6 +394,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "m":
|
||||
e := m.selectedEntity()
|
||||
if e != nil {
|
||||
if e.CardType != nil {
|
||||
m.status = "target must be fluid"
|
||||
return m, nil
|
||||
}
|
||||
return m, loadAbsorbSources(m.store, e.ID)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "p":
|
||||
e := m.selectedEntity()
|
||||
if e != nil {
|
||||
@@ -387,8 +461,20 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.input.reset()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if e := m.input.submit(); e != nil {
|
||||
return m, createEntity(m.store, e)
|
||||
result := m.input.submit()
|
||||
if result == nil {
|
||||
return m, nil
|
||||
}
|
||||
if result.query {
|
||||
m.searchQuery = result.body
|
||||
m.searchTags = result.tags
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.applySearch()
|
||||
return m, nil
|
||||
}
|
||||
if result.entity != nil {
|
||||
return m, createEntity(m.store, result.entity)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -441,6 +527,23 @@ func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
case "enter":
|
||||
source := m.absorb.selectedSource()
|
||||
if source != nil {
|
||||
return m, absorbEntity(m.store, m.absorb.targetID, source.ID)
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
m.absorb = m.absorb.update(msg)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.showHelp {
|
||||
return renderHelp(m.width, m.height)
|
||||
@@ -460,6 +563,8 @@ func (m model) View() string {
|
||||
content = m.filter.view(m.width)
|
||||
case statePromote:
|
||||
content = m.promote.view(m.width)
|
||||
case stateAbsorb:
|
||||
content = m.absorb.view(m.width)
|
||||
}
|
||||
|
||||
header := m.headerView()
|
||||
@@ -481,6 +586,17 @@ func (m model) headerView() string {
|
||||
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
||||
}
|
||||
|
||||
if m.hasSearch() {
|
||||
pill := "?"
|
||||
if m.searchQuery != "" {
|
||||
pill += m.searchQuery
|
||||
}
|
||||
for _, t := range m.searchTags {
|
||||
pill += " #" + t
|
||||
}
|
||||
header += " " + searchPillStyle.Render(pill)
|
||||
}
|
||||
|
||||
if m.mode == modeCards && m.cards.intent != intentAll {
|
||||
header += " " + affordanceStyle.Render(m.cards.intent.String())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity {
|
||||
if query == "" && len(tags) == 0 {
|
||||
return entities
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
lowerTags := make([]string, len(tags))
|
||||
for i, t := range tags {
|
||||
lowerTags[i] = strings.ToLower(t)
|
||||
}
|
||||
|
||||
var result []*db.Entity
|
||||
for _, e := range entities {
|
||||
if !matchesSearch(e, query, lowerTags) {
|
||||
continue
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func matchesSearch(e *db.Entity, query string, tags []string) bool {
|
||||
if len(tags) > 0 {
|
||||
eTags := make(map[string]bool, len(e.Tags))
|
||||
for _, t := range e.Tags {
|
||||
eTags[strings.ToLower(t)] = true
|
||||
}
|
||||
for _, t := range tags {
|
||||
if !eTags[t] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
haystack := strings.ToLower(e.Body)
|
||||
if e.Title != nil {
|
||||
haystack += " " + strings.ToLower(*e.Title)
|
||||
}
|
||||
if e.Description != nil {
|
||||
haystack += " " + strings.ToLower(*e.Description)
|
||||
}
|
||||
return strings.Contains(haystack, query)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func countText(m model) string {
|
||||
if m.mode == modeCards {
|
||||
total = len(m.cards.filtered)
|
||||
} else {
|
||||
total = len(m.list.entities)
|
||||
total = len(m.list.displayEntities())
|
||||
}
|
||||
if m.filterTag != "" {
|
||||
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
||||
@@ -47,10 +47,12 @@ func contextHints(m model) string {
|
||||
return "y:confirm n:cancel"
|
||||
case statePromote:
|
||||
return "j/k:nav enter:select esc:cancel"
|
||||
case stateAbsorb:
|
||||
return "j/k:nav enter:absorb esc:cancel"
|
||||
default:
|
||||
if m.mode == modeCards {
|
||||
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
||||
}
|
||||
return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit"
|
||||
return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,4 +101,8 @@ var (
|
||||
|
||||
checkPendingStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
searchPillStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user