mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 08:18:23 +02:00
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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("│") + " "
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user