diff --git a/cmd/root.go b/cmd/root.go index 8331fd5..9a48156 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,11 +9,13 @@ import ( "os" "strings" + "github.com/charmbracelet/bubbles/key" + tables "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" "github.com/cli/browser" + "github.com/lukasmwerner/mark/helpers" "github.com/lukasmwerner/mark/store" "github.com/spf13/cobra" ) @@ -41,9 +43,8 @@ var ( statusBackground = lipgloss.Color("238") - headerStyle = lipgloss.NewStyle(). - BorderForeground(lipgloss.Color("240")). - Bold(true) + tableStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()) selectedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("229")). @@ -62,8 +63,7 @@ var ( type rootAppModel struct { db *store.DB input textinput.Model - table *table.Table - rows []store.Bookmark + table tables.Model currentIndex int width int height int @@ -75,7 +75,7 @@ func (m rootAppModel) Init() tea.Cmd { return nil } func (m rootAppModel) updateTable() rootAppModel { - bmCount := len(m.rows) + bmCount := m.rowsCount bookmarks, err := store.SearchBookmarks(m.db, m.input.Value()) if err != nil { @@ -87,18 +87,13 @@ func (m rootAppModel) updateTable() rootAppModel { return m } - if m.rowsCount != 0 { - m.table.ClearRows() - m.table.Data(table.NewStringData()) // BUG: here for clearing idk why but yeah datatype needs setup - } - - m.rows = bookmarks m.rowsCount = len(bookmarks) - m.currentIndex = 1 + rows := make([]tables.Row, m.rowsCount) - for _, bookmark := range bookmarks { - m.table.Row(strings.TrimSpace(bookmark.Title), bookmark.Description, strings.Join(bookmark.Tags, ","), bookmark.Url) + for i, bookmark := range bookmarks { + rows[i] = []string{strings.TrimSpace(bookmark.Title), bookmark.Description, strings.Join(bookmark.Tags, ","), bookmark.Url} } + m.table.SetRows(rows) return m } @@ -109,7 +104,10 @@ func (m rootAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.table.Width(msg.Width).Height(msg.Height - 3) + m.input.Width = msg.Width + m.table.SetColumns(resizeCols(msg.Width-10, msg.Height, m.table.Columns())) + m.table.SetHeight(msg.Height - 4) + m.table.SetWidth(msg.Width - 2) case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": @@ -120,38 +118,37 @@ func (m rootAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.mode { case SEARCH: m.input.Blur() + m.table.Focus() m = m.updateTable() case PREVIEW: - break + m.table.Focus() } m.mode = NORMAL case " ": - if m.mode == NORMAL { + switch m.mode { + case NORMAL: m.mode = PREVIEW - } else if m.mode == PREVIEW { + m.table.Blur() + case PREVIEW: m.mode = NORMAL + m.table.Focus() } case "enter": switch m.mode { case NORMAL: - // TODO: open active link - if m.currentIndex <= m.rowsCount { - url := m.rows[m.currentIndex-1].Url - browser.OpenURL(url) - } + url := m.table.SelectedRow()[3] + browser.OpenURL(url) case PREVIEW: - if m.currentIndex <= m.rowsCount { - url := m.rows[m.currentIndex-1].Url - browser.OpenURL(url) - } + url := m.table.SelectedRow()[3] + browser.OpenURL(url) m.mode = NORMAL case SEARCH: m.mode = NORMAL m.input.Blur() + m.table.SetCursor(0) m = m.updateTable() - break } case "i": if m.mode == NORMAL { @@ -159,16 +156,7 @@ func (m rootAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.input.Focus()) return m, tea.Batch(cmds...) } - case "j", "down": - if m.mode == NORMAL && m.currentIndex+1 <= m.rowsCount { - m.currentIndex += 1 - } - case "k", "up": - if m.mode == NORMAL && m.currentIndex-1 != 0 { - m.currentIndex -= 1 - } } - } if m.mode == SEARCH && m.input.Focused() { @@ -177,11 +165,27 @@ func (m rootAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + if m.mode == NORMAL { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) } -func (m rootAppModel) View() string { +func resizeCols(width, _ int, cols []tables.Column) []tables.Column { + newCols := make([]tables.Column, 4) + copy(newCols, cols) + newCols[0].Width = int(width / 4) // Title + newCols[1].Width = int(width / 4) // Description + newCols[2].Width = int(width / 4) // Tags + newCols[3].Width = int(width / 4) // URL + return newCols +} + +func (m rootAppModel) View() string { statusBar := "" switch m.mode { case NORMAL: @@ -193,37 +197,26 @@ func (m rootAppModel) View() string { } statusBar = lipgloss.PlaceHorizontal(m.width, lipgloss.Left, statusBar, lipgloss.WithWhitespaceBackground(statusBackground)) - if m.mode == PREVIEW { - textSty := lipgloss.NewStyle().Width(m.width - 4).Align(lipgloss.Left) - - contents := "title: " + m.rows[m.currentIndex-1].Title + "\n" - contents += "tags: " + strings.Join(m.rows[m.currentIndex-1].Tags, ", ") + "\n" - contents += "url: " + m.rows[m.currentIndex-1].Url + "\n" - contents += "desc: \n" + textSty.Render(m.rows[m.currentIndex-1].Description) + table := lipgloss.PlaceVertical(m.height-2, lipgloss.Top, tableStyle.Render(m.table.View())) - modal := modalstyle.Width(m.width - 4).Render(contents) + screen := lipgloss.JoinVertical(lipgloss.Left, m.input.View(), table, statusBar) - centered := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) + if m.mode == PREVIEW { - return lipgloss.JoinVertical(lipgloss.Center, centered, statusBar) - } + textSty := lipgloss.NewStyle().Width(m.width - 4).Align(lipgloss.Left) - m.table.StyleFunc(func(row, col int) lipgloss.Style { - switch { - case row == 0: - return headerStyle - case row == m.currentIndex && m.mode == NORMAL: - return selectedStyle - case row == m.currentIndex && m.mode == SEARCH: - return unactiveSelectedStyle - default: - return lipgloss.NewStyle() - } - }) + contents := categoryColorStyle.Render("title: ") + m.table.SelectedRow()[0] + "\n" + contents += categoryColorStyle.Render("tags: ") + m.table.SelectedRow()[2] + "\n" + contents += categoryColorStyle.Render("url: ") + m.table.SelectedRow()[3] + "\n" + contents += categoryColorStyle.Render("desc: ") + textSty.Render(m.table.SelectedRow()[1]) - table := lipgloss.PlaceVertical(m.height-2, lipgloss.Top, m.table.Render()) + modal := modalstyle.Width(2 * (m.width / 3)).Render(contents) + modalLeft := (m.width / 2) - (lipgloss.Width(modal) / 2) + modalRight := (m.height / 2) - (lipgloss.Height(modal) / 2) + return helpers.PlaceOverlay(modalLeft, modalRight, modal, screen, true) + } - return lipgloss.JoinVertical(lipgloss.Left, m.input.View(), table, statusBar) + return screen } // rootCmd represents the base command when called without any subcommands @@ -246,17 +239,35 @@ file sync service. This is sort-of explained the following blog post: defer db.Close() - t := table.New() + t := tables.New(tables.WithColumns([]tables.Column{ + { + Title: "Title", + Width: 10, + }, + { + Title: "Description", + Width: 10, + }, + { + Title: "Tags", + Width: 10, + }, + { + Title: "URL", + Width: 10, + }, + })) + tableStyles := tables.DefaultStyles() + tableStyles.Selected = selectedStyle + t.SetStyles(tableStyles) + t.Focus() + t.KeyMap.PageDown = key.NewBinding(key.WithKeys("f", "pgdown"), key.WithHelp("f/pgdn", "page down")) input := textinput.New() input.Placeholder = "Search / Filter" m := rootAppModel{db: db, table: t, input: input, currentIndex: 1, rowsCount: 0, mode: NORMAL} - t.Border(lipgloss.NormalBorder()) - - t.Headers("Title", "Description", "Tags", "URL") - prog := tea.NewProgram(m, tea.WithAltScreen()) if _, err := prog.Run(); err != nil { diff --git a/go.mod b/go.mod index f9fab0d..33ed68a 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,32 @@ module github.com/lukasmwerner/mark -go 1.22.0 +go 1.23.0 require ( github.com/PuerkitoBio/goquery v1.9.2 - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v0.5.2 github.com/cli/browser v1.3.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/spf13/cobra v1.8.1 ) +require ( + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect +) + require ( github.com/andybalholm/cascadia v1.3.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.12.1 // indirect - github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/input v0.1.3 // indirect - github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/windows v0.1.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -35,13 +40,13 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.24.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 583529f..724533d 100644 --- a/go.sum +++ b/go.sum @@ -10,18 +10,32 @@ github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/huh v0.5.2 h1:ofeNkJ4iaFnzv46Njhx896DzLUe/j0L2QAf8znwzX4c= github.com/charmbracelet/huh v0.5.2/go.mod h1:Sf7dY0oAn6N/e3sXJFtFX9hdQLrUdO3z7AYollG9bAM= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= @@ -30,6 +44,8 @@ github.com/charmbracelet/x/input v0.1.3 h1:oy4TMhyGQsYs/WWJwu1ELUMFnjiUAXwtDf048 github.com/charmbracelet/x/input v0.1.3/go.mod h1:1gaCOyw1KI9e2j00j/BBZ4ErzRZqa05w0Ghn83yIhKU= github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= @@ -70,6 +86,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -77,6 +95,7 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -100,6 +119,9 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -115,6 +137,9 @@ golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -128,6 +153,8 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/helpers/overlay.go b/helpers/overlay.go new file mode 100644 index 0000000..696aa54 --- /dev/null +++ b/helpers/overlay.go @@ -0,0 +1,208 @@ +package helpers + +import ( + "bytes" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/muesli/ansi" + "github.com/muesli/reflow/truncate" + "github.com/muesli/termenv" +) + +// Most of this code is borrowed from +// https://github.com/charmbracelet/lipgloss/pull/102 +// as well as the lipgloss library, with some modification for what I needed. + +// Split a string into lines, additionally returning the size of the widest +// line. +func getLines(s string) (lines []string, widest int) { + lines = strings.Split(s, "\n") + + for _, l := range lines { + w := ansi.PrintableRuneWidth(l) + if widest < w { + widest = w + } + } + + return lines, widest +} + +// PlaceOverlay places fg on top of bg. +func PlaceOverlay( + x, y int, + fg, bg string, + shadow bool, opts ...WhitespaceOption, +) string { + fgLines, fgWidth := getLines(fg) + bgLines, bgWidth := getLines(bg) + bgHeight := len(bgLines) + fgHeight := len(fgLines) + + if shadow { + var shadowbg string = "" + shadowchar := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#333333")). + Render("░") + for i := 0; i <= fgHeight; i++ { + if i == 0 { + shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n" + } else { + shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n" + } + } + + fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...) + fgLines, fgWidth = getLines(fg) + fgHeight = len(fgLines) + } + + if fgWidth >= bgWidth && fgHeight >= bgHeight { + // FIXME: return fg or bg? + return fg + } + // TODO: allow placement outside of the bg box? + x = clamp(x, 0, bgWidth-fgWidth) + y = clamp(y, 0, bgHeight-fgHeight) + + ws := &Whitespace{} + for _, opt := range opts { + opt(ws) + } + + var b strings.Builder + for i, bgLine := range bgLines { + if i > 0 { + b.WriteByte('\n') + } + if i < y || i >= y+fgHeight { + b.WriteString(bgLine) + continue + } + + pos := 0 + if x > 0 { + left := truncate.String(bgLine, uint(x)) + pos = ansi.PrintableRuneWidth(left) + b.WriteString(left) + if pos < x { + b.WriteString(ws.render(x - pos)) + pos = x + } + } + + fgLine := fgLines[i-y] + b.WriteString(fgLine) + pos += ansi.PrintableRuneWidth(fgLine) + + right := cutLeft(bgLine, pos) + bgWidth := ansi.PrintableRuneWidth(bgLine) + rightWidth := ansi.PrintableRuneWidth(right) + if rightWidth <= bgWidth-pos { + b.WriteString(ws.render(bgWidth - rightWidth - pos)) + } + + b.WriteString(right) + } + + return b.String() +} + +// cutLeft cuts printable characters from the left. +// This function is heavily based on muesli's ansi and truncate packages. +func cutLeft(s string, cutWidth int) string { + var ( + pos int + isAnsi bool + ab bytes.Buffer + b bytes.Buffer + ) + for _, c := range s { + var w int + if c == ansi.Marker || isAnsi { + isAnsi = true + ab.WriteRune(c) + if ansi.IsTerminator(c) { + isAnsi = false + if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) { + ab.Reset() + } + } + } else { + w = runewidth.RuneWidth(c) + } + + if pos >= cutWidth { + if b.Len() == 0 { + if ab.Len() > 0 { + b.Write(ab.Bytes()) + } + if pos-cutWidth > 1 { + b.WriteByte(' ') + continue + } + } + b.WriteRune(c) + } + pos += w + } + return b.String() +} + +func clamp(v, lower, upper int) int { + return min(max(v, lower), upper) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type Whitespace struct { + Style termenv.Style + Chars string +} + +// Render whitespaces. +func (w Whitespace) render(width int) string { + if w.Chars == "" { + w.Chars = " " + } + + r := []rune(w.Chars) + j := 0 + b := strings.Builder{} + + // Cycle through runes and print them into the whitespace. + for i := 0; i < width; { + b.WriteRune(r[j]) + j++ + if j >= len(r) { + j = 0 + } + i += ansi.PrintableRuneWidth(string(r[j])) + } + + // Fill any extra gaps white spaces. This might be necessary if any runes + // are more than one cell wide, which could leave a one-rune gap. + short := width - ansi.PrintableRuneWidth(b.String()) + if short > 0 { + b.WriteString(strings.Repeat(" ", short)) + } + + return w.Style.Styled(b.String()) +} + +// WhitespaceOption sets a styling rule for rendering whitespace. +type WhitespaceOption func(*Whitespace)