From d4fa74f701aedcfbb441f78e9b40ccd0d69e92fc Mon Sep 17 00:00:00 2001 From: Patrick Dewey Date: Wed, 10 Jun 2026 07:19:06 -0400 Subject: [PATCH] improved connections ux --- internal/app/app.go | 21 ++++-- internal/app/forms.go | 145 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 135 insertions(+), 31 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index e62d557..a6d48c8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -53,9 +53,10 @@ const ( ) type form struct { - fields []textinput.Model - note textarea.Model - focus int + fields []textinput.Model + note textarea.Model + focus int + linkTypeIndex int } type model struct { @@ -187,6 +188,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = "yanking " + selected.url return m, yankURL(selected.url) } + case "c": + if m.screen == screenCards { + if selected, ok := m.list.SelectedItem().(item); ok && selected.url != "" { + m.mode = modeLinkForm + m.form = newLinkForm(selected.url) + m.status = "" + m.err = nil + return m, nil + } + } case "a": switch m.screen { case screenCards: @@ -199,7 +210,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case screenLinks: m.mode = modeLinkForm - m.form = newLinkForm() + m.form = newLinkForm("") return m, nil } case "m": @@ -278,7 +289,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { - nav := titleStyle.Render("Semble") + " " + m.tabsView() + "\n" + helpStyle.Render("tab tabs · h/l or ←/→ pages · enter open · y yank · r refresh · a add · q quit") + nav := titleStyle.Render("Semble") + " " + m.tabsView() + "\n" + helpStyle.Render("tab tabs · h/l or ←/→ pages · enter open · y yank · c connect card · r refresh · a add · q quit") if m.loading { nav += "\n" + m.spinner.View() + " loading..." } diff --git a/internal/app/forms.go b/internal/app/forms.go index cd23fdf..37db195 100644 --- a/internal/app/forms.go +++ b/internal/app/forms.go @@ -10,6 +10,17 @@ import ( semble "tangled.org/pdewey.com/semble" ) +var linkTypes = []semble.ConnectionType{ + semble.ConnectionSupports, + semble.ConnectionOpposes, + semble.ConnectionAddresses, + semble.ConnectionHelpful, + semble.ConnectionLeadsTo, + semble.ConnectionRelated, + semble.ConnectionSupplement, + semble.ConnectionExplainer, +} + func newInput(prompt string) textinput.Model { t := textinput.New() t.Prompt = prompt + ": " @@ -31,15 +42,46 @@ func newCollectionForm() form { f.fields[1].SetValue("OPEN") return f } -func newLinkForm() form { +func newLinkForm(sourceURL string) form { f := form{fields: []textinput.Model{newInput("Source URL"), newInput("Target URL"), newInput("Type")}, note: textarea.New()} f.note.Placeholder = "Note (optional)" f.note.SetHeight(5) - f.fields[0].Focus() - f.fields[2].SetValue(string(semble.ConnectionRelated)) + f.fields[0].SetValue(sourceURL) + f.linkTypeIndex = linkTypeIndex(semble.ConnectionRelated) + f.syncLinkTypeField() + if sourceURL == "" { + f.fields[0].Focus() + } else { + f.focus = 1 + f.fields[1].Focus() + } return f } +func linkTypeIndex(typ semble.ConnectionType) int { + for i, candidate := range linkTypes { + if candidate == typ { + return i + } + } + return 0 +} + +func (f *form) syncLinkTypeField() { + if len(f.fields) < 3 { + return + } + if f.linkTypeIndex < 0 || f.linkTypeIndex >= len(linkTypes) { + f.linkTypeIndex = linkTypeIndex(semble.ConnectionRelated) + } + f.fields[2].SetValue(string(linkTypes[f.linkTypeIndex])) +} + +func (f *form) selectLinkType(delta int) { + f.linkTypeIndex = (f.linkTypeIndex + delta + len(linkTypes)) % len(linkTypes) + f.syncLinkTypeField() +} + func (m model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": @@ -47,31 +89,29 @@ func (m model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+s": return m, m.submitForm() - case "tab", "shift+tab", "up", "down", "enter": - max := len(m.form.fields) - if msg.String() == "enter" && m.form.focus == max { - return m, m.submitForm() + case "up", "k": + if m.mode == modeLinkForm && m.form.focus == 2 { + m.form.selectLinkType(-1) + return m, nil } - for i := range m.form.fields { - m.form.fields[i].Blur() + if msg.String() == "up" { + m.moveFormFocus(true) + return m, nil } - m.form.note.Blur() - if msg.String() == "up" || msg.String() == "shift+tab" { - m.form.focus-- - if m.form.focus < 0 { - m.form.focus = max - } - } else { - m.form.focus++ - if m.form.focus > max { - m.form.focus = 0 - } + case "down", "j": + if m.mode == modeLinkForm && m.form.focus == 2 { + m.form.selectLinkType(1) + return m, nil + } + if msg.String() == "down" { + m.moveFormFocus(false) + return m, nil } - if m.form.focus == max { - m.form.note.Focus() - } else { - m.form.fields[m.form.focus].Focus() + case "tab", "shift+tab", "enter": + if msg.String() == "enter" && m.form.focus == len(m.form.fields) { + return m, m.submitForm() } + m.moveFormFocus(msg.String() == "shift+tab") return m, nil } if m.form.focus == len(m.form.fields) { @@ -79,21 +119,74 @@ func (m model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.form.note, cmd = m.form.note.Update(msg) return m, cmd } + if m.mode == modeLinkForm && m.form.focus == 2 { + return m, nil + } var cmd tea.Cmd m.form.fields[m.form.focus], cmd = m.form.fields[m.form.focus].Update(msg) return m, cmd } +func (m *model) moveFormFocus(back bool) { + max := len(m.form.fields) + for i := range m.form.fields { + m.form.fields[i].Blur() + } + m.form.note.Blur() + if back { + m.form.focus-- + if m.form.focus < 0 { + m.form.focus = max + } + } else { + m.form.focus++ + if m.form.focus > max { + m.form.focus = 0 + } + } + if m.form.focus == max { + m.form.note.Focus() + } else { + m.form.fields[m.form.focus].Focus() + } +} + func (m model) formView() string { var b strings.Builder b.WriteString(helpStyle.Render("tab move · ctrl+s save · esc cancel") + "\n\n") - for _, f := range m.form.fields { + for i, f := range m.form.fields { + if m.mode == modeLinkForm && i == 2 { + b.WriteString(linkTypeSelectorView(m.form) + "\n") + continue + } b.WriteString(f.View() + "\n") } b.WriteString(m.form.note.View()) return b.String() } +func linkTypeSelectorView(f form) string { + var b strings.Builder + title := "Type:" + if f.focus == 2 { + title = activeTab.Render(title) + } + b.WriteString(title + "\n") + for i, typ := range linkTypes { + cursor := " " + value := string(typ) + if i == f.linkTypeIndex { + cursor = "> " + value = okStyle.Render(value) + } + b.WriteString(cursor + value + "\n") + } + if f.focus == 2 { + b.WriteString(helpStyle.Render(" j/k or ↑/↓ select link type") + "\n") + } + return strings.TrimRight(b.String(), "\n") +} + func (m model) submitForm() tea.Cmd { switch m.mode { case modeCardForm: @@ -117,7 +210,7 @@ func (m model) submitForm() tea.Cmd { return createdMsg("created collection " + r.CollectionID) } case modeLinkForm: - src, tgt, typ, note := m.form.fields[0].Value(), m.form.fields[1].Value(), semble.ConnectionType(strings.ToUpper(m.form.fields[2].Value())), m.form.note.Value() + src, tgt, typ, note := m.form.fields[0].Value(), m.form.fields[1].Value(), linkTypes[m.form.linkTypeIndex], m.form.note.Value() return func() tea.Msg { r, e := m.client.CreateConnection(ctx(), semble.CreateConnectionRequest{SourceType: "URL", SourceValue: src, TargetType: "URL", TargetValue: tgt, ConnectionType: typ, Note: note}) if e != nil { -- 2.51.2