feat(ui): improve password flow with confirmation and conditional disk encryption
- commit
- f7637c329d8fc298230e9bfdf54957c3e5c84122
- author
- bnema · · 2025-10-26 12:14 UTC
- parent
- a85170f
- signature
- unsigned
7 files · +116 -111
M internal/config/config.go +19 -14
| @@ -200,6 +200,10 @@ type Config struct { | |||
| 200 | 200 | EncryptionType string // "none", "luks", or "luks-lvm" | |
| 201 | 201 | EncryptPassword string // Separate encryption password if different from user password | |
| 202 | 202 | ||
| 203 | + | // Form-only fields (not persisted) | |
| 204 | + | ConfirmPassword string // Temporary field for password confirmation | |
| 205 | + | UseSamePasswordForEncryption bool // Checkbox state for using same password for encryption | |
| 206 | + | ||
| 203 | 207 | // Partitioning | |
| 204 | 208 | TargetDisk string | |
| 205 | 209 | BootPartition string | |
| @@ -233,20 +237,21 @@ type Config struct { | |||
| 233 | 237 | // NewConfig creates a new Config with sensible defaults | |
| 234 | 238 | func NewConfig() *Config { | |
| 235 | 239 | return &Config{ | |
| 236 | - | Locale: "en_US.UTF-8", | |
| 237 | - | Timezone: "UTC", | |
| 238 | - | Keymap: "us", | |
| 239 | - | Bootloader: "limine", | |
| 240 | - | EncryptionType: "none", | |
| 241 | - | KernelChoice: "linux", | |
| 242 | - | NetworkManager: "NetworkManager", | |
| 243 | - | AURHelper: "paru", | |
| 244 | - | EnableMultilib: true, | |
| 245 | - | EnableChaotic: true, | |
| 246 | - | ConfigPath: DefaultConfigPath, | |
| 247 | - | LogPath: DefaultLogPath, | |
| 248 | - | RepoURL: "https://github.com/bnema/archup", | |
| 249 | - | RawURL: "https://raw.githubusercontent.com/bnema/archup/main", | |
| 240 | + | Locale: "en_US.UTF-8", | |
| 241 | + | Timezone: "UTC", | |
| 242 | + | Keymap: "us", | |
| 243 | + | Bootloader: "limine", | |
| 244 | + | EncryptionType: "none", | |
| 245 | + | KernelChoice: "linux", | |
| 246 | + | NetworkManager: "NetworkManager", | |
| 247 | + | AURHelper: "paru", | |
| 248 | + | EnableMultilib: true, | |
| 249 | + | EnableChaotic: true, | |
| 250 | + | UseSamePasswordForEncryption: true, // Default to using same password for encryption | |
| 251 | + | ConfigPath: DefaultConfigPath, | |
| 252 | + | LogPath: DefaultLogPath, | |
| 253 | + | RepoURL: "https://github.com/bnema/archup", | |
| 254 | + | RawURL: "https://raw.githubusercontent.com/bnema/archup/main", | |
| 250 | 255 | } | |
| 251 | 256 | } | |
| 252 | 257 | ||
| @@ -200,6 +200,10 @@ type Config struct { | |||||
| 200 | EncryptionType string // "none", "luks", or "luks-lvm" | 200 | EncryptionType string // "none", "luks", or "luks-lvm" | ||
| 201 | EncryptPassword string // Separate encryption password if different from user password | 201 | EncryptPassword string // Separate encryption password if different from user password | ||
| 202 | 202 | ||||
| 203 | + | // Form-only fields (not persisted) | |||
| 204 | + | ConfirmPassword string // Temporary field for password confirmation | |||
| 205 | + | UseSamePasswordForEncryption bool // Checkbox state for using same password for encryption | |||
| 206 | + | ||||
| 203 | // Partitioning | 207 | // Partitioning | ||
| 204 | TargetDisk string | 208 | TargetDisk string | ||
| 205 | BootPartition string | 209 | BootPartition string | ||
| @@ -233,20 +237,21 @@ type Config struct { | |||||
| 233 | // NewConfig creates a new Config with sensible defaults | 237 | // NewConfig creates a new Config with sensible defaults | ||
| 234 | func NewConfig() *Config { | 238 | func NewConfig() *Config { | ||
| 235 | return &Config{ | 239 | return &Config{ | ||
| 236 | - | Locale: "en_US.UTF-8", | 240 | + | Locale: "en_US.UTF-8", |
| 237 | - | Timezone: "UTC", | 241 | + | Timezone: "UTC", |
| 238 | - | Keymap: "us", | 242 | + | Keymap: "us", |
| 239 | - | Bootloader: "limine", | 243 | + | Bootloader: "limine", |
| 240 | - | EncryptionType: "none", | 244 | + | EncryptionType: "none", |
| 241 | - | KernelChoice: "linux", | 245 | + | KernelChoice: "linux", |
| 242 | - | NetworkManager: "NetworkManager", | 246 | + | NetworkManager: "NetworkManager", |
| 243 | - | AURHelper: "paru", | 247 | + | AURHelper: "paru", |
| 244 | - | EnableMultilib: true, | 248 | + | EnableMultilib: true, |
| 245 | - | EnableChaotic: true, | 249 | + | EnableChaotic: true, |
| 246 | - | ConfigPath: DefaultConfigPath, | 250 | + | UseSamePasswordForEncryption: true, // Default to using same password for encryption |
| 247 | - | LogPath: DefaultLogPath, | 251 | + | ConfigPath: DefaultConfigPath, |
| 248 | - | RepoURL: "https://github.com/bnema/archup", | 252 | + | LogPath: DefaultLogPath, |
| 249 | - | RawURL: "https://raw.githubusercontent.com/bnema/archup/main", | 253 | + | RepoURL: "https://github.com/bnema/archup", |
| 254 | + | RawURL: "https://raw.githubusercontent.com/bnema/archup/main", | |||
| 250 | } | 255 | } | ||
| 251 | } | 256 | } | ||
| 252 | 257 | ||||
M internal/phases/systemconfig.go +1 -8
| @@ -182,13 +182,6 @@ func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error | |||
| 182 | 182 | func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error { | |
| 183 | 183 | p.SendOutput(progressChan, "Creating user account...") | |
| 184 | 184 | ||
| 185 | - | // Set root password using stdin (secure - not visible in process list) | |
| 186 | - | rootPassInput := fmt.Sprintf("root:%s", p.config.UserPassword) | |
| 187 | - | switch err := system.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", rootPassInput); { | |
| 188 | - | case err != nil: | |
| 189 | - | return fmt.Errorf("failed to set root password: %w", err) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | 185 | // Create user with home directory | |
| 193 | 186 | userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username) | |
| 194 | 187 | switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); { | |
| @@ -203,7 +196,7 @@ func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error { | |||
| 203 | 196 | return fmt.Errorf("failed to set user password: %w", err) | |
| 204 | 197 | } | |
| 205 | 198 | ||
| 206 | - | // Enable sudo for wheel group | |
| 199 | + | // Enable sudo for wheel group (passwordless for convenience) | |
| 207 | 200 | switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); { | |
| 208 | 201 | case err != nil: | |
| 209 | 202 | return fmt.Errorf("failed to write sudoers config: %w", err) | |
| @@ -182,13 +182,6 @@ func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error | |||||
| 182 | func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error { | 182 | func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error { | ||
| 183 | p.SendOutput(progressChan, "Creating user account...") | 183 | p.SendOutput(progressChan, "Creating user account...") | ||
| 184 | 184 | ||||
| 185 | - | // Set root password using stdin (secure - not visible in process list) | |||
| 186 | - | rootPassInput := fmt.Sprintf("root:%s", p.config.UserPassword) | |||
| 187 | - | switch err := system.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", rootPassInput); { | |||
| 188 | - | case err != nil: | |||
| 189 | - | return fmt.Errorf("failed to set root password: %w", err) | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | // Create user with home directory | 185 | // Create user with home directory | ||
| 193 | userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username) | 186 | userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username) | ||
| 194 | switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); { | 187 | switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); { | ||
| @@ -203,7 +196,7 @@ func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error { | |||||
| 203 | return fmt.Errorf("failed to set user password: %w", err) | 196 | return fmt.Errorf("failed to set user password: %w", err) | ||
| 204 | } | 197 | } | ||
| 205 | 198 | ||||
| 206 | - | // Enable sudo for wheel group | 199 | + | // Enable sudo for wheel group (passwordless for convenience) |
| 207 | switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); { | 200 | switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); { | ||
| 208 | case err != nil: | 201 | case err != nil: | ||
| 209 | return fmt.Errorf("failed to write sudoers config: %w", err) | 202 | return fmt.Errorf("failed to write sudoers config: %w", err) | ||
M internal/ui/components/forms.go +7 -3
| @@ -63,19 +63,23 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri | |||
| 63 | 63 | return ms | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | // Confirm creates a yes/no confirmation | |
| 66 | + | // Confirm creates a yes/no confirmation with inline button alignment | |
| 67 | 67 | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | |
| 68 | 68 | return huh.NewConfirm(). | |
| 69 | 69 | Title(title). | |
| 70 | 70 | Affirmative(affirmative). | |
| 71 | 71 | Negative(negative). | |
| 72 | - | Value(value) | |
| 72 | + | Value(value). | |
| 73 | + | Inline(true) | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 76 | // CreateForm creates a form with accessibility settings and custom theme | |
| 76 | 77 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | |
| 78 | + | const maxFormWidth = 80 // Maximum width for better readability | |
| 79 | + | ||
| 77 | 80 | form := huh.NewForm(groups...). | |
| 78 | - | WithTheme(styles.HuhTheme()) | |
| 81 | + | WithTheme(styles.HuhTheme()). | |
| 82 | + | WithWidth(maxFormWidth) | |
| 79 | 83 | ||
| 80 | 84 | if fb.accessible { | |
| 81 | 85 | form.WithAccessible(true) | |
| @@ -63,19 +63,23 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri | |||||
| 63 | return ms | 63 | return ms | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | - | // Confirm creates a yes/no confirmation | 66 | + | // Confirm creates a yes/no confirmation with inline button alignment |
| 67 | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | 67 | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | ||
| 68 | return huh.NewConfirm(). | 68 | return huh.NewConfirm(). | ||
| 69 | Title(title). | 69 | Title(title). | ||
| 70 | Affirmative(affirmative). | 70 | Affirmative(affirmative). | ||
| 71 | Negative(negative). | 71 | Negative(negative). | ||
| 72 | - | Value(value) | 72 | + | Value(value). |
| 73 | + | Inline(true) | |||
| 73 | } | 74 | } | ||
| 74 | 75 | ||||
| 75 | // CreateForm creates a form with accessibility settings and custom theme | 76 | // CreateForm creates a form with accessibility settings and custom theme | ||
| 76 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | 77 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | ||
| 78 | + | const maxFormWidth = 80 // Maximum width for better readability | |||
| 79 | + | ||||
| 77 | form := huh.NewForm(groups...). | 80 | form := huh.NewForm(groups...). | ||
| 78 | - | WithTheme(styles.HuhTheme()) | 81 | + | WithTheme(styles.HuhTheme()). |
| 82 | + | WithWidth(maxFormWidth) | |||
| 79 | 83 | ||||
| 80 | if fb.accessible { | 84 | if fb.accessible { | ||
| 81 | form.WithAccessible(true) | 85 | form.WithAccessible(true) | ||
M internal/ui/installer_forms.go +26 -42
| @@ -28,19 +28,25 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form { | |||
| 28 | 28 | huh.NewGroup( | |
| 29 | 29 | fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname), | |
| 30 | 30 | fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername), | |
| 31 | + | fb.TextInput("Email (optional)", "> ", &cfg.Email, nil). | |
| 32 | + | Description(DescEmail), | |
| 31 | 33 | ).Title(SectionUserIdentity), | |
| 32 | 34 | ||
| 33 | 35 | // Passwords | |
| 34 | 36 | huh.NewGroup( | |
| 35 | 37 | fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword), | |
| 36 | - | fb.PasswordInput("Root Password", "> ", &cfg.RootPassword, validation.ValidatePassword), | |
| 38 | + | fb.PasswordInput("Confirm Password", "> ", &cfg.ConfirmPassword, validation.ValidatePasswordConfirmation(&cfg.UserPassword)), | |
| 39 | + | ||
| 40 | + | fb.Confirm("Use same password for disk encryption?", "Yes", "No", &cfg.UseSamePasswordForEncryption), | |
| 37 | 41 | ), | |
| 38 | 42 | ||
| 39 | - | // Email (optional) | |
| 43 | + | // Disk Encryption Password (only shown when NOT using same password) | |
| 40 | 44 | huh.NewGroup( | |
| 41 | - | fb.TextInput("Email (optional)", "> ", &cfg.Email, nil). | |
| 42 | - | Description(DescEmail), | |
| 43 | - | ), | |
| 45 | + | fb.PasswordInput("Disk Encryption Password", "> ", &cfg.EncryptPassword, validation.ValidatePassword). | |
| 46 | + | Description("Enter a separate password to unlock your encrypted disk at boot"), | |
| 47 | + | ).WithHideFunc(func() bool { | |
| 48 | + | return cfg.UseSamePasswordForEncryption // Hide when using same password | |
| 49 | + | }), | |
| 44 | 50 | ||
| 45 | 51 | // Localization | |
| 46 | 52 | huh.NewGroup( | |
| @@ -113,47 +119,38 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form { | |||
| 113 | 119 | cfg.Microcode = cpuInfo.Microcode | |
| 114 | 120 | } | |
| 115 | 121 | ||
| 116 | - | // Kernel selection (always shown) | |
| 117 | - | kernelGroup := huh.NewGroup( | |
| 118 | - | fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice), | |
| 119 | - | ).Title(SectionKernel) | |
| 120 | - | ||
| 121 | - | // Encryption options | |
| 122 | + | // Page 1: Kernel and Encryption (system base configuration) | |
| 122 | 123 | encryptionOptions := []huh.Option[string]{ | |
| 123 | 124 | huh.NewOption("No encryption", config.EncryptionNone), | |
| 124 | 125 | huh.NewOption("LUKS encryption", config.EncryptionLUKS), | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 127 | - | encryptionGroup := huh.NewGroup( | |
| 128 | - | fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType), | |
| 129 | - | ).Title(SectionDiskEncryption).Description(DescEncryption) | |
| 130 | - | ||
| 131 | - | // Repository options | |
| 132 | - | repoGroup := huh.NewGroup( | |
| 133 | - | fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib). | |
| 134 | - | Description(DescMultilib), | |
| 135 | - | fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic). | |
| 136 | - | Description(DescChaoticAUR), | |
| 137 | - | ) | |
| 128 | + | systemBaseGroup := huh.NewGroup( | |
| 129 | + | fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice), | |
| 130 | + | fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType). | |
| 131 | + | Description(DescEncryption), | |
| 132 | + | ).Title(SectionKernel) | |
| 138 | 133 | ||
| 139 | - | // AUR helper options | |
| 134 | + | // Page 2: Repository and package management options | |
| 140 | 135 | aurOptions := []huh.Option[string]{ | |
| 141 | 136 | huh.NewOption("None", ""), | |
| 142 | 137 | components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu), | |
| 143 | 138 | components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay), | |
| 144 | 139 | } | |
| 145 | 140 | ||
| 146 | - | aurGroup := huh.NewGroup( | |
| 141 | + | packageManagementGroup := huh.NewGroup( | |
| 142 | + | fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib). | |
| 143 | + | Description(DescMultilib), | |
| 144 | + | fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic). | |
| 145 | + | Description(DescChaoticAUR), | |
| 147 | 146 | fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper). | |
| 148 | 147 | Description(DescAURHelper), | |
| 149 | 148 | ) | |
| 150 | 149 | ||
| 151 | 150 | // Build form groups list | |
| 152 | 151 | groups := []*huh.Group{ | |
| 153 | - | kernelGroup, | |
| 154 | - | encryptionGroup, | |
| 155 | - | repoGroup, | |
| 156 | - | aurGroup, | |
| 152 | + | systemBaseGroup, | |
| 153 | + | packageManagementGroup, | |
| 157 | 154 | } | |
| 158 | 155 | ||
| 159 | 156 | // Add AMD P-State tuning group (conditional - only for AMD CPUs) | |
| @@ -176,7 +173,7 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form { | |||
| 176 | 173 | &cfg.AMDPState), | |
| 177 | 174 | ).Title(SectionAMDTuning).Description(DescAMDPState) | |
| 178 | 175 | ||
| 179 | - | // Insert AMD group after kernel group | |
| 176 | + | // Insert AMD group after system base group (between kernel/encryption and package management) | |
| 180 | 177 | groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...) | |
| 181 | 178 | } | |
| 182 | 179 | } | |
| @@ -184,19 +181,6 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form { | |||
| 184 | 181 | return fb.CreateForm(groups...) | |
| 185 | 182 | } | |
| 186 | 183 | ||
| 187 | - | // CreateEncryptionPasswordForm creates encryption password form | |
| 188 | - | // User can leave empty to use same password as user account | |
| 189 | - | func CreateEncryptionPasswordForm(cfg *config.Config) *huh.Form { | |
| 190 | - | fb := components.NewFormBuilder(false) | |
| 191 | - | ||
| 192 | - | return fb.CreateForm( | |
| 193 | - | huh.NewGroup( | |
| 194 | - | fb.PasswordInput("Encryption Password", "> ", &cfg.EncryptPassword, nil). | |
| 195 | - | Description(DescEncryptionPassword), | |
| 196 | - | ).Title(SectionDiskEncryption).Description(DescEncryption), | |
| 197 | - | ) | |
| 198 | - | } | |
| 199 | - | ||
| 200 | 184 | // FormatDiskOption formats a disk into a user-friendly option string (DRY helper) | |
| 201 | 185 | func FormatDiskOption(disk system.Disk) string { | |
| 202 | 186 | label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size) | |
| @@ -28,19 +28,25 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form { | |||||
| 28 | huh.NewGroup( | 28 | huh.NewGroup( | ||
| 29 | fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname), | 29 | fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname), | ||
| 30 | fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername), | 30 | fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername), | ||
| 31 | + | fb.TextInput("Email (optional)", "> ", &cfg.Email, nil). | |||
| 32 | + | Description(DescEmail), | |||
| 31 | ).Title(SectionUserIdentity), | 33 | ).Title(SectionUserIdentity), | ||
| 32 | 34 | ||||
| 33 | // Passwords | 35 | // Passwords | ||
| 34 | huh.NewGroup( | 36 | huh.NewGroup( | ||
| 35 | fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword), | 37 | fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword), | ||
| 36 | - | fb.PasswordInput("Root Password", "> ", &cfg.RootPassword, validation.ValidatePassword), | 38 | + | fb.PasswordInput("Confirm Password", "> ", &cfg.ConfirmPassword, validation.ValidatePasswordConfirmation(&cfg.UserPassword)), |
| 39 | + | ||||
| 40 | + | fb.Confirm("Use same password for disk encryption?", "Yes", "No", &cfg.UseSamePasswordForEncryption), | |||
| 37 | ), | 41 | ), | ||
| 38 | 42 | ||||
| 39 | - | // Email (optional) | 43 | + | // Disk Encryption Password (only shown when NOT using same password) |
| 40 | huh.NewGroup( | 44 | huh.NewGroup( | ||
| 41 | - | fb.TextInput("Email (optional)", "> ", &cfg.Email, nil). | 45 | + | fb.PasswordInput("Disk Encryption Password", "> ", &cfg.EncryptPassword, validation.ValidatePassword). |
| 42 | - | Description(DescEmail), | 46 | + | Description("Enter a separate password to unlock your encrypted disk at boot"), |
| 43 | - | ), | 47 | + | ).WithHideFunc(func() bool { |
| 48 | + | return cfg.UseSamePasswordForEncryption // Hide when using same password | |||
| 49 | + | }), | |||
| 44 | 50 | ||||
| 45 | // Localization | 51 | // Localization | ||
| 46 | huh.NewGroup( | 52 | huh.NewGroup( | ||
| @@ -113,47 +119,38 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form { | |||||
| 113 | cfg.Microcode = cpuInfo.Microcode | 119 | cfg.Microcode = cpuInfo.Microcode | ||
| 114 | } | 120 | } | ||
| 115 | 121 | ||||
| 116 | - | // Kernel selection (always shown) | 122 | + | // Page 1: Kernel and Encryption (system base configuration) |
| 117 | - | kernelGroup := huh.NewGroup( | |||
| 118 | - | fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice), | |||
| 119 | - | ).Title(SectionKernel) | |||
| 120 | - | ||||
| 121 | - | // Encryption options | |||
| 122 | encryptionOptions := []huh.Option[string]{ | 123 | encryptionOptions := []huh.Option[string]{ | ||
| 123 | huh.NewOption("No encryption", config.EncryptionNone), | 124 | huh.NewOption("No encryption", config.EncryptionNone), | ||
| 124 | huh.NewOption("LUKS encryption", config.EncryptionLUKS), | 125 | huh.NewOption("LUKS encryption", config.EncryptionLUKS), | ||
| 125 | } | 126 | } | ||
| 126 | 127 | ||||
| 127 | - | encryptionGroup := huh.NewGroup( | 128 | + | systemBaseGroup := huh.NewGroup( |
| 128 | - | fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType), | 129 | + | fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice), |
| 129 | - | ).Title(SectionDiskEncryption).Description(DescEncryption) | 130 | + | fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType). |
| 130 | - | 131 | + | Description(DescEncryption), | |
| 131 | - | // Repository options | 132 | + | ).Title(SectionKernel) |
| 132 | - | repoGroup := huh.NewGroup( | |||
| 133 | - | fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib). | |||
| 134 | - | Description(DescMultilib), | |||
| 135 | - | fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic). | |||
| 136 | - | Description(DescChaoticAUR), | |||
| 137 | - | ) | |||
| 138 | 133 | ||||
| 139 | - | // AUR helper options | 134 | + | // Page 2: Repository and package management options |
| 140 | aurOptions := []huh.Option[string]{ | 135 | aurOptions := []huh.Option[string]{ | ||
| 141 | huh.NewOption("None", ""), | 136 | huh.NewOption("None", ""), | ||
| 142 | components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu), | 137 | components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu), | ||
| 143 | components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay), | 138 | components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay), | ||
| 144 | } | 139 | } | ||
| 145 | 140 | ||||
| 146 | - | aurGroup := huh.NewGroup( | 141 | + | packageManagementGroup := huh.NewGroup( |
| 142 | + | fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib). | |||
| 143 | + | Description(DescMultilib), | |||
| 144 | + | fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic). | |||
| 145 | + | Description(DescChaoticAUR), | |||
| 147 | fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper). | 146 | fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper). | ||
| 148 | Description(DescAURHelper), | 147 | Description(DescAURHelper), | ||
| 149 | ) | 148 | ) | ||
| 150 | 149 | ||||
| 151 | // Build form groups list | 150 | // Build form groups list | ||
| 152 | groups := []*huh.Group{ | 151 | groups := []*huh.Group{ | ||
| 153 | - | kernelGroup, | 152 | + | systemBaseGroup, |
| 154 | - | encryptionGroup, | 153 | + | packageManagementGroup, |
| 155 | - | repoGroup, | |||
| 156 | - | aurGroup, | |||
| 157 | } | 154 | } | ||
| 158 | 155 | ||||
| 159 | // Add AMD P-State tuning group (conditional - only for AMD CPUs) | 156 | // Add AMD P-State tuning group (conditional - only for AMD CPUs) | ||
| @@ -176,7 +173,7 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form { | |||||
| 176 | &cfg.AMDPState), | 173 | &cfg.AMDPState), | ||
| 177 | ).Title(SectionAMDTuning).Description(DescAMDPState) | 174 | ).Title(SectionAMDTuning).Description(DescAMDPState) | ||
| 178 | 175 | ||||
| 179 | - | // Insert AMD group after kernel group | 176 | + | // Insert AMD group after system base group (between kernel/encryption and package management) |
| 180 | groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...) | 177 | groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...) | ||
| 181 | } | 178 | } | ||
| 182 | } | 179 | } | ||
| @@ -184,19 +181,6 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form { | |||||
| 184 | return fb.CreateForm(groups...) | 181 | return fb.CreateForm(groups...) | ||
| 185 | } | 182 | } | ||
| 186 | 183 | ||||
| 187 | - | // CreateEncryptionPasswordForm creates encryption password form | |||
| 188 | - | // User can leave empty to use same password as user account | |||
| 189 | - | func CreateEncryptionPasswordForm(cfg *config.Config) *huh.Form { | |||
| 190 | - | fb := components.NewFormBuilder(false) | |||
| 191 | - | ||||
| 192 | - | return fb.CreateForm( | |||
| 193 | - | huh.NewGroup( | |||
| 194 | - | fb.PasswordInput("Encryption Password", "> ", &cfg.EncryptPassword, nil). | |||
| 195 | - | Description(DescEncryptionPassword), | |||
| 196 | - | ).Title(SectionDiskEncryption).Description(DescEncryption), | |||
| 197 | - | ) | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | // FormatDiskOption formats a disk into a user-friendly option string (DRY helper) | 184 | // FormatDiskOption formats a disk into a user-friendly option string (DRY helper) | ||
| 201 | func FormatDiskOption(disk system.Disk) string { | 185 | func FormatDiskOption(disk system.Disk) string { | ||
| 202 | label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size) | 186 | label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size) | ||
M internal/ui/logo.go +7 -16
| @@ -72,28 +72,19 @@ func (l *Logo) Render() string { | |||
| 72 | 72 | return rendered.String() | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | // RenderCentered renders the logo centered horizontally within the terminal | |
| 75 | + | // RenderCentered renders the logo centered horizontally within the terminal using lipgloss | |
| 76 | 76 | func (l *Logo) RenderCentered(termWidth int) string { | |
| 77 | 77 | logo := l.Render() | |
| 78 | 78 | lines := strings.Split(logo, "\n") | |
| 79 | 79 | ||
| 80 | - | var centered strings.Builder | |
| 81 | - | paddingLeft := (termWidth - l.width) / 2 | |
| 82 | - | ||
| 83 | - | if paddingLeft < 0 { | |
| 84 | - | paddingLeft = 0 | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | padding := strings.Repeat(" ", paddingLeft) | |
| 80 | + | centerStyle := lipgloss.NewStyle(). | |
| 81 | + | Width(termWidth). | |
| 82 | + | AlignHorizontal(lipgloss.Center) | |
| 88 | 83 | ||
| 84 | + | var centered strings.Builder | |
| 89 | 85 | for _, line := range lines { | |
| 90 | - | if strings.TrimSpace(line) != "" { | |
| 91 | - | centered.WriteString(padding) | |
| 92 | - | } | |
| 93 | - | centered.WriteString(line) | |
| 94 | - | if !strings.HasSuffix(line, "\n") { | |
| 95 | - | centered.WriteString("\n") | |
| 96 | - | } | |
| 86 | + | centered.WriteString(centerStyle.Render(line)) | |
| 87 | + | centered.WriteString("\n") | |
| 97 | 88 | } | |
| 98 | 89 | ||
| 99 | 90 | return centered.String() | |
| @@ -72,28 +72,19 @@ func (l *Logo) Render() string { | |||||
| 72 | return rendered.String() | 72 | return rendered.String() | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | - | // RenderCentered renders the logo centered horizontally within the terminal | 75 | + | // RenderCentered renders the logo centered horizontally within the terminal using lipgloss |
| 76 | func (l *Logo) RenderCentered(termWidth int) string { | 76 | func (l *Logo) RenderCentered(termWidth int) string { | ||
| 77 | logo := l.Render() | 77 | logo := l.Render() | ||
| 78 | lines := strings.Split(logo, "\n") | 78 | lines := strings.Split(logo, "\n") | ||
| 79 | 79 | ||||
| 80 | - | var centered strings.Builder | 80 | + | centerStyle := lipgloss.NewStyle(). |
| 81 | - | paddingLeft := (termWidth - l.width) / 2 | 81 | + | Width(termWidth). |
| 82 | - | 82 | + | AlignHorizontal(lipgloss.Center) | |
| 83 | - | if paddingLeft < 0 { | |||
| 84 | - | paddingLeft = 0 | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | padding := strings.Repeat(" ", paddingLeft) | |||
| 88 | 83 | ||||
| 84 | + | var centered strings.Builder | |||
| 89 | for _, line := range lines { | 85 | for _, line := range lines { | ||
| 90 | - | if strings.TrimSpace(line) != "" { | 86 | + | centered.WriteString(centerStyle.Render(line)) |
| 91 | - | centered.WriteString(padding) | 87 | + | centered.WriteString("\n") |
| 92 | - | } | |||
| 93 | - | centered.WriteString(line) | |||
| 94 | - | if !strings.HasSuffix(line, "\n") { | |||
| 95 | - | centered.WriteString("\n") | |||
| 96 | - | } | |||
| 97 | } | 88 | } | ||
| 98 | 89 | ||||
| 99 | return centered.String() | 90 | return centered.String() | ||
M internal/ui/model.go +46 -28
| @@ -2,15 +2,14 @@ package ui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "strings" | |
| 6 | 5 | ||
| 7 | - | tea "github.com/charmbracelet/bubbletea" | |
| 8 | - | "github.com/charmbracelet/huh" | |
| 9 | - | "github.com/charmbracelet/lipgloss" | |
| 10 | 6 | "github.com/bnema/archup/internal/config" | |
| 11 | 7 | "github.com/bnema/archup/internal/phases" | |
| 12 | 8 | "github.com/bnema/archup/internal/ui/components" | |
| 13 | 9 | "github.com/bnema/archup/internal/ui/styles" | |
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/charmbracelet/huh" | |
| 12 | + | "github.com/charmbracelet/lipgloss" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | 15 | // ModelState represents the current UI state | |
| @@ -119,6 +118,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 119 | 118 | case StatePreflightForm, StateDiskForm, StateOptionsForm: | |
| 120 | 119 | switch { | |
| 121 | 120 | case m.currentForm != nil: | |
| 121 | + | // Don't pass WindowSizeMsg to form to preserve our max width | |
| 122 | + | if _, isWindowSize := msg.(tea.WindowSizeMsg); isWindowSize { | |
| 123 | + | return m, nil | |
| 124 | + | } | |
| 125 | + | ||
| 122 | 126 | form, cmd := m.currentForm.Update(msg) | |
| 123 | 127 | if f, ok := form.(*huh.Form); ok { | |
| 124 | 128 | m.currentForm = f | |
| @@ -144,14 +148,15 @@ func (m *Model) View() string { | |||
| 144 | 148 | switch m.state { | |
| 145 | 149 | case StateShowLogo: | |
| 146 | 150 | logo := NewLogo(m.version) | |
| 147 | - | return logo.RenderCentered(m.width) + "\n\n" + | |
| 151 | + | return logo.RenderCentered(m.width) + "\n\n\n" + | |
| 148 | 152 | centerText(WelcomeTagline, m.width) + "\n\n" + | |
| 149 | 153 | centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width) | |
| 150 | 154 | ||
| 151 | 155 | case StatePreflightForm, StateDiskForm, StateOptionsForm: | |
| 152 | 156 | switch { | |
| 153 | 157 | case m.currentForm != nil: | |
| 154 | - | return m.currentForm.View() | |
| 158 | + | header := centerText(m.renderPhaseHeader(), m.width) | |
| 159 | + | return header + "\n\n" + m.currentForm.View() | |
| 155 | 160 | } | |
| 156 | 161 | return "Loading form..." | |
| 157 | 162 | ||
| @@ -176,6 +181,12 @@ func (m *Model) View() string { | |||
| 176 | 181 | func (m *Model) handleFormComplete() tea.Cmd { | |
| 177 | 182 | switch m.state { | |
| 178 | 183 | case StatePreflightForm: | |
| 184 | + | // Handle encryption password setting based on checkbox | |
| 185 | + | switch { | |
| 186 | + | case m.config.UseSamePasswordForEncryption: | |
| 187 | + | m.config.EncryptPassword = m.config.UserPassword | |
| 188 | + | } | |
| 189 | + | ||
| 179 | 190 | m.state = StateDiskForm | |
| 180 | 191 | m.currentForm = CreateDiskSelectionForm(m.config) | |
| 181 | 192 | return m.currentForm.Init() | |
| @@ -186,25 +197,12 @@ func (m *Model) handleFormComplete() tea.Cmd { | |||
| 186 | 197 | return m.currentForm.Init() | |
| 187 | 198 | ||
| 188 | 199 | case StateOptionsForm: | |
| 189 | - | // Check if encryption password needed | |
| 190 | - | switch m.config.EncryptionType { | |
| 191 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |
| 192 | - | m.currentForm = CreateEncryptionPasswordForm(m.config) | |
| 193 | - | return m.currentForm.Init() | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | // Move to confirmation | |
| 200 | + | // Move directly to confirmation | |
| 201 | + | // Encryption password is already handled in preflight form | |
| 197 | 202 | m.state = StateConfirmation | |
| 198 | 203 | return nil | |
| 199 | 204 | ||
| 200 | 205 | default: | |
| 201 | - | // Encryption password form completed | |
| 202 | - | // If password is empty, use user password | |
| 203 | - | switch { | |
| 204 | - | case m.config.EncryptPassword == "": | |
| 205 | - | m.config.EncryptPassword = m.config.UserPassword | |
| 206 | - | } | |
| 207 | - | ||
| 208 | 206 | // Move to confirmation | |
| 209 | 207 | m.state = StateConfirmation | |
| 210 | 208 | return nil | |
| @@ -245,13 +243,33 @@ type phaseCompleteMsg struct { | |||
| 245 | 243 | err error | |
| 246 | 244 | } | |
| 247 | 245 | ||
| 248 | - | // centerText centers text within a given width | |
| 246 | + | // centerText centers text within a given width using lipgloss | |
| 249 | 247 | func centerText(text string, width int) string { | |
| 250 | - | textLen := lipgloss.Width(text) | |
| 251 | - | switch { | |
| 252 | - | case textLen >= width: | |
| 253 | - | return text | |
| 248 | + | return lipgloss.NewStyle(). | |
| 249 | + | Width(width). | |
| 250 | + | AlignHorizontal(lipgloss.Center). | |
| 251 | + | Render(text) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | ||
| 255 | + | // renderPhaseHeader renders phase progress header | |
| 256 | + | func (m *Model) renderPhaseHeader() string { | |
| 257 | + | var stepNum, stepName string | |
| 258 | + | ||
| 259 | + | switch m.state { | |
| 260 | + | case StatePreflightForm: | |
| 261 | + | stepNum = "1" | |
| 262 | + | stepName = "System Configuration" | |
| 263 | + | case StateDiskForm: | |
| 264 | + | stepNum = "2" | |
| 265 | + | stepName = "Disk Selection" | |
| 266 | + | case StateOptionsForm: | |
| 267 | + | stepNum = "3" | |
| 268 | + | stepName = "Installation Options" | |
| 269 | + | default: | |
| 270 | + | return "" | |
| 254 | 271 | } | |
| 255 | - | padding := (width - textLen) / 2 | |
| 256 | - | return strings.Repeat(" ", padding) + text | |
| 272 | + | ||
| 273 | + | header := fmt.Sprintf("ArchUp Install - Step %s/3: %s", stepNum, stepName) | |
| 274 | + | return styles.TitleStyle.Render(header) | |
| 257 | 275 | } | |
| @@ -2,15 +2,14 @@ package ui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "strings" | |||
| 6 | 5 | ||||
| 7 | - | tea "github.com/charmbracelet/bubbletea" | |||
| 8 | - | "github.com/charmbracelet/huh" | |||
| 9 | - | "github.com/charmbracelet/lipgloss" | |||
| 10 | "github.com/bnema/archup/internal/config" | 6 | "github.com/bnema/archup/internal/config" | ||
| 11 | "github.com/bnema/archup/internal/phases" | 7 | "github.com/bnema/archup/internal/phases" | ||
| 12 | "github.com/bnema/archup/internal/ui/components" | 8 | "github.com/bnema/archup/internal/ui/components" | ||
| 13 | "github.com/bnema/archup/internal/ui/styles" | 9 | "github.com/bnema/archup/internal/ui/styles" | ||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | + | "github.com/charmbracelet/huh" | |||
| 12 | + | "github.com/charmbracelet/lipgloss" | |||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | // ModelState represents the current UI state | 15 | // ModelState represents the current UI state | ||
| @@ -119,6 +118,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 119 | case StatePreflightForm, StateDiskForm, StateOptionsForm: | 118 | case StatePreflightForm, StateDiskForm, StateOptionsForm: | ||
| 120 | switch { | 119 | switch { | ||
| 121 | case m.currentForm != nil: | 120 | case m.currentForm != nil: | ||
| 121 | + | // Don't pass WindowSizeMsg to form to preserve our max width | |||
| 122 | + | if _, isWindowSize := msg.(tea.WindowSizeMsg); isWindowSize { | |||
| 123 | + | return m, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 122 | form, cmd := m.currentForm.Update(msg) | 126 | form, cmd := m.currentForm.Update(msg) | ||
| 123 | if f, ok := form.(*huh.Form); ok { | 127 | if f, ok := form.(*huh.Form); ok { | ||
| 124 | m.currentForm = f | 128 | m.currentForm = f | ||
| @@ -144,14 +148,15 @@ func (m *Model) View() string { | |||||
| 144 | switch m.state { | 148 | switch m.state { | ||
| 145 | case StateShowLogo: | 149 | case StateShowLogo: | ||
| 146 | logo := NewLogo(m.version) | 150 | logo := NewLogo(m.version) | ||
| 147 | - | return logo.RenderCentered(m.width) + "\n\n" + | 151 | + | return logo.RenderCentered(m.width) + "\n\n\n" + |
| 148 | centerText(WelcomeTagline, m.width) + "\n\n" + | 152 | centerText(WelcomeTagline, m.width) + "\n\n" + | ||
| 149 | centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width) | 153 | centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width) | ||
| 150 | 154 | ||||
| 151 | case StatePreflightForm, StateDiskForm, StateOptionsForm: | 155 | case StatePreflightForm, StateDiskForm, StateOptionsForm: | ||
| 152 | switch { | 156 | switch { | ||
| 153 | case m.currentForm != nil: | 157 | case m.currentForm != nil: | ||
| 154 | - | return m.currentForm.View() | 158 | + | header := centerText(m.renderPhaseHeader(), m.width) |
| 159 | + | return header + "\n\n" + m.currentForm.View() | |||
| 155 | } | 160 | } | ||
| 156 | return "Loading form..." | 161 | return "Loading form..." | ||
| 157 | 162 | ||||
| @@ -176,6 +181,12 @@ func (m *Model) View() string { | |||||
| 176 | func (m *Model) handleFormComplete() tea.Cmd { | 181 | func (m *Model) handleFormComplete() tea.Cmd { | ||
| 177 | switch m.state { | 182 | switch m.state { | ||
| 178 | case StatePreflightForm: | 183 | case StatePreflightForm: | ||
| 184 | + | // Handle encryption password setting based on checkbox | |||
| 185 | + | switch { | |||
| 186 | + | case m.config.UseSamePasswordForEncryption: | |||
| 187 | + | m.config.EncryptPassword = m.config.UserPassword | |||
| 188 | + | } | |||
| 189 | + | ||||
| 179 | m.state = StateDiskForm | 190 | m.state = StateDiskForm | ||
| 180 | m.currentForm = CreateDiskSelectionForm(m.config) | 191 | m.currentForm = CreateDiskSelectionForm(m.config) | ||
| 181 | return m.currentForm.Init() | 192 | return m.currentForm.Init() | ||
| @@ -186,25 +197,12 @@ func (m *Model) handleFormComplete() tea.Cmd { | |||||
| 186 | return m.currentForm.Init() | 197 | return m.currentForm.Init() | ||
| 187 | 198 | ||||
| 188 | case StateOptionsForm: | 199 | case StateOptionsForm: | ||
| 189 | - | // Check if encryption password needed | 200 | + | // Move directly to confirmation |
| 190 | - | switch m.config.EncryptionType { | 201 | + | // Encryption password is already handled in preflight form |
| 191 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 192 | - | m.currentForm = CreateEncryptionPasswordForm(m.config) | |||
| 193 | - | return m.currentForm.Init() | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | // Move to confirmation | |||
| 197 | m.state = StateConfirmation | 202 | m.state = StateConfirmation | ||
| 198 | return nil | 203 | return nil | ||
| 199 | 204 | ||||
| 200 | default: | 205 | default: | ||
| 201 | - | // Encryption password form completed | |||
| 202 | - | // If password is empty, use user password | |||
| 203 | - | switch { | |||
| 204 | - | case m.config.EncryptPassword == "": | |||
| 205 | - | m.config.EncryptPassword = m.config.UserPassword | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | // Move to confirmation | 206 | // Move to confirmation | ||
| 209 | m.state = StateConfirmation | 207 | m.state = StateConfirmation | ||
| 210 | return nil | 208 | return nil | ||
| @@ -245,13 +243,33 @@ type phaseCompleteMsg struct { | |||||
| 245 | err error | 243 | err error | ||
| 246 | } | 244 | } | ||
| 247 | 245 | ||||
| 248 | - | // centerText centers text within a given width | 246 | + | // centerText centers text within a given width using lipgloss |
| 249 | func centerText(text string, width int) string { | 247 | func centerText(text string, width int) string { | ||
| 250 | - | textLen := lipgloss.Width(text) | 248 | + | return lipgloss.NewStyle(). |
| 251 | - | switch { | 249 | + | Width(width). |
| 252 | - | case textLen >= width: | 250 | + | AlignHorizontal(lipgloss.Center). |
| 253 | - | return text | 251 | + | Render(text) |
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | ||||
| 255 | + | // renderPhaseHeader renders phase progress header | |||
| 256 | + | func (m *Model) renderPhaseHeader() string { | |||
| 257 | + | var stepNum, stepName string | |||
| 258 | + | ||||
| 259 | + | switch m.state { | |||
| 260 | + | case StatePreflightForm: | |||
| 261 | + | stepNum = "1" | |||
| 262 | + | stepName = "System Configuration" | |||
| 263 | + | case StateDiskForm: | |||
| 264 | + | stepNum = "2" | |||
| 265 | + | stepName = "Disk Selection" | |||
| 266 | + | case StateOptionsForm: | |||
| 267 | + | stepNum = "3" | |||
| 268 | + | stepName = "Installation Options" | |||
| 269 | + | default: | |||
| 270 | + | return "" | |||
| 254 | } | 271 | } | ||
| 255 | - | padding := (width - textLen) / 2 | 272 | + | |
| 256 | - | return strings.Repeat(" ", padding) + text | 273 | + | header := fmt.Sprintf("ArchUp Install - Step %s/3: %s", stepNum, stepName) |
| 274 | + | return styles.TitleStyle.Render(header) | |||
| 257 | } | 275 | } | ||
M internal/validation/validators.go +10 -0
| @@ -79,3 +79,13 @@ func ValidateLocale(s string) error { | |||
| 79 | 79 | // Basic validation - should match pattern like en_US.UTF-8 | |
| 80 | 80 | return nil | |
| 81 | 81 | } | |
| 82 | + | ||
| 83 | + | // ValidatePasswordConfirmation validates that password and confirmation match | |
| 84 | + | func ValidatePasswordConfirmation(password *string) func(string) error { | |
| 85 | + | return func(confirm string) error { | |
| 86 | + | if confirm != *password { | |
| 87 | + | return errors.New("passwords do not match") | |
| 88 | + | } | |
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | } | |
| @@ -79,3 +79,13 @@ func ValidateLocale(s string) error { | |||||
| 79 | // Basic validation - should match pattern like en_US.UTF-8 | 79 | // Basic validation - should match pattern like en_US.UTF-8 | ||
| 80 | return nil | 80 | return nil | ||
| 81 | } | 81 | } | ||
| 82 | + | ||||
| 83 | + | // ValidatePasswordConfirmation validates that password and confirmation match | |||
| 84 | + | func ValidatePasswordConfirmation(password *string) func(string) error { | |||
| 85 | + | return func(confirm string) error { | |||
| 86 | + | if confirm != *password { | |||
| 87 | + | return errors.New("passwords do not match") | |||
| 88 | + | } | |||
| 89 | + | return nil | |||
| 90 | + | } | |||
| 91 | + | } | |||