Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
856 lines
20 KiB
Go
856 lines
20 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
|
|
}
|
|
|
|
if len(m.envs) > 0 {
|
|
switch k {
|
|
case ",", "<":
|
|
m.selIdx--
|
|
if m.selIdx < 0 {
|
|
m.selIdx = len(m.envs) - 1
|
|
}
|
|
m.spring = -4
|
|
return m, nil
|
|
case ".", ">":
|
|
m.selIdx++
|
|
if m.selIdx >= len(m.envs) {
|
|
m.selIdx = 0
|
|
}
|
|
m.spring = 4
|
|
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
|
|
|
|
cardBg = lipgloss.Color("0") // black card background
|
|
cardPad = 2
|
|
|
|
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.width > 0 && m.height > 0 {
|
|
if m.automata != nil {
|
|
bg := m.automata.Frame()
|
|
var sb strings.Builder
|
|
sb.WriteString(bg)
|
|
sb.WriteString("\x1b[H") // cursor home before overlaying
|
|
sb.WriteString(overlayCenter(m.width, m.height, content))
|
|
return sb.String()
|
|
}
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
content,
|
|
)
|
|
}
|
|
return content
|
|
}
|
|
|
|
func overlayCenter(w, h int, content string) string {
|
|
lines := strings.Split(content, "\n")
|
|
cw := 0
|
|
for _, l := range lines {
|
|
if lw := lipgloss.Width(l); lw > cw {
|
|
cw = lw
|
|
}
|
|
}
|
|
cx := (w - cw) / 2
|
|
cy := (h - len(lines)) / 2
|
|
if cx < 0 {
|
|
cx = 0
|
|
}
|
|
if cy < 0 {
|
|
cy = 0
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for i, line := range lines {
|
|
sb.WriteString(fmt.Sprintf("\x1b[%d;%dH", cy+i+1, cx+1))
|
|
sb.WriteString(line)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func (m *Model) renderLayout() string {
|
|
clock := m.renderClock()
|
|
|
|
// top bar: power hints on left, clock on right
|
|
var topParts []string
|
|
topParts = append(topParts, m.renderPowerHints())
|
|
if clock != "" {
|
|
topParts = append(topParts, clock)
|
|
}
|
|
top := strings.Join(topParts, " ")
|
|
|
|
// avatar + form side by side
|
|
avatar := m.renderAvatar()
|
|
form := m.renderForm()
|
|
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
|
|
|
// DE switcher with specifier label
|
|
sw := m.renderSwitcher()
|
|
|
|
// 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 lipgloss.NewStyle().
|
|
Background(cardBg).
|
|
Padding(cardPad, cardPad+2).
|
|
Render(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) renderClock() string {
|
|
now := time.Now()
|
|
return powerHint.Render(now.Format("Mon Jan 02 15:04:05"))
|
|
}
|
|
|
|
func (m *Model) renderSwitcher() 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
|
|
|
|
// session specifier (like "x11", "wayland")
|
|
spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
|
|
|
|
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, spec, 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)
|
|
})
|
|
}
|