Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
package util
// CursorMovePage moves cursor forward or backward by one page (perPage items),
// clamped to [0, total-1].
func CursorMovePage(cursor, total, perPage int, forward bool) int {
step := perPage
if step < 1 {
step = 1
}
if forward {
cursor += step
} else {
cursor -= step
}
if cursor < 0 || total <= 0 {
return 0
}
if cursor >= total {
return total - 1
}
return cursor
}
// CursorGotoBottom returns the last valid cursor index for a list of total items.
func CursorGotoBottom(total int) int {
if total <= 0 {
return 0
}
return total - 1
}
+65
View File
@@ -0,0 +1,65 @@
package util
import (
"log"
"os"
"os/exec"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/config"
)
type EditorFinishedMsg struct {
Content string
Err error
}
func ResolveEditor() string {
editor := config.Global.App.ExternalEditor
if editor == "" {
editor = os.Getenv("EDITOR")
}
if editor == "" {
editor = "vi"
}
return editor
}
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
f, err := os.CreateTemp("", "settuings-*.http")
if err != nil {
log.Printf("editor: create temp file: %v", err)
return nil
}
tmpPath := f.Name()
if _, err := f.WriteString(content); err != nil {
log.Printf("editor: writing temp file: %v", err)
}
if err := f.Close(); err != nil {
log.Printf("editor: closing temp file: %v", err)
}
return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp
defer os.Remove(tmpPath)
return callback(tmpPath, err)
})
}
func OpenExternalEditor(content string) tea.Cmd {
return openWithEditor(content, func(tmpPath string, err error) tea.Msg {
if err != nil {
return EditorFinishedMsg{Err: err}
}
data, readErr := os.ReadFile(tmpPath) // #nosec G304 -- tmpPath is from os.CreateTemp, controlled by this process
if readErr != nil {
return EditorFinishedMsg{Err: readErr}
}
return EditorFinishedMsg{Content: string(data)}
})
}
func OpenExternalEditorView(content string) tea.Cmd {
return openWithEditor(content, func(_ string, _ error) tea.Msg {
return nil
})
}
+48
View File
@@ -0,0 +1,48 @@
package util
import (
"os"
"runtime"
"strings"
)
// OSName returns a human-readable OS name, e.g. "NixOS 25.05" or
// "Ubuntu 24.04.1 LTS", read from /etc/os-release. Falls back to
// runtime.GOOS if the file is missing or has no PRETTY_NAME.
func OSName() string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return runtime.GOOS
}
for _, line := range strings.Split(string(data), "\n") {
name, ok := strings.CutPrefix(line, "PRETTY_NAME=")
if !ok {
continue
}
return strings.Trim(strings.TrimSpace(name), `"`)
}
return runtime.GOOS
}
// KernelVersion returns the running kernel release, e.g. "6.12.9-arch1-1".
func KernelVersion() string {
data, err := os.ReadFile("/proc/sys/kernel/osrelease")
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(data))
}
// Hostname returns the machine's hostname.
func Hostname() string {
name, err := os.Hostname()
if err != nil {
return "unknown"
}
return name
}
// Arch returns the CPU architecture, e.g. "amd64".
func Arch() string {
return runtime.GOARCH
}
+58
View File
@@ -0,0 +1,58 @@
package util
import (
"strings"
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
)
func Truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
// EmptyState renders centered placeholder lines, word-wrapping to fit maxW.
func EmptyState(maxW int, lines ...string) string {
return lipgloss.NewStyle().
Width(maxW).
AlignHorizontal(lipgloss.Center).
Render(strings.Join(lines, "\n"))
}
// CenterLines centers each line horizontally relative to the longest one.
func CenterLines(lines ...string) string {
maxWidth := 0
for _, l := range lines {
if w := lipgloss.Width(l); w > maxWidth {
maxWidth = w
}
}
centered := make([]string, len(lines))
for i, l := range lines {
centered[i] = lipgloss.PlaceHorizontal(maxWidth, lipgloss.Center, l)
}
return strings.Join(centered, "\n")
}
// PageBounds returns clamped start/end indices for rendering a paginated list.
func PageBounds(p paginator.Model, total int) (start, end int) {
start, end = p.GetSliceBounds(total)
if start < 0 {
start = 0
}
if end < start {
end = start
}
return
}
// InferScheme returns "http" for port 80, "https" otherwise.
func InferScheme(host string) string {
if strings.HasSuffix(host, ":80") {
return "http"
}
return "https"
}
+107
View File
@@ -0,0 +1,107 @@
package util
import (
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
// ScrollbarGutterWidth is the fixed width, in terminal columns, that
// RefreshScrollbar reserves for its gutter — whether or not a scrollbar is
// actually needed right now. Content that pre-wraps itself to a viewport's
// width (e.g. glamour markdown) must subtract this, otherwise it overflows
// the moment the scrollbar's thumb/track appear and the gutter eats into
// the width the content assumed it had.
const ScrollbarGutterWidth = 2
// SetYOffset sets vp's Y offset and refreshes its scrollbar gutter to match.
// Every scroll site should go through this (or [ScrollLines]/[ScrollViewport])
// instead of calling vp.SetYOffset directly, so the scrollbar never goes
// stale.
func SetYOffset(vp *viewport.Model, y int) {
vp.SetYOffset(y)
RefreshScrollbar(vp)
}
// ScrollLines scrolls vp by n lines (negative n scrolls up) and refreshes
// its scrollbar gutter.
func ScrollLines(vp *viewport.Model, n int) {
SetYOffset(vp, vp.YOffset()+n)
}
// ScrollViewport scrolls vp vertically by half its height, then refreshes
// its scrollbar gutter. delta should be -1 for up, +1 for down.
func ScrollViewport(vp *viewport.Model, delta int) {
step := vp.Height() / 2
if step < 1 {
step = 1
}
ScrollLines(vp, delta*step)
}
// HandleMouseWheel applies standard mouse wheel scrolling to vp, refreshing
// its scrollbar gutter on vertical scrolls.
// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns.
func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
vp.ScrollLeft(6)
} else {
ScrollLines(vp, -1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
vp.ScrollRight(6)
} else {
ScrollLines(vp, 1)
}
case tea.MouseWheelLeft:
vp.ScrollLeft(6)
case tea.MouseWheelRight:
vp.ScrollRight(6)
}
}
// RefreshScrollbar rebuilds vp's left-gutter scrollbar to reflect its
// current size, content and scroll position. Call it after anything that
// changes vp's width, height or content; YOffset changes are already
// covered if they go through [SetYOffset], [ScrollLines], [ScrollViewport]
// or [HandleMouseWheel].
//
// The gutter is always [ScrollbarGutterWidth] columns wide, even when
// there's nothing to scroll (it's just blank then). That keeps vp's usable
// content width constant regardless of scroll state, so content sized
// against vp.Width() doesn't need to know whether a scrollbar happens to be
// showing right now.
func RefreshScrollbar(vp *viewport.Model) {
height := vp.Height()
total := vp.TotalLineCount()
blank := strings.Repeat(" ", ScrollbarGutterWidth)
if height <= 0 || total <= height {
vp.LeftGutterFunc = func(viewport.GutterContext) string { return blank }
return
}
yOffset := vp.YOffset()
thumbSize := max(1, height*height/total)
thumbStart := 0
if maxOffset := total - height; maxOffset > 0 {
thumbStart = yOffset * (height - thumbSize) / maxOffset
}
trackStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
thumbStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
vp.LeftGutterFunc = func(ctx viewport.GutterContext) string {
pos := ctx.Index - yOffset
if pos >= thumbStart && pos < thumbStart+thumbSize {
return thumbStyle.Render("█") + " "
}
return trackStyle.Render("│") + " "
}
}