786 lines
18 KiB
Go
786 lines
18 KiB
Go
package login
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"latchd/auth"
|
|
"latchd/config"
|
|
"latchd/session"
|
|
"latchd/tui/bg"
|
|
)
|
|
|
|
// ── pixelated user avatar ───────────────────────────────────────────────
|
|
|
|
var avatarArt = []string{
|
|
` ▄▄▄▄▄ `,
|
|
` ▄███████▄ `,
|
|
` ██ ██ ██ `,
|
|
` ██ ██ `,
|
|
` ██ ███ ██ `,
|
|
` █████████ `,
|
|
` ██ ██ ██ `,
|
|
` ██ ██ `,
|
|
}
|
|
|
|
// ── messages ─────────────────────────────────────────────────────────────
|
|
|
|
type tickMsg time.Time
|
|
|
|
type authResult struct {
|
|
ok bool
|
|
err error
|
|
faillock auth.FaillockStatus
|
|
}
|
|
|
|
type adminBypassResult struct {
|
|
ok bool
|
|
err error
|
|
}
|
|
|
|
type mode int
|
|
|
|
const (
|
|
modeNormal mode = iota
|
|
modeSwitcher
|
|
modeUsername
|
|
modePassword
|
|
modeAdminUser
|
|
modeAdminPass
|
|
)
|
|
|
|
// ── result (sent back to main on successful login) ──────────────────────
|
|
|
|
type Result struct {
|
|
Username string
|
|
Password string
|
|
Env session.PostLoginEnv
|
|
Config config.Config
|
|
Preview bool
|
|
}
|
|
|
|
// ── model ───────────────────────────────────────────────────────────────
|
|
|
|
type Model struct {
|
|
cfg config.Config
|
|
preview bool
|
|
width int
|
|
height int
|
|
|
|
automata *bg.Automata
|
|
|
|
envs []session.PostLoginEnv
|
|
selIdx int
|
|
|
|
username string
|
|
password string
|
|
ucur int
|
|
pcur int
|
|
uscroll int
|
|
pscroll int
|
|
|
|
adminUser string
|
|
adminPass string
|
|
aucur int
|
|
apcur int
|
|
lockedFor string
|
|
|
|
mode mode
|
|
status string
|
|
statusErr bool
|
|
shakeOff int
|
|
shakeLeft float64
|
|
shakeDec float64
|
|
spring float64
|
|
|
|
quitting bool
|
|
result *Result
|
|
}
|
|
|
|
func New(cfg config.Config, preview bool) *Model {
|
|
raw := session.ScanEnvs(
|
|
cfg.X11.SessionsPath, cfg.Wayland.SessionsPath,
|
|
cfg.X11.ScriptsPath, cfg.Wayland.ScriptsPath,
|
|
cfg.Switcher.IncludeTTYShell,
|
|
)
|
|
m := &Model{cfg: cfg, preview: preview, envs: raw}
|
|
switch cfg.FocusBehaviour {
|
|
case config.FocusEnvironment:
|
|
if len(raw) > 0 {
|
|
m.mode = modeSwitcher
|
|
} else {
|
|
m.mode = modeUsername
|
|
}
|
|
case config.FocusUsername:
|
|
m.mode = modeUsername
|
|
case config.FocusPassword:
|
|
m.mode = modePassword
|
|
default:
|
|
if len(raw) > 0 {
|
|
m.mode = modeSwitcher
|
|
} else {
|
|
m.mode = modeUsername
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
return tea.Batch(tick(), tea.EnterAltScreen)
|
|
}
|
|
|
|
// ── update ──────────────────────────────────────────────────────────────
|
|
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
if m.automata != nil {
|
|
m.automata.Resize(msg.Width, msg.Height)
|
|
}
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
case tickMsg:
|
|
if m.automata != nil {
|
|
m.automata.Tick()
|
|
}
|
|
m.tickShake()
|
|
m.tickSpring()
|
|
return m, tick()
|
|
case authResult:
|
|
return m.handleAuth(msg)
|
|
case adminBypassResult:
|
|
return m.handleBypass(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) tickShake() {
|
|
if m.shakeLeft > 0.01 || m.shakeLeft < -0.01 {
|
|
m.shakeDec += 0.05
|
|
m.shakeLeft *= 0.82
|
|
m.shakeOff = int(m.shakeLeft * 6.0 * (1.0 - m.shakeDec))
|
|
if m.shakeDec >= 1.0 {
|
|
m.shakeOff, m.shakeLeft, m.shakeDec = 0, 0, 0
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Model) tickSpring() {
|
|
if m.spring > 0.5 || m.spring < -0.5 {
|
|
m.spring *= 0.75
|
|
} else {
|
|
m.spring = 0
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleAuth(msg authResult) (tea.Model, tea.Cmd) {
|
|
if msg.ok {
|
|
m.result = &Result{
|
|
Username: m.username, Password: m.password,
|
|
Env: m.selectedEnv(), Config: m.cfg, Preview: m.preview,
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
m.status = "incorrect username or password"
|
|
m.statusErr = true
|
|
m.shakeLeft = 1.0
|
|
m.shakeDec = 0
|
|
m.password = ""
|
|
m.pcur = 0
|
|
m.pscroll = 0
|
|
if msg.faillock.Enabled && msg.faillock.LockedOut {
|
|
m.lockedFor = m.username
|
|
m.status = fmt.Sprintf("account locked — %d/%d attempts [Ctrl+U to unlock]",
|
|
msg.faillock.Failures, msg.faillock.MaxTries)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
|
|
if msg.ok {
|
|
m.lockedFor = ""
|
|
m.mode = modeNormal
|
|
m.adminUser = ""
|
|
m.adminPass = ""
|
|
m.status = "account unlocked — try logging in again"
|
|
m.statusErr = false
|
|
} else {
|
|
m.status = "override failed: " + msg.err.Error()
|
|
m.statusErr = true
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ── keys ────────────────────────────────────────────────────────────────
|
|
|
|
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
k := msg.String()
|
|
|
|
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
|
return m.adminKey(msg)
|
|
}
|
|
|
|
switch k {
|
|
case "esc":
|
|
if m.preview {
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
}
|
|
m.mode, m.status = modeNormal, ""
|
|
return m, nil
|
|
case "ctrl+c":
|
|
if m.preview {
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
case "ctrl+u":
|
|
if m.lockedFor != "" {
|
|
m.mode = modeAdminUser
|
|
m.status = "admin unlock · enter username"
|
|
m.statusErr = false
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
switch k {
|
|
case "tab", "down", "ctrl+n":
|
|
m.mode = m.nextMode()
|
|
case "shift+tab", "up", "ctrl+p":
|
|
m.mode = m.prevMode()
|
|
}
|
|
|
|
if k == "enter" && m.mode == modePassword {
|
|
return m, m.loginCmd()
|
|
}
|
|
|
|
if len(k) >= 2 && k[0] == 'f' {
|
|
m.handleFn(k)
|
|
return m, nil
|
|
}
|
|
|
|
switch m.mode {
|
|
case modeSwitcher:
|
|
switch k {
|
|
case "left", "h":
|
|
if m.selIdx > 0 {
|
|
m.selIdx--
|
|
m.spring = -4
|
|
}
|
|
case "right", "l":
|
|
if m.selIdx < len(m.envs)-1 {
|
|
m.selIdx++
|
|
m.spring = 4
|
|
}
|
|
}
|
|
case modeUsername:
|
|
handleField(k, &m.username, &m.ucur, &m.uscroll)
|
|
case modePassword:
|
|
handleField(k, &m.password, &m.pcur, &m.pscroll)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) adminKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
k := msg.String()
|
|
switch k {
|
|
case "esc":
|
|
m.mode, m.adminUser, m.adminPass, m.status = modeNormal, "", "", ""
|
|
return m, nil
|
|
case "enter":
|
|
if m.mode == modeAdminUser && m.adminUser != "" {
|
|
m.mode = modeAdminPass
|
|
m.status = "admin unlock · enter password"
|
|
return m, nil
|
|
}
|
|
if m.mode == modeAdminPass && m.adminPass != "" {
|
|
return m, m.adminBypassCmd()
|
|
}
|
|
case "tab":
|
|
if m.mode == modeAdminUser {
|
|
m.mode = modeAdminPass
|
|
} else {
|
|
m.mode = modeAdminUser
|
|
}
|
|
default:
|
|
if m.mode == modeAdminUser {
|
|
handleField(k, &m.adminUser, &m.aucur, &m.aucur)
|
|
} else {
|
|
handleField(k, &m.adminPass, &m.apcur, &m.apcur)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) nextMode() mode {
|
|
switch m.mode {
|
|
case modeNormal:
|
|
if len(m.envs) > 0 {
|
|
return modeSwitcher
|
|
}
|
|
return modeUsername
|
|
case modeSwitcher:
|
|
return modeUsername
|
|
case modeUsername:
|
|
return modePassword
|
|
}
|
|
return m.mode
|
|
}
|
|
|
|
func (m *Model) prevMode() mode {
|
|
switch m.mode {
|
|
case modeSwitcher:
|
|
return modeNormal
|
|
case modeUsername:
|
|
if len(m.envs) > 0 {
|
|
return modeSwitcher
|
|
}
|
|
return modeNormal
|
|
case modePassword:
|
|
return modeUsername
|
|
}
|
|
return modeNormal
|
|
}
|
|
|
|
func (m *Model) handleFn(k string) {
|
|
for _, pc := range m.cfg.PowerControls.BaseEntries {
|
|
if strings.EqualFold(k, pc.Key) {
|
|
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
|
|
}
|
|
}
|
|
for _, pc := range m.cfg.PowerControls.Entries {
|
|
if strings.EqualFold(k, pc.Key) {
|
|
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Model) selectedEnv() session.PostLoginEnv {
|
|
if len(m.envs) == 0 || m.selIdx >= len(m.envs) {
|
|
return session.PostLoginEnv{}
|
|
}
|
|
return m.envs[m.selIdx]
|
|
}
|
|
|
|
// ── commands ────────────────────────────────────────────────────────────
|
|
|
|
func (m *Model) loginCmd() tea.Cmd {
|
|
if m.preview {
|
|
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
|
|
}
|
|
user, pass, svc := m.username, m.password, m.cfg.PAMService
|
|
return func() tea.Msg {
|
|
_, err := auth.Validate(user, pass, svc)
|
|
if err == nil {
|
|
return authResult{ok: true}
|
|
}
|
|
fs, _ := auth.GetFaillockStatus(user)
|
|
return authResult{ok: false, err: err, faillock: fs}
|
|
}
|
|
}
|
|
|
|
func (m *Model) adminBypassCmd() tea.Cmd {
|
|
au, ap, tgt := m.adminUser, m.adminPass, m.lockedFor
|
|
return func() tea.Msg {
|
|
if _, err := auth.Validate(au, ap, "su"); err != nil {
|
|
return adminBypassResult{ok: false, err: fmt.Errorf("admin auth failed")}
|
|
}
|
|
if err := auth.ResetFaillock(tgt); err != nil {
|
|
return adminBypassResult{ok: false, err: fmt.Errorf("faillock: %w", err)}
|
|
}
|
|
return adminBypassResult{ok: true}
|
|
}
|
|
}
|
|
|
|
// ── queries ────────────────────────────────────────────────────────────
|
|
|
|
func (m *Model) Result() *Result { return m.result }
|
|
func (m *Model) Quitting() bool { return m.quitting }
|
|
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
|
|
|
|
// ── view ───────────────────────────────────────────────────────────────
|
|
|
|
var (
|
|
accent = lipgloss.Color("208") // orange
|
|
subtle = lipgloss.Color("8") // grey
|
|
bright = lipgloss.Color("15") // white
|
|
errCol = lipgloss.Color("1") // red
|
|
infoCol = lipgloss.Color("3") // yellow
|
|
greenCol = lipgloss.Color("2") // green
|
|
|
|
avatarStyle = lipgloss.NewStyle().Foreground(accent)
|
|
|
|
fieldBox = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(subtle).
|
|
Padding(0, 1).
|
|
Width(34)
|
|
|
|
fieldBoxFocused = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(accent).
|
|
Padding(0, 1).
|
|
Width(34)
|
|
|
|
fieldLabel = lipgloss.NewStyle().Foreground(subtle).Padding(0, 0)
|
|
fieldText = lipgloss.NewStyle().Foreground(bright)
|
|
fieldFocus = lipgloss.NewStyle().Foreground(accent)
|
|
|
|
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
|
|
swNeighbour = lipgloss.NewStyle().Foreground(subtle)
|
|
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
|
swDot = lipgloss.NewStyle().Foreground(subtle)
|
|
swDotActive = lipgloss.NewStyle().Foreground(accent)
|
|
|
|
powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true)
|
|
|
|
capsOnStyle = lipgloss.NewStyle().Foreground(greenCol).Bold(true)
|
|
capsOffStyle = lipgloss.NewStyle().Foreground(subtle).Faint(true)
|
|
|
|
statusErrStyle = lipgloss.NewStyle().Foreground(errCol)
|
|
statusInfoStyle = lipgloss.NewStyle().Foreground(infoCol)
|
|
|
|
adminLabel = lipgloss.NewStyle().Foreground(infoCol).Bold(true)
|
|
)
|
|
|
|
func (m *Model) View() string {
|
|
if m.quitting || m.result != nil {
|
|
return ""
|
|
}
|
|
|
|
content := m.renderLayout()
|
|
|
|
if m.automata != nil && m.width > 0 && m.height > 0 {
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
content,
|
|
lipgloss.WithWhitespaceChars(" "),
|
|
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
|
)
|
|
}
|
|
return content
|
|
}
|
|
|
|
func (m *Model) renderLayout() string {
|
|
// top bar: power hints
|
|
top := m.renderPowerHints()
|
|
top = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top)
|
|
|
|
// avatar + form side by side
|
|
avatar := m.renderAvatar()
|
|
form := m.renderForm()
|
|
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
|
|
|
// DE switcher below
|
|
sw := m.renderSwitcherDots()
|
|
|
|
// caps lock
|
|
caps := m.renderCapsLock()
|
|
|
|
// admin unlock
|
|
var admin string
|
|
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
|
admin = m.renderAdminForm()
|
|
}
|
|
|
|
// status
|
|
st := m.renderStatus()
|
|
|
|
var parts []string
|
|
parts = append(parts, top)
|
|
parts = append(parts, "")
|
|
parts = append(parts, mid)
|
|
parts = append(parts, "")
|
|
parts = append(parts, sw)
|
|
if caps != "" {
|
|
parts = append(parts, caps)
|
|
}
|
|
if admin != "" {
|
|
parts = append(parts, "")
|
|
parts = append(parts, admin)
|
|
}
|
|
if st != "" {
|
|
parts = append(parts, st)
|
|
}
|
|
|
|
joined := lipgloss.JoinVertical(lipgloss.Center, parts...)
|
|
if m.shakeOff != 0 {
|
|
pad := strings.Repeat(" ", abs(m.shakeOff))
|
|
if m.shakeOff > 0 {
|
|
joined = pad + joined
|
|
}
|
|
}
|
|
return joined
|
|
}
|
|
|
|
func (m *Model) renderPowerHints() string {
|
|
var hints []string
|
|
for _, pc := range m.cfg.PowerControls.BaseEntries {
|
|
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
|
|
}
|
|
for _, pc := range m.cfg.PowerControls.Entries {
|
|
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
|
|
}
|
|
return strings.Join(hints, " ")
|
|
}
|
|
|
|
func (m *Model) renderAvatar() string {
|
|
var lines []string
|
|
for _, row := range avatarArt {
|
|
lines = append(lines, avatarStyle.Render(row))
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
}
|
|
|
|
func (m *Model) renderForm() string {
|
|
userLabel := fieldLabel.Render("username")
|
|
if m.mode == modeUsername {
|
|
userLabel = fieldFocus.Render("▸ username")
|
|
}
|
|
userVal := m.username
|
|
if userVal == "" {
|
|
userVal = " "
|
|
}
|
|
ubox := fieldBoxFocused
|
|
utxt := fieldFocus
|
|
if m.mode != modeUsername {
|
|
ubox = fieldBox
|
|
utxt = fieldText
|
|
}
|
|
userField := ubox.Render(userLabel + "\n" + utxt.Render(userVal))
|
|
|
|
passLabel := fieldLabel.Render("password")
|
|
if m.mode == modePassword {
|
|
passLabel = fieldFocus.Render("▸ password")
|
|
}
|
|
passVal := strings.Repeat("•", len(m.password))
|
|
if passVal == "" {
|
|
passVal = " "
|
|
}
|
|
pbox := fieldBoxFocused
|
|
ptxt := fieldFocus
|
|
if m.mode != modePassword {
|
|
pbox = fieldBox
|
|
ptxt = fieldText
|
|
}
|
|
passField := pbox.Render(passLabel + "\n" + ptxt.Render(passVal))
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField)
|
|
}
|
|
|
|
func (m *Model) renderSwitcherDots() string {
|
|
if len(m.envs) == 0 {
|
|
return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found")
|
|
}
|
|
|
|
cfg := m.cfg.Switcher
|
|
w := int(cfg.MaxDisplayLength)
|
|
|
|
var parts []string
|
|
|
|
if cfg.ShowMovers && m.selIdx > 0 {
|
|
parts = append(parts, swMover.Render(cfg.LeftMover+" "))
|
|
}
|
|
|
|
if cfg.ShowNeighbours && m.selIdx > 0 {
|
|
parts = append(parts, swNeighbour.Render(padTrim(m.envs[m.selIdx-1].Title, w)+" "))
|
|
}
|
|
|
|
cur := swSelected.Render(padTrim(m.envs[m.selIdx].Title, w))
|
|
parts = append(parts, cur)
|
|
|
|
if cfg.ShowNeighbours && m.selIdx < len(m.envs)-1 {
|
|
parts = append(parts, swNeighbour.Render(" "+padTrim(m.envs[m.selIdx+1].Title, w)))
|
|
}
|
|
|
|
if cfg.ShowMovers && m.selIdx < len(m.envs)-1 {
|
|
parts = append(parts, swMover.Render(" "+cfg.RightMover))
|
|
}
|
|
|
|
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
|
|
|
|
// Dot indicators
|
|
var dots []string
|
|
for i := 0; i < len(m.envs); i++ {
|
|
if i == m.selIdx {
|
|
dots = append(dots, swDotActive.Render("●"))
|
|
} else {
|
|
dots = append(dots, swDot.Render("·"))
|
|
}
|
|
}
|
|
dotLine := strings.Join(dots, " ")
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Center, swLine, dotLine)
|
|
}
|
|
|
|
func (m *Model) renderCapsLock() string {
|
|
if capsLockOn() {
|
|
return capsOnStyle.Align(lipgloss.Center).Render("⬤ Caps Lock")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Model) renderAdminForm() string {
|
|
label := adminLabel.Render("── admin override ──")
|
|
|
|
auLabel := fieldLabel.Render("admin username")
|
|
if m.mode == modeAdminUser {
|
|
auLabel = fieldFocus.Render("▸ admin username")
|
|
}
|
|
auVal := m.adminUser
|
|
if auVal == "" {
|
|
auVal = " "
|
|
}
|
|
auBox := fieldBox
|
|
if m.mode == modeAdminUser {
|
|
auBox = fieldBoxFocused
|
|
}
|
|
auField := auBox.Render(auLabel + "\n" + fieldText.Render(auVal))
|
|
|
|
apLabel := fieldLabel.Render("admin password")
|
|
if m.mode == modeAdminPass {
|
|
apLabel = fieldFocus.Render("▸ admin password")
|
|
}
|
|
apVal := strings.Repeat("•", len(m.adminPass))
|
|
if apVal == "" {
|
|
apVal = " "
|
|
}
|
|
apBox := fieldBox
|
|
if m.mode == modeAdminPass {
|
|
apBox = fieldBoxFocused
|
|
}
|
|
apField := apBox.Render(apLabel + "\n" + fieldText.Render(apVal))
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Center,
|
|
label, "", auField, "", apField,
|
|
)
|
|
}
|
|
|
|
func (m *Model) renderStatus() string {
|
|
if m.status == "" {
|
|
return ""
|
|
}
|
|
st := statusInfoStyle
|
|
if m.statusErr {
|
|
st = statusErrStyle
|
|
}
|
|
return st.Align(lipgloss.Center).Width(70).Render(m.status)
|
|
}
|
|
|
|
// ── caps lock detection ────────────────────────────────────────────────
|
|
|
|
const kdgkbled = 0x4B64
|
|
|
|
func capsLockOn() bool {
|
|
fd := int(os.Stdin.Fd())
|
|
var leds int32
|
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
|
|
uintptr(fd), kdgkbled, uintptr(unsafe.Pointer(&leds)))
|
|
if errno != 0 {
|
|
return false
|
|
}
|
|
return leds&0x04 != 0
|
|
}
|
|
|
|
// ── field editing ──────────────────────────────────────────────────────
|
|
|
|
func handleField(k string, buf *string, cur, scroll *int) {
|
|
switch {
|
|
case k == "backspace" || k == "ctrl+h":
|
|
if *cur == 0 && *scroll == 0 {
|
|
return
|
|
}
|
|
moveLeft(cur, scroll, *buf)
|
|
r := []rune(*buf)
|
|
idx := *scroll + *cur
|
|
if idx < len(r) {
|
|
*buf = string(append(r[:idx], r[idx+1:]...))
|
|
}
|
|
case k == "delete" || k == "ctrl+d":
|
|
r := []rune(*buf)
|
|
idx := *scroll + *cur
|
|
if idx < len(r) {
|
|
*buf = string(append(r[:idx], r[idx+1:]...))
|
|
}
|
|
case k == "ctrl+a":
|
|
*cur, *scroll = 0, 0
|
|
case k == "ctrl+e":
|
|
*cur, *scroll = len([]rune(*buf)), 0
|
|
case k == "ctrl+l", k == "ctrl+u":
|
|
*buf, *cur, *scroll = "", 0, 0
|
|
case k == "left" || k == "ctrl+b":
|
|
moveLeft(cur, scroll, *buf)
|
|
case k == "right" || k == "ctrl+f":
|
|
moveRight(cur, scroll, *buf)
|
|
default:
|
|
if len(k) == 1 {
|
|
r := []rune(*buf)
|
|
idx := *scroll + *cur
|
|
if idx > len(r) {
|
|
idx = len(r)
|
|
}
|
|
ch := []rune(k)[0]
|
|
r = append(r[:idx], append([]rune{ch}, r[idx:]...)...)
|
|
*buf = string(r)
|
|
if *cur >= 30 {
|
|
*scroll++
|
|
} else {
|
|
*cur++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func moveLeft(cur, scroll *int, buf string) {
|
|
if *cur > 0 {
|
|
*cur--
|
|
return
|
|
}
|
|
if *scroll > 0 {
|
|
*scroll--
|
|
}
|
|
}
|
|
|
|
func moveRight(cur, scroll *int, buf string) {
|
|
if *scroll+*cur >= len([]rune(buf)) {
|
|
return
|
|
}
|
|
if *cur >= 30 {
|
|
*scroll++
|
|
} else {
|
|
*cur++
|
|
}
|
|
}
|
|
|
|
func padTrim(s string, w int) string {
|
|
if len(s) > w {
|
|
return s[:w]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func abs(n int) int {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ── tick ────────────────────────────────────────────────────────────────
|
|
|
|
func tick() tea.Cmd {
|
|
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
|
|
return tickMsg(t)
|
|
})
|
|
}
|