(Feat): Initial Commit
This commit is contained in:
785
src/login/login.go
Normal file
785
src/login/login.go
Normal file
@@ -0,0 +1,785 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user