fix(ui): improve form layout, width control, and fix install file paths across phases
- commit
- 11cad8a78c26af0c87abbf53e1de0e318340ede6
- author
- bnema · · 2025-10-26 17:21 UTC
- parent
- da00894
- signature
- unsigned
12 files · +176 -100
M cmd/archup-installer/main.go +3 -2
| @@ -54,8 +54,9 @@ func main() { | |||
| 54 | 54 | log.Info("Running in DRY-RUN mode - commands will be logged but not executed") | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | // Load or create config | |
| 58 | - | cfg := config.NewConfig() | |
| 57 | + | // Load or create config (pass version to determine correct branch for downloads) | |
| 58 | + | cfg := config.NewConfig(version) | |
| 59 | + | log.Info("Config initialized", "version", version, "raw_url", cfg.RawURL) | |
| 59 | 60 | ||
| 60 | 61 | // Initialize orchestrator | |
| 61 | 62 | orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath) | |
| @@ -54,8 +54,9 @@ func main() { | |||||
| 54 | log.Info("Running in DRY-RUN mode - commands will be logged but not executed") | 54 | log.Info("Running in DRY-RUN mode - commands will be logged but not executed") | ||
| 55 | } | 55 | } | ||
| 56 | 56 | ||||
| 57 | - | // Load or create config | 57 | + | // Load or create config (pass version to determine correct branch for downloads) |
| 58 | - | cfg := config.NewConfig() | 58 | + | cfg := config.NewConfig(version) |
| 59 | + | log.Info("Config initialized", "version", version, "raw_url", cfg.RawURL) | |||
| 59 | 60 | ||||
| 60 | // Initialize orchestrator | 61 | // Initialize orchestrator | ||
| 61 | orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath) | 62 | orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath) | ||
M internal/config/config.go +11 -4
| @@ -235,7 +235,14 @@ type Config struct { | |||
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | 237 | // NewConfig creates a new Config with sensible defaults | |
| 238 | - | func NewConfig() *Config { | |
| 238 | + | // version parameter determines which branch to use for downloads (dev builds use dev branch) | |
| 239 | + | func NewConfig(version string) *Config { | |
| 240 | + | // Determine which branch to use based on version | |
| 241 | + | branch := "main" | |
| 242 | + | if version == "dev" || version == "" { | |
| 243 | + | branch = "dev" | |
| 244 | + | } | |
| 245 | + | ||
| 239 | 246 | return &Config{ | |
| 240 | 247 | Locale: "en_US.UTF-8", | |
| 241 | 248 | Timezone: "UTC", | |
| @@ -251,13 +258,13 @@ func NewConfig() *Config { | |||
| 251 | 258 | ConfigPath: DefaultConfigPath, | |
| 252 | 259 | LogPath: DefaultLogPath, | |
| 253 | 260 | RepoURL: "https://github.com/bnema/archup", | |
| 254 | - | RawURL: "https://raw.githubusercontent.com/bnema/archup/main", | |
| 261 | + | RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch), | |
| 255 | 262 | } | |
| 256 | 263 | } | |
| 257 | 264 | ||
| 258 | 265 | // Load reads configuration from a shell-compatible config file | |
| 259 | - | func Load(path string) (*Config, error) { | |
| 260 | - | cfg := NewConfig() | |
| 266 | + | func Load(path string, version string) (*Config, error) { | |
| 267 | + | cfg := NewConfig(version) | |
| 261 | 268 | cfg.ConfigPath = path | |
| 262 | 269 | ||
| 263 | 270 | file, err := os.Open(path) | |
| @@ -235,7 +235,14 @@ type Config struct { | |||||
| 235 | } | 235 | } | ||
| 236 | 236 | ||||
| 237 | // NewConfig creates a new Config with sensible defaults | 237 | // NewConfig creates a new Config with sensible defaults | ||
| 238 | - | func NewConfig() *Config { | 238 | + | // version parameter determines which branch to use for downloads (dev builds use dev branch) |
| 239 | + | func NewConfig(version string) *Config { | |||
| 240 | + | // Determine which branch to use based on version | |||
| 241 | + | branch := "main" | |||
| 242 | + | if version == "dev" || version == "" { | |||
| 243 | + | branch = "dev" | |||
| 244 | + | } | |||
| 245 | + | ||||
| 239 | return &Config{ | 246 | return &Config{ | ||
| 240 | Locale: "en_US.UTF-8", | 247 | Locale: "en_US.UTF-8", | ||
| 241 | Timezone: "UTC", | 248 | Timezone: "UTC", | ||
| @@ -251,13 +258,13 @@ func NewConfig() *Config { | |||||
| 251 | ConfigPath: DefaultConfigPath, | 258 | ConfigPath: DefaultConfigPath, | ||
| 252 | LogPath: DefaultLogPath, | 259 | LogPath: DefaultLogPath, | ||
| 253 | RepoURL: "https://github.com/bnema/archup", | 260 | RepoURL: "https://github.com/bnema/archup", | ||
| 254 | - | RawURL: "https://raw.githubusercontent.com/bnema/archup/main", | 261 | + | RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch), |
| 255 | } | 262 | } | ||
| 256 | } | 263 | } | ||
| 257 | 264 | ||||
| 258 | // Load reads configuration from a shell-compatible config file | 265 | // Load reads configuration from a shell-compatible config file | ||
| 259 | - | func Load(path string) (*Config, error) { | 266 | + | func Load(path string, version string) (*Config, error) { |
| 260 | - | cfg := NewConfig() | 267 | + | cfg := NewConfig(version) |
| 261 | cfg.ConfigPath = path | 268 | cfg.ConfigPath = path | ||
| 262 | 269 | ||||
| 263 | file, err := os.Open(path) | 270 | file, err := os.Open(path) | ||
M internal/phases/base.go +2 -8
| @@ -205,14 +205,8 @@ func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error | |||
| 205 | 205 | // loadBasePackages reads the base package list from file | |
| 206 | 206 | func (p *BaseInstallPhase) loadBasePackages() ([]string, error) { | |
| 207 | 207 | // Read from install directory (downloaded during bootstrap) | |
| 208 | - | installPath := os.Getenv("ARCHUP_INSTALL") | |
| 209 | - | switch { | |
| 210 | - | case installPath == "": | |
| 211 | - | home := os.Getenv("HOME") | |
| 212 | - | installPath = home + "/" + config.DefaultInstallPath | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | packageFile := installPath + "/" + config.BasePackagesFile | |
| 208 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files | |
| 209 | + | packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile | |
| 216 | 210 | ||
| 217 | 211 | file, err := os.Open(packageFile) | |
| 218 | 212 | switch { | |
| @@ -205,14 +205,8 @@ func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error | |||||
| 205 | // loadBasePackages reads the base package list from file | 205 | // loadBasePackages reads the base package list from file | ||
| 206 | func (p *BaseInstallPhase) loadBasePackages() ([]string, error) { | 206 | func (p *BaseInstallPhase) loadBasePackages() ([]string, error) { | ||
| 207 | // Read from install directory (downloaded during bootstrap) | 207 | // Read from install directory (downloaded during bootstrap) | ||
| 208 | - | installPath := os.Getenv("ARCHUP_INSTALL") | 208 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files |
| 209 | - | switch { | 209 | + | packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile |
| 210 | - | case installPath == "": | |||
| 211 | - | home := os.Getenv("HOME") | |||
| 212 | - | installPath = home + "/" + config.DefaultInstallPath | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | packageFile := installPath + "/" + config.BasePackagesFile | |||
| 216 | 210 | ||||
| 217 | file, err := os.Open(packageFile) | 211 | file, err := os.Open(packageFile) | ||
| 218 | switch { | 212 | switch { | ||
M internal/phases/boot.go +2 -7
| @@ -263,13 +263,8 @@ func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error { | |||
| 263 | 263 | ||
| 264 | 264 | // getInstallPath constructs full path to install file | |
| 265 | 265 | func (p *BootPhase) getInstallPath(filename string) string { | |
| 266 | - | installPath := os.Getenv("ARCHUP_INSTALL") | |
| 267 | - | switch { | |
| 268 | - | case installPath == "": | |
| 269 | - | home := os.Getenv("HOME") | |
| 270 | - | installPath = filepath.Join(home, config.DefaultInstallPath) | |
| 271 | - | } | |
| 272 | - | return filepath.Join(installPath, filename) | |
| 266 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files | |
| 267 | + | return filepath.Join(config.DefaultInstallDir, filename) | |
| 273 | 268 | } | |
| 274 | 269 | ||
| 275 | 270 | // extractPartitionNumber extracts partition number from device path | |
| @@ -263,13 +263,8 @@ func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error { | |||||
| 263 | 263 | ||||
| 264 | // getInstallPath constructs full path to install file | 264 | // getInstallPath constructs full path to install file | ||
| 265 | func (p *BootPhase) getInstallPath(filename string) string { | 265 | func (p *BootPhase) getInstallPath(filename string) string { | ||
| 266 | - | installPath := os.Getenv("ARCHUP_INSTALL") | 266 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files |
| 267 | - | switch { | 267 | + | return filepath.Join(config.DefaultInstallDir, filename) |
| 268 | - | case installPath == "": | |||
| 269 | - | home := os.Getenv("HOME") | |||
| 270 | - | installPath = filepath.Join(home, config.DefaultInstallPath) | |||
| 271 | - | } | |||
| 272 | - | return filepath.Join(installPath, filename) | |||
| 273 | } | 268 | } | ||
| 274 | 269 | ||||
| 275 | // extractPartitionNumber extracts partition number from device path | 270 | // extractPartitionNumber extracts partition number from device path | ||
M internal/phases/bootstrap.go +0 -4
| @@ -91,10 +91,6 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult | |||
| 91 | 91 | url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL), | |
| 92 | 92 | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"), | |
| 93 | 93 | }, | |
| 94 | - | { | |
| 95 | - | url: fmt.Sprintf("%s/install/configs/shell/functions", p.config.RawURL), | |
| 96 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "functions"), | |
| 97 | - | }, | |
| 98 | 94 | { | |
| 99 | 95 | url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL), | |
| 100 | 96 | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"), | |
| @@ -91,10 +91,6 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult | |||||
| 91 | url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL), | 91 | url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL), | ||
| 92 | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"), | 92 | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"), | ||
| 93 | }, | 93 | }, | ||
| 94 | - | { | |||
| 95 | - | url: fmt.Sprintf("%s/install/configs/shell/functions", p.config.RawURL), | |||
| 96 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "functions"), | |||
| 97 | - | }, | |||
| 98 | { | 94 | { | ||
| 99 | url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL), | 95 | url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL), | ||
| 100 | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"), | 96 | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"), | ||
M internal/phases/postinstall.go +2 -8
| @@ -509,14 +509,8 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s | |||
| 509 | 509 | ||
| 510 | 510 | // readTemplate reads a template file from install path | |
| 511 | 511 | func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) { | |
| 512 | - | installPath := os.Getenv("ARCHUP_INSTALL") | |
| 513 | - | switch { | |
| 514 | - | case installPath == "": | |
| 515 | - | home := os.Getenv("HOME") | |
| 516 | - | installPath = filepath.Join(home, config.DefaultInstallPath) | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | templatePath := filepath.Join(installPath, filename) | |
| 512 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files | |
| 513 | + | templatePath := filepath.Join(config.DefaultInstallDir, filename) | |
| 520 | 514 | return os.ReadFile(templatePath) | |
| 521 | 515 | } | |
| 522 | 516 | ||
| @@ -509,14 +509,8 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s | |||||
| 509 | 509 | ||||
| 510 | // readTemplate reads a template file from install path | 510 | // readTemplate reads a template file from install path | ||
| 511 | func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) { | 511 | func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) { | ||
| 512 | - | installPath := os.Getenv("ARCHUP_INSTALL") | 512 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files |
| 513 | - | switch { | 513 | + | templatePath := filepath.Join(config.DefaultInstallDir, filename) |
| 514 | - | case installPath == "": | |||
| 515 | - | home := os.Getenv("HOME") | |||
| 516 | - | installPath = filepath.Join(home, config.DefaultInstallPath) | |||
| 517 | - | } | |||
| 518 | - | ||||
| 519 | - | templatePath := filepath.Join(installPath, filename) | |||
| 520 | return os.ReadFile(templatePath) | 514 | return os.ReadFile(templatePath) | ||
| 521 | } | 515 | } | ||
| 522 | 516 | ||||
M internal/phases/repos.go +2 -7
| @@ -359,13 +359,8 @@ func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error | |||
| 359 | 359 | ||
| 360 | 360 | // getInstallPath constructs full path to install file | |
| 361 | 361 | func (p *ReposPhase) getInstallPath(filename string) string { | |
| 362 | - | installPath := os.Getenv("ARCHUP_INSTALL") | |
| 363 | - | switch { | |
| 364 | - | case installPath == "": | |
| 365 | - | home := os.Getenv("HOME") | |
| 366 | - | installPath = filepath.Join(home, config.DefaultInstallPath) | |
| 367 | - | } | |
| 368 | - | return filepath.Join(installPath, filename) | |
| 362 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files | |
| 363 | + | return filepath.Join(config.DefaultInstallDir, filename) | |
| 369 | 364 | } | |
| 370 | 365 | ||
| 371 | 366 | // PostCheck validates repository configuration | |
| @@ -359,13 +359,8 @@ func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error | |||||
| 359 | 359 | ||||
| 360 | // getInstallPath constructs full path to install file | 360 | // getInstallPath constructs full path to install file | ||
| 361 | func (p *ReposPhase) getInstallPath(filename string) string { | 361 | func (p *ReposPhase) getInstallPath(filename string) string { | ||
| 362 | - | installPath := os.Getenv("ARCHUP_INSTALL") | 362 | + | // Use DefaultInstallDir directly to match where bootstrap downloads files |
| 363 | - | switch { | 363 | + | return filepath.Join(config.DefaultInstallDir, filename) |
| 364 | - | case installPath == "": | |||
| 365 | - | home := os.Getenv("HOME") | |||
| 366 | - | installPath = filepath.Join(home, config.DefaultInstallPath) | |||
| 367 | - | } | |||
| 368 | - | return filepath.Join(installPath, filename) | |||
| 369 | } | 364 | } | ||
| 370 | 365 | ||||
| 371 | // PostCheck validates repository configuration | 366 | // PostCheck validates repository configuration | ||
M internal/ui/components/forms.go +17 -6
| @@ -2,18 +2,21 @@ package components | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/charmbracelet/huh" | |
| 5 | + | "github.com/charmbracelet/lipgloss" | |
| 5 | 6 | "github.com/bnema/archup/internal/ui/styles" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // FormBuilder provides reusable form components | |
| 9 | 10 | type FormBuilder struct { | |
| 10 | 11 | accessible bool | |
| 12 | + | width int | |
| 11 | 13 | } | |
| 12 | 14 | ||
| 13 | - | // NewFormBuilder creates a new form builder | |
| 14 | - | func NewFormBuilder(accessible bool) *FormBuilder { | |
| 15 | + | // NewFormBuilder creates a new form builder with specified width | |
| 16 | + | func NewFormBuilder(accessible bool, width int) *FormBuilder { | |
| 15 | 17 | return &FormBuilder{ | |
| 16 | 18 | accessible: accessible, | |
| 19 | + | width: width, | |
| 17 | 20 | } | |
| 18 | 21 | } | |
| 19 | 22 | ||
| @@ -63,23 +66,31 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri | |||
| 63 | 66 | return ms | |
| 64 | 67 | } | |
| 65 | 68 | ||
| 66 | - | // Confirm creates a yes/no confirmation with inline button alignment | |
| 69 | + | // Confirm creates a yes/no confirmation with left-aligned buttons | |
| 67 | 70 | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | |
| 68 | 71 | return huh.NewConfirm(). | |
| 69 | 72 | Title(title). | |
| 70 | 73 | Affirmative(affirmative). | |
| 71 | 74 | Negative(negative). | |
| 72 | 75 | Value(value). | |
| 73 | - | Inline(true) | |
| 76 | + | WithButtonAlignment(lipgloss.Left) | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | 79 | // CreateForm creates a form with accessibility settings and custom theme | |
| 77 | 80 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | |
| 78 | - | const maxFormWidth = 80 // Maximum width for better readability | |
| 81 | + | // Use 70% of terminal width for better readability (max 80) | |
| 82 | + | formWidth := fb.width * 70 / 100 | |
| 83 | + | if formWidth > 80 { | |
| 84 | + | formWidth = 80 | |
| 85 | + | } | |
| 86 | + | if formWidth < 40 { | |
| 87 | + | formWidth = 40 // Minimum width | |
| 88 | + | } | |
| 79 | 89 | ||
| 80 | 90 | form := huh.NewForm(groups...). | |
| 81 | 91 | WithTheme(styles.HuhTheme()). | |
| 82 | - | WithWidth(maxFormWidth) | |
| 92 | + | WithKeyMap(CreateTTYKeyMap()). | |
| 93 | + | WithWidth(formWidth) | |
| 83 | 94 | ||
| 84 | 95 | if fb.accessible { | |
| 85 | 96 | form.WithAccessible(true) | |
| @@ -2,18 +2,21 @@ package components | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/charmbracelet/huh" | 4 | "github.com/charmbracelet/huh" | ||
| 5 | + | "github.com/charmbracelet/lipgloss" | |||
| 5 | "github.com/bnema/archup/internal/ui/styles" | 6 | "github.com/bnema/archup/internal/ui/styles" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | // FormBuilder provides reusable form components | 9 | // FormBuilder provides reusable form components | ||
| 9 | type FormBuilder struct { | 10 | type FormBuilder struct { | ||
| 10 | accessible bool | 11 | accessible bool | ||
| 12 | + | width int | |||
| 11 | } | 13 | } | ||
| 12 | 14 | ||||
| 13 | - | // NewFormBuilder creates a new form builder | 15 | + | // NewFormBuilder creates a new form builder with specified width |
| 14 | - | func NewFormBuilder(accessible bool) *FormBuilder { | 16 | + | func NewFormBuilder(accessible bool, width int) *FormBuilder { |
| 15 | return &FormBuilder{ | 17 | return &FormBuilder{ | ||
| 16 | accessible: accessible, | 18 | accessible: accessible, | ||
| 19 | + | width: width, | |||
| 17 | } | 20 | } | ||
| 18 | } | 21 | } | ||
| 19 | 22 | ||||
| @@ -63,23 +66,31 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri | |||||
| 63 | return ms | 66 | return ms | ||
| 64 | } | 67 | } | ||
| 65 | 68 | ||||
| 66 | - | // Confirm creates a yes/no confirmation with inline button alignment | 69 | + | // Confirm creates a yes/no confirmation with left-aligned buttons |
| 67 | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | 70 | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | ||
| 68 | return huh.NewConfirm(). | 71 | return huh.NewConfirm(). | ||
| 69 | Title(title). | 72 | Title(title). | ||
| 70 | Affirmative(affirmative). | 73 | Affirmative(affirmative). | ||
| 71 | Negative(negative). | 74 | Negative(negative). | ||
| 72 | Value(value). | 75 | Value(value). | ||
| 73 | - | Inline(true) | 76 | + | WithButtonAlignment(lipgloss.Left) |
| 74 | } | 77 | } | ||
| 75 | 78 | ||||
| 76 | // CreateForm creates a form with accessibility settings and custom theme | 79 | // CreateForm creates a form with accessibility settings and custom theme | ||
| 77 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | 80 | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | ||
| 78 | - | const maxFormWidth = 80 // Maximum width for better readability | 81 | + | // Use 70% of terminal width for better readability (max 80) |
| 82 | + | formWidth := fb.width * 70 / 100 | |||
| 83 | + | if formWidth > 80 { | |||
| 84 | + | formWidth = 80 | |||
| 85 | + | } | |||
| 86 | + | if formWidth < 40 { | |||
| 87 | + | formWidth = 40 // Minimum width | |||
| 88 | + | } | |||
| 79 | 89 | ||||
| 80 | form := huh.NewForm(groups...). | 90 | form := huh.NewForm(groups...). | ||
| 81 | WithTheme(styles.HuhTheme()). | 91 | WithTheme(styles.HuhTheme()). | ||
| 82 | - | WithWidth(maxFormWidth) | 92 | + | WithKeyMap(CreateTTYKeyMap()). |
| 93 | + | WithWidth(formWidth) | |||
| 83 | 94 | ||||
| 84 | if fb.accessible { | 95 | if fb.accessible { | ||
| 85 | form.WithAccessible(true) | 96 | form.WithAccessible(true) | ||
A internal/ui/components/keybinds.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package components | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/charmbracelet/bubbles/key" | |
| 5 | + | "github.com/charmbracelet/huh" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // CreateTTYKeyMap creates a custom keymap with TTY-friendly alternatives to Shift+Tab | |
| 9 | + | // This is needed for QEMU console and other limited terminal environments where | |
| 10 | + | // Shift+Tab is not properly transmitted. | |
| 11 | + | func CreateTTYKeyMap() *huh.KeyMap { | |
| 12 | + | km := huh.NewDefaultKeyMap() | |
| 13 | + | ||
| 14 | + | // Add Ctrl+P as alternative to Shift+Tab for "previous" navigation | |
| 15 | + | // These work better in QEMU console and limited TTY environments | |
| 16 | + | km.Input.Prev = key.NewBinding( | |
| 17 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 18 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 19 | + | ) | |
| 20 | + | km.Text.Prev = key.NewBinding( | |
| 21 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 22 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 23 | + | ) | |
| 24 | + | km.Select.Prev = key.NewBinding( | |
| 25 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 26 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 27 | + | ) | |
| 28 | + | km.MultiSelect.Prev = key.NewBinding( | |
| 29 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 30 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 31 | + | ) | |
| 32 | + | km.Note.Prev = key.NewBinding( | |
| 33 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 34 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 35 | + | ) | |
| 36 | + | km.Confirm.Prev = key.NewBinding( | |
| 37 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 38 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 39 | + | ) | |
| 40 | + | km.FilePicker.Prev = key.NewBinding( | |
| 41 | + | key.WithKeys("shift+tab", "ctrl+p"), | |
| 42 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | return km | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/charmbracelet/bubbles/key" | |||
| 5 | + | "github.com/charmbracelet/huh" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // CreateTTYKeyMap creates a custom keymap with TTY-friendly alternatives to Shift+Tab | |||
| 9 | + | // This is needed for QEMU console and other limited terminal environments where | |||
| 10 | + | // Shift+Tab is not properly transmitted. | |||
| 11 | + | func CreateTTYKeyMap() *huh.KeyMap { | |||
| 12 | + | km := huh.NewDefaultKeyMap() | |||
| 13 | + | ||||
| 14 | + | // Add Ctrl+P as alternative to Shift+Tab for "previous" navigation | |||
| 15 | + | // These work better in QEMU console and limited TTY environments | |||
| 16 | + | km.Input.Prev = key.NewBinding( | |||
| 17 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 18 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 19 | + | ) | |||
| 20 | + | km.Text.Prev = key.NewBinding( | |||
| 21 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 22 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 23 | + | ) | |||
| 24 | + | km.Select.Prev = key.NewBinding( | |||
| 25 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 26 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 27 | + | ) | |||
| 28 | + | km.MultiSelect.Prev = key.NewBinding( | |||
| 29 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 30 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 31 | + | ) | |||
| 32 | + | km.Note.Prev = key.NewBinding( | |||
| 33 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 34 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 35 | + | ) | |||
| 36 | + | km.Confirm.Prev = key.NewBinding( | |||
| 37 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 38 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 39 | + | ) | |||
| 40 | + | km.FilePicker.Prev = key.NewBinding( | |||
| 41 | + | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 42 | + | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | return km | |||
| 46 | + | } | |||
M internal/ui/installer_forms.go +11 -11
| @@ -11,8 +11,7 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // CreatePreflightForm creates the initial configuration form with all user identity fields | |
| 14 | - | func CreatePreflightForm(cfg *config.Config) *huh.Form { | |
| 15 | - | fb := components.NewFormBuilder(false) | |
| 14 | + | func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { | |
| 16 | 15 | ||
| 17 | 16 | // Auto-detect timezone from API, fallback to default if detection fails | |
| 18 | 17 | detectedTimezone := system.DetectTimezone() | |
| @@ -61,19 +60,21 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form { | |||
| 61 | 60 | } | |
| 62 | 61 | ||
| 63 | 62 | // CreateDiskSelectionForm creates disk selection form with detailed disk information | |
| 64 | - | func CreateDiskSelectionForm(cfg *config.Config) *huh.Form { | |
| 65 | - | fb := components.NewFormBuilder(false) | |
| 63 | + | func CreateDiskSelectionForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { | |
| 66 | 64 | ||
| 67 | 65 | // Detect available disks | |
| 68 | 66 | disks, err := system.ListDisks() | |
| 69 | 67 | ||
| 70 | - | var diskOptions []string | |
| 68 | + | var diskOptions []huh.Option[string] | |
| 71 | 69 | switch { | |
| 72 | 70 | case err != nil: | |
| 73 | - | // Fallback to empty list if detection fails | |
| 74 | - | diskOptions = []string{"No disks detected"} | |
| 71 | + | // Fallback to empty option if detection fails | |
| 72 | + | diskOptions = []huh.Option[string]{ | |
| 73 | + | huh.NewOption("No disks detected", ""), | |
| 74 | + | } | |
| 75 | 75 | default: | |
| 76 | 76 | // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]" | |
| 77 | + | // Use disk.Path as value and formatted string as display label | |
| 77 | 78 | for _, disk := range disks { | |
| 78 | 79 | label := disk.Path + " (" + disk.Size + ")" | |
| 79 | 80 | ||
| @@ -95,21 +96,20 @@ func CreateDiskSelectionForm(cfg *config.Config) *huh.Form { | |||
| 95 | 96 | label += " " + disk.Vendor | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 98 | - | diskOptions = append(diskOptions, label) | |
| 99 | + | diskOptions = append(diskOptions, huh.NewOption(label, disk.Path)) | |
| 99 | 100 | } | |
| 100 | 101 | } | |
| 101 | 102 | ||
| 102 | 103 | return fb.CreateForm( | |
| 103 | 104 | huh.NewGroup( | |
| 104 | - | fb.Select(SectionDiskSelection, diskOptions, &cfg.TargetDisk). | |
| 105 | + | fb.SelectWithOptions(SectionDiskSelection, diskOptions, &cfg.TargetDisk). | |
| 105 | 106 | Description(WarnDiskErase), | |
| 106 | 107 | ), | |
| 107 | 108 | ) | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 110 | 111 | // CreateOptionsForm creates installation options form with CPU detection and descriptions | |
| 111 | - | func CreateOptionsForm(cfg *config.Config) *huh.Form { | |
| 112 | - | fb := components.NewFormBuilder(false) | |
| 112 | + | func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { | |
| 113 | 113 | ||
| 114 | 114 | // Auto-detect CPU information | |
| 115 | 115 | cpuInfo, err := system.DetectCPUInfo() | |
| @@ -11,8 +11,7 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // CreatePreflightForm creates the initial configuration form with all user identity fields | 13 | // CreatePreflightForm creates the initial configuration form with all user identity fields | ||
| 14 | - | func CreatePreflightForm(cfg *config.Config) *huh.Form { | 14 | + | func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { |
| 15 | - | fb := components.NewFormBuilder(false) | |||
| 16 | 15 | ||||
| 17 | // Auto-detect timezone from API, fallback to default if detection fails | 16 | // Auto-detect timezone from API, fallback to default if detection fails | ||
| 18 | detectedTimezone := system.DetectTimezone() | 17 | detectedTimezone := system.DetectTimezone() | ||
| @@ -61,19 +60,21 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form { | |||||
| 61 | } | 60 | } | ||
| 62 | 61 | ||||
| 63 | // CreateDiskSelectionForm creates disk selection form with detailed disk information | 62 | // CreateDiskSelectionForm creates disk selection form with detailed disk information | ||
| 64 | - | func CreateDiskSelectionForm(cfg *config.Config) *huh.Form { | 63 | + | func CreateDiskSelectionForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { |
| 65 | - | fb := components.NewFormBuilder(false) | |||
| 66 | 64 | ||||
| 67 | // Detect available disks | 65 | // Detect available disks | ||
| 68 | disks, err := system.ListDisks() | 66 | disks, err := system.ListDisks() | ||
| 69 | 67 | ||||
| 70 | - | var diskOptions []string | 68 | + | var diskOptions []huh.Option[string] |
| 71 | switch { | 69 | switch { | ||
| 72 | case err != nil: | 70 | case err != nil: | ||
| 73 | - | // Fallback to empty list if detection fails | 71 | + | // Fallback to empty option if detection fails |
| 74 | - | diskOptions = []string{"No disks detected"} | 72 | + | diskOptions = []huh.Option[string]{ |
| 73 | + | huh.NewOption("No disks detected", ""), | |||
| 74 | + | } | |||
| 75 | default: | 75 | default: | ||
| 76 | // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]" | 76 | // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]" | ||
| 77 | + | // Use disk.Path as value and formatted string as display label | |||
| 77 | for _, disk := range disks { | 78 | for _, disk := range disks { | ||
| 78 | label := disk.Path + " (" + disk.Size + ")" | 79 | label := disk.Path + " (" + disk.Size + ")" | ||
| 79 | 80 | ||||
| @@ -95,21 +96,20 @@ func CreateDiskSelectionForm(cfg *config.Config) *huh.Form { | |||||
| 95 | label += " " + disk.Vendor | 96 | label += " " + disk.Vendor | ||
| 96 | } | 97 | } | ||
| 97 | 98 | ||||
| 98 | - | diskOptions = append(diskOptions, label) | 99 | + | diskOptions = append(diskOptions, huh.NewOption(label, disk.Path)) |
| 99 | } | 100 | } | ||
| 100 | } | 101 | } | ||
| 101 | 102 | ||||
| 102 | return fb.CreateForm( | 103 | return fb.CreateForm( | ||
| 103 | huh.NewGroup( | 104 | huh.NewGroup( | ||
| 104 | - | fb.Select(SectionDiskSelection, diskOptions, &cfg.TargetDisk). | 105 | + | fb.SelectWithOptions(SectionDiskSelection, diskOptions, &cfg.TargetDisk). |
| 105 | Description(WarnDiskErase), | 106 | Description(WarnDiskErase), | ||
| 106 | ), | 107 | ), | ||
| 107 | ) | 108 | ) | ||
| 108 | } | 109 | } | ||
| 109 | 110 | ||||
| 110 | // CreateOptionsForm creates installation options form with CPU detection and descriptions | 111 | // CreateOptionsForm creates installation options form with CPU detection and descriptions | ||
| 111 | - | func CreateOptionsForm(cfg *config.Config) *huh.Form { | 112 | + | func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { |
| 112 | - | fb := components.NewFormBuilder(false) | |||
| 113 | 113 | ||||
| 114 | // Auto-detect CPU information | 114 | // Auto-detect CPU information | ||
| 115 | cpuInfo, err := system.DetectCPUInfo() | 115 | cpuInfo, err := system.DetectCPUInfo() | ||
M internal/ui/model.go +33 -14
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/archup/internal/phases" | |
| 9 | 9 | "github.com/bnema/archup/internal/ui/components" | |
| 10 | 10 | "github.com/bnema/archup/internal/ui/styles" | |
| 11 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 11 | 12 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 13 | "github.com/charmbracelet/huh" | |
| 13 | 14 | "github.com/charmbracelet/lipgloss" | |
| @@ -33,8 +34,10 @@ type Model struct { | |||
| 33 | 34 | orchestrator *phases.Orchestrator | |
| 34 | 35 | config *config.Config | |
| 35 | 36 | currentForm *huh.Form | |
| 37 | + | formBuilder *components.FormBuilder | |
| 36 | 38 | output *components.OutputViewer | |
| 37 | 39 | logger *logger.Logger | |
| 40 | + | spinner spinner.Model | |
| 38 | 41 | version string | |
| 39 | 42 | width int | |
| 40 | 43 | height int | |
| @@ -44,11 +47,18 @@ type Model struct { | |||
| 44 | 47 | // NewModel creates a new installer model | |
| 45 | 48 | func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model { | |
| 46 | 49 | log.Info("UI Model created", "version", version) | |
| 50 | + | ||
| 51 | + | s := spinner.New() | |
| 52 | + | s.Spinner = spinner.Line | |
| 53 | + | s.Style = styles.SpinnerStyle | |
| 54 | + | ||
| 47 | 55 | return &Model{ | |
| 48 | 56 | state: StateShowLogo, | |
| 49 | 57 | orchestrator: orchestrator, | |
| 50 | 58 | config: cfg, | |
| 59 | + | formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg | |
| 51 | 60 | logger: log, | |
| 61 | + | spinner: s, | |
| 52 | 62 | version: version, | |
| 53 | 63 | width: 80, | |
| 54 | 64 | height: 24, | |
| @@ -58,7 +68,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger | |||
| 58 | 68 | ||
| 59 | 69 | // Init initializes the model | |
| 60 | 70 | func (m *Model) Init() tea.Cmd { | |
| 61 | - | return nil | |
| 71 | + | return m.spinner.Tick | |
| 62 | 72 | } | |
| 63 | 73 | ||
| 64 | 74 | // Update handles messages | |
| @@ -68,6 +78,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 68 | 78 | m.width = msg.Width | |
| 69 | 79 | m.height = msg.Height | |
| 70 | 80 | m.output.SetSize(msg.Width, msg.Height-10) | |
| 81 | + | // Update formBuilder width for responsive forms | |
| 82 | + | m.formBuilder = components.NewFormBuilder(false, msg.Width) | |
| 71 | 83 | ||
| 72 | 84 | case tea.KeyMsg: | |
| 73 | 85 | m.logger.Info("Key pressed", "key", msg.String(), "state", m.state) | |
| @@ -82,7 +94,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 82 | 94 | case StateShowLogo: | |
| 83 | 95 | m.logger.Info("Moving to preflight form") | |
| 84 | 96 | m.state = StatePreflightForm | |
| 85 | - | m.currentForm = CreatePreflightForm(m.config) | |
| 97 | + | m.currentForm = CreatePreflightForm(m.config, m.formBuilder) | |
| 86 | 98 | return m, m.currentForm.Init() | |
| 87 | 99 | ||
| 88 | 100 | case StateConfirmation: | |
| @@ -96,17 +108,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 96 | 108 | switch m.state { | |
| 97 | 109 | case StateDiskForm: | |
| 98 | 110 | m.state = StatePreflightForm | |
| 99 | - | m.currentForm = CreatePreflightForm(m.config) | |
| 111 | + | m.currentForm = CreatePreflightForm(m.config, m.formBuilder) | |
| 100 | 112 | return m, m.currentForm.Init() | |
| 101 | 113 | ||
| 102 | 114 | case StateOptionsForm: | |
| 103 | 115 | m.state = StateDiskForm | |
| 104 | - | m.currentForm = CreateDiskSelectionForm(m.config) | |
| 116 | + | m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder) | |
| 105 | 117 | return m, m.currentForm.Init() | |
| 106 | 118 | ||
| 107 | 119 | case StateConfirmation: | |
| 108 | 120 | m.state = StateOptionsForm | |
| 109 | - | m.currentForm = CreateOptionsForm(m.config) | |
| 121 | + | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) | |
| 110 | 122 | return m, m.currentForm.Init() | |
| 111 | 123 | } | |
| 112 | 124 | } | |
| @@ -170,8 +182,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 170 | 182 | return m, cmd | |
| 171 | 183 | } | |
| 172 | 184 | case StateExecuting: | |
| 173 | - | // Update output viewer | |
| 174 | - | return m, m.output.Update(msg) | |
| 185 | + | // Update spinner and output viewer | |
| 186 | + | var cmd tea.Cmd | |
| 187 | + | m.spinner, cmd = m.spinner.Update(msg) | |
| 188 | + | return m, tea.Batch(cmd, m.output.Update(msg)) | |
| 175 | 189 | } | |
| 176 | 190 | ||
| 177 | 191 | return m, nil | |
| @@ -198,7 +212,8 @@ func (m *Model) View() string { | |||
| 198 | 212 | return m.renderConfirmation() | |
| 199 | 213 | ||
| 200 | 214 | case StateExecuting: | |
| 201 | - | return styles.TitleStyle.Render("Installing ArchUp...") + "\n\n" + m.output.View() | |
| 215 | + | title := m.spinner.View() + " Installation in progress..." | |
| 216 | + | return styles.TitleStyle.Render(title) + "\n\n" + m.output.View() | |
| 202 | 217 | ||
| 203 | 218 | case StateError: | |
| 204 | 219 | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | |
| @@ -229,13 +244,13 @@ func (m *Model) handleFormComplete() tea.Cmd { | |||
| 229 | 244 | } | |
| 230 | 245 | ||
| 231 | 246 | m.state = StateDiskForm | |
| 232 | - | m.currentForm = CreateDiskSelectionForm(m.config) | |
| 247 | + | m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder) | |
| 233 | 248 | return m.currentForm.Init() | |
| 234 | 249 | ||
| 235 | 250 | case StateDiskForm: | |
| 236 | 251 | m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk) | |
| 237 | 252 | m.state = StateOptionsForm | |
| 238 | - | m.currentForm = CreateOptionsForm(m.config) | |
| 253 | + | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) | |
| 239 | 254 | return m.currentForm.Init() | |
| 240 | 255 | ||
| 241 | 256 | case StateOptionsForm: | |
| @@ -270,10 +285,14 @@ func (m *Model) renderConfirmation() string { | |||
| 270 | 285 | ||
| 271 | 286 | // executeNextPhase executes the next pending phase | |
| 272 | 287 | func (m *Model) executeNextPhase() tea.Cmd { | |
| 273 | - | return func() tea.Msg { | |
| 274 | - | err := m.orchestrator.ExecuteNext() | |
| 275 | - | return phaseCompleteMsg{err: err} | |
| 276 | - | } | |
| 288 | + | // Execute the phase in the background AND start listening for progress updates | |
| 289 | + | return tea.Batch( | |
| 290 | + | func() tea.Msg { | |
| 291 | + | err := m.orchestrator.ExecuteNext() | |
| 292 | + | return phaseCompleteMsg{err: err} | |
| 293 | + | }, | |
| 294 | + | waitForProgress(m.orchestrator.ProgressChannel()), | |
| 295 | + | ) | |
| 277 | 296 | } | |
| 278 | 297 | ||
| 279 | 298 | // waitForProgress waits for progress updates | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "github.com/bnema/archup/internal/phases" | 8 | "github.com/bnema/archup/internal/phases" | ||
| 9 | "github.com/bnema/archup/internal/ui/components" | 9 | "github.com/bnema/archup/internal/ui/components" | ||
| 10 | "github.com/bnema/archup/internal/ui/styles" | 10 | "github.com/bnema/archup/internal/ui/styles" | ||
| 11 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 11 | tea "github.com/charmbracelet/bubbletea" | 12 | tea "github.com/charmbracelet/bubbletea" | ||
| 12 | "github.com/charmbracelet/huh" | 13 | "github.com/charmbracelet/huh" | ||
| 13 | "github.com/charmbracelet/lipgloss" | 14 | "github.com/charmbracelet/lipgloss" | ||
| @@ -33,8 +34,10 @@ type Model struct { | |||||
| 33 | orchestrator *phases.Orchestrator | 34 | orchestrator *phases.Orchestrator | ||
| 34 | config *config.Config | 35 | config *config.Config | ||
| 35 | currentForm *huh.Form | 36 | currentForm *huh.Form | ||
| 37 | + | formBuilder *components.FormBuilder | |||
| 36 | output *components.OutputViewer | 38 | output *components.OutputViewer | ||
| 37 | logger *logger.Logger | 39 | logger *logger.Logger | ||
| 40 | + | spinner spinner.Model | |||
| 38 | version string | 41 | version string | ||
| 39 | width int | 42 | width int | ||
| 40 | height int | 43 | height int | ||
| @@ -44,11 +47,18 @@ type Model struct { | |||||
| 44 | // NewModel creates a new installer model | 47 | // NewModel creates a new installer model | ||
| 45 | func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model { | 48 | func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model { | ||
| 46 | log.Info("UI Model created", "version", version) | 49 | log.Info("UI Model created", "version", version) | ||
| 50 | + | ||||
| 51 | + | s := spinner.New() | |||
| 52 | + | s.Spinner = spinner.Line | |||
| 53 | + | s.Style = styles.SpinnerStyle | |||
| 54 | + | ||||
| 47 | return &Model{ | 55 | return &Model{ | ||
| 48 | state: StateShowLogo, | 56 | state: StateShowLogo, | ||
| 49 | orchestrator: orchestrator, | 57 | orchestrator: orchestrator, | ||
| 50 | config: cfg, | 58 | config: cfg, | ||
| 59 | + | formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg | |||
| 51 | logger: log, | 60 | logger: log, | ||
| 61 | + | spinner: s, | |||
| 52 | version: version, | 62 | version: version, | ||
| 53 | width: 80, | 63 | width: 80, | ||
| 54 | height: 24, | 64 | height: 24, | ||
| @@ -58,7 +68,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger | |||||
| 58 | 68 | ||||
| 59 | // Init initializes the model | 69 | // Init initializes the model | ||
| 60 | func (m *Model) Init() tea.Cmd { | 70 | func (m *Model) Init() tea.Cmd { | ||
| 61 | - | return nil | 71 | + | return m.spinner.Tick |
| 62 | } | 72 | } | ||
| 63 | 73 | ||||
| 64 | // Update handles messages | 74 | // Update handles messages | ||
| @@ -68,6 +78,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 68 | m.width = msg.Width | 78 | m.width = msg.Width | ||
| 69 | m.height = msg.Height | 79 | m.height = msg.Height | ||
| 70 | m.output.SetSize(msg.Width, msg.Height-10) | 80 | m.output.SetSize(msg.Width, msg.Height-10) | ||
| 81 | + | // Update formBuilder width for responsive forms | |||
| 82 | + | m.formBuilder = components.NewFormBuilder(false, msg.Width) | |||
| 71 | 83 | ||||
| 72 | case tea.KeyMsg: | 84 | case tea.KeyMsg: | ||
| 73 | m.logger.Info("Key pressed", "key", msg.String(), "state", m.state) | 85 | m.logger.Info("Key pressed", "key", msg.String(), "state", m.state) | ||
| @@ -82,7 +94,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 82 | case StateShowLogo: | 94 | case StateShowLogo: | ||
| 83 | m.logger.Info("Moving to preflight form") | 95 | m.logger.Info("Moving to preflight form") | ||
| 84 | m.state = StatePreflightForm | 96 | m.state = StatePreflightForm | ||
| 85 | - | m.currentForm = CreatePreflightForm(m.config) | 97 | + | m.currentForm = CreatePreflightForm(m.config, m.formBuilder) |
| 86 | return m, m.currentForm.Init() | 98 | return m, m.currentForm.Init() | ||
| 87 | 99 | ||||
| 88 | case StateConfirmation: | 100 | case StateConfirmation: | ||
| @@ -96,17 +108,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 96 | switch m.state { | 108 | switch m.state { | ||
| 97 | case StateDiskForm: | 109 | case StateDiskForm: | ||
| 98 | m.state = StatePreflightForm | 110 | m.state = StatePreflightForm | ||
| 99 | - | m.currentForm = CreatePreflightForm(m.config) | 111 | + | m.currentForm = CreatePreflightForm(m.config, m.formBuilder) |
| 100 | return m, m.currentForm.Init() | 112 | return m, m.currentForm.Init() | ||
| 101 | 113 | ||||
| 102 | case StateOptionsForm: | 114 | case StateOptionsForm: | ||
| 103 | m.state = StateDiskForm | 115 | m.state = StateDiskForm | ||
| 104 | - | m.currentForm = CreateDiskSelectionForm(m.config) | 116 | + | m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder) |
| 105 | return m, m.currentForm.Init() | 117 | return m, m.currentForm.Init() | ||
| 106 | 118 | ||||
| 107 | case StateConfirmation: | 119 | case StateConfirmation: | ||
| 108 | m.state = StateOptionsForm | 120 | m.state = StateOptionsForm | ||
| 109 | - | m.currentForm = CreateOptionsForm(m.config) | 121 | + | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) |
| 110 | return m, m.currentForm.Init() | 122 | return m, m.currentForm.Init() | ||
| 111 | } | 123 | } | ||
| 112 | } | 124 | } | ||
| @@ -170,8 +182,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 170 | return m, cmd | 182 | return m, cmd | ||
| 171 | } | 183 | } | ||
| 172 | case StateExecuting: | 184 | case StateExecuting: | ||
| 173 | - | // Update output viewer | 185 | + | // Update spinner and output viewer |
| 174 | - | return m, m.output.Update(msg) | 186 | + | var cmd tea.Cmd |
| 187 | + | m.spinner, cmd = m.spinner.Update(msg) | |||
| 188 | + | return m, tea.Batch(cmd, m.output.Update(msg)) | |||
| 175 | } | 189 | } | ||
| 176 | 190 | ||||
| 177 | return m, nil | 191 | return m, nil | ||
| @@ -198,7 +212,8 @@ func (m *Model) View() string { | |||||
| 198 | return m.renderConfirmation() | 212 | return m.renderConfirmation() | ||
| 199 | 213 | ||||
| 200 | case StateExecuting: | 214 | case StateExecuting: | ||
| 201 | - | return styles.TitleStyle.Render("Installing ArchUp...") + "\n\n" + m.output.View() | 215 | + | title := m.spinner.View() + " Installation in progress..." |
| 216 | + | return styles.TitleStyle.Render(title) + "\n\n" + m.output.View() | |||
| 202 | 217 | ||||
| 203 | case StateError: | 218 | case StateError: | ||
| 204 | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | 219 | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | ||
| @@ -229,13 +244,13 @@ func (m *Model) handleFormComplete() tea.Cmd { | |||||
| 229 | } | 244 | } | ||
| 230 | 245 | ||||
| 231 | m.state = StateDiskForm | 246 | m.state = StateDiskForm | ||
| 232 | - | m.currentForm = CreateDiskSelectionForm(m.config) | 247 | + | m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder) |
| 233 | return m.currentForm.Init() | 248 | return m.currentForm.Init() | ||
| 234 | 249 | ||||
| 235 | case StateDiskForm: | 250 | case StateDiskForm: | ||
| 236 | m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk) | 251 | m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk) | ||
| 237 | m.state = StateOptionsForm | 252 | m.state = StateOptionsForm | ||
| 238 | - | m.currentForm = CreateOptionsForm(m.config) | 253 | + | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) |
| 239 | return m.currentForm.Init() | 254 | return m.currentForm.Init() | ||
| 240 | 255 | ||||
| 241 | case StateOptionsForm: | 256 | case StateOptionsForm: | ||
| @@ -270,10 +285,14 @@ func (m *Model) renderConfirmation() string { | |||||
| 270 | 285 | ||||
| 271 | // executeNextPhase executes the next pending phase | 286 | // executeNextPhase executes the next pending phase | ||
| 272 | func (m *Model) executeNextPhase() tea.Cmd { | 287 | func (m *Model) executeNextPhase() tea.Cmd { | ||
| 273 | - | return func() tea.Msg { | 288 | + | // Execute the phase in the background AND start listening for progress updates |
| 274 | - | err := m.orchestrator.ExecuteNext() | 289 | + | return tea.Batch( |
| 275 | - | return phaseCompleteMsg{err: err} | 290 | + | func() tea.Msg { |
| 276 | - | } | 291 | + | err := m.orchestrator.ExecuteNext() |
| 292 | + | return phaseCompleteMsg{err: err} | |||
| 293 | + | }, | |||
| 294 | + | waitForProgress(m.orchestrator.ProgressChannel()), | |||
| 295 | + | ) | |||
| 277 | } | 296 | } | ||
| 278 | 297 | ||||
| 279 | // waitForProgress waits for progress updates | 298 | // waitForProgress waits for progress updates | ||
M internal/ui/styles/theme.go +47 -29
| @@ -260,44 +260,62 @@ func RenderHelp(bindings map[string]string) string { | |||
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | // HuhTheme returns a custom huh.Theme matching the bleu color scheme | |
| 263 | + | // Uses ThemeBase16 as foundation for maximum TTY compatibility (QEMU console, etc) | |
| 263 | 264 | func HuhTheme() *huh.Theme { | |
| 264 | - | theme := huh.ThemeBase() | |
| 265 | + | // Start with Base16 theme for limited terminal support | |
| 266 | + | theme := huh.ThemeBase16() | |
| 267 | + | ||
| 268 | + | // Customize with bleu theme colors using ANSI 16 codes | |
| 269 | + | // ANSI Color mapping for bleu theme: | |
| 270 | + | // 0=black, 4=blue, 6=cyan, 7=white, 8=grey, 10=bright green, 12=bright blue, 14=bright cyan, 15=bright white | |
| 265 | 271 | ||
| 266 | 272 | // Form and Group styles | |
| 267 | - | theme.Form.Base = lipgloss.NewStyle() | |
| 268 | - | theme.Group.Base = lipgloss.NewStyle() | |
| 269 | - | theme.Group.Title = TitleStyle | |
| 270 | - | theme.Group.Description = SubtitleStyle | |
| 273 | + | theme.Group.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan | |
| 274 | + | theme.Group.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue | |
| 271 | 275 | ||
| 272 | 276 | // Focused field styles | |
| 273 | - | theme.Focused.Base = lipgloss.NewStyle() | |
| 274 | - | theme.Focused.Title = lipgloss.NewStyle().Foreground(ColorBrightCyan).Bold(true) | |
| 275 | - | theme.Focused.Description = lipgloss.NewStyle().Foreground(ColorPureBlue) | |
| 276 | - | theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ") | |
| 277 | - | theme.Focused.Option = lipgloss.NewStyle().Foreground(ColorPrimaryText) | |
| 278 | - | theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ") | |
| 279 | - | theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorBrightCyan) | |
| 280 | - | theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(ColorSuccessGreen).SetString("✓ ") | |
| 281 | - | theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorPrimaryText) | |
| 282 | - | theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("○ ") | |
| 283 | - | theme.Focused.FocusedButton = lipgloss.NewStyle().Foreground(ColorPureWhite).Background(ColorOceanBlue).Padding(0, 2).Bold(true) | |
| 284 | - | theme.Focused.BlurredButton = lipgloss.NewStyle().Foreground(ColorDimmedText).Padding(0, 2) | |
| 285 | - | ||
| 286 | - | // Blurred field styles | |
| 287 | - | theme.Blurred.Base = lipgloss.NewStyle() | |
| 288 | - | theme.Blurred.Title = lipgloss.NewStyle().Foreground(ColorDimmedText) | |
| 289 | - | theme.Blurred.Description = lipgloss.NewStyle().Foreground(ColorDimmedText) | |
| 290 | - | theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("> ") | |
| 291 | - | theme.Blurred.Option = lipgloss.NewStyle().Foreground(ColorDimmedText) | |
| 277 | + | theme.Focused.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan | |
| 278 | + | theme.Focused.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue | |
| 279 | + | theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ") | |
| 280 | + | theme.Focused.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White | |
| 281 | + | theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ") | |
| 282 | + | theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan | |
| 283 | + | theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).SetString("✓ ") // Bright green | |
| 284 | + | theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White | |
| 285 | + | theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("○ ") // Grey | |
| 286 | + | ||
| 287 | + | // Button styles with explicit background colors for TTY compatibility | |
| 288 | + | // Active/focused button (currently selected - e.g., "Yes" when on Yes) | |
| 289 | + | theme.Focused.FocusedButton = lipgloss.NewStyle(). | |
| 290 | + | Foreground(lipgloss.Color("15")). // Bright white text | |
| 291 | + | Background(lipgloss.Color("4")). // Blue background | |
| 292 | + | Padding(0, 2). | |
| 293 | + | Bold(true) | |
| 294 | + | ||
| 295 | + | // Inactive/blurred button (not selected - e.g., "No" when on Yes) | |
| 296 | + | theme.Focused.BlurredButton = lipgloss.NewStyle(). | |
| 297 | + | Foreground(lipgloss.Color("7")). // White text | |
| 298 | + | Background(lipgloss.Color("0")). // Black background | |
| 299 | + | Padding(0, 2) | |
| 300 | + | ||
| 301 | + | // Blurred field styles (when entire field loses focus) | |
| 302 | + | theme.Blurred.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |
| 303 | + | theme.Blurred.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |
| 304 | + | theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("> ") | |
| 305 | + | theme.Blurred.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |
| 306 | + | ||
| 307 | + | // Override buttons in Blurred state too (inherited from Focused in Base16) | |
| 308 | + | theme.Blurred.FocusedButton = theme.Focused.FocusedButton | |
| 309 | + | theme.Blurred.BlurredButton = theme.Focused.BlurredButton | |
| 292 | 310 | ||
| 293 | 311 | // Text input styles | |
| 294 | - | theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(ColorBrightCyan) | |
| 295 | - | theme.Focused.TextInput.Placeholder = PlaceholderStyle | |
| 296 | - | theme.Focused.TextInput.Prompt = PromptStyle | |
| 312 | + | theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan | |
| 313 | + | theme.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |
| 314 | + | theme.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan | |
| 297 | 315 | ||
| 298 | 316 | // Error styles | |
| 299 | - | theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(ColorSoftRed).SetString("✗ ") | |
| 300 | - | theme.Focused.ErrorMessage = ErrorStyle | |
| 317 | + | theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).SetString("✗ ") // Bright red | |
| 318 | + | theme.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Bright red | |
| 301 | 319 | ||
| 302 | 320 | return theme | |
| 303 | 321 | } | |
| @@ -260,44 +260,62 @@ func RenderHelp(bindings map[string]string) string { | |||||
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | // HuhTheme returns a custom huh.Theme matching the bleu color scheme | 262 | // HuhTheme returns a custom huh.Theme matching the bleu color scheme | ||
| 263 | + | // Uses ThemeBase16 as foundation for maximum TTY compatibility (QEMU console, etc) | |||
| 263 | func HuhTheme() *huh.Theme { | 264 | func HuhTheme() *huh.Theme { | ||
| 264 | - | theme := huh.ThemeBase() | 265 | + | // Start with Base16 theme for limited terminal support |
| 266 | + | theme := huh.ThemeBase16() | |||
| 267 | + | ||||
| 268 | + | // Customize with bleu theme colors using ANSI 16 codes | |||
| 269 | + | // ANSI Color mapping for bleu theme: | |||
| 270 | + | // 0=black, 4=blue, 6=cyan, 7=white, 8=grey, 10=bright green, 12=bright blue, 14=bright cyan, 15=bright white | |||
| 265 | 271 | ||||
| 266 | // Form and Group styles | 272 | // Form and Group styles | ||
| 267 | - | theme.Form.Base = lipgloss.NewStyle() | 273 | + | theme.Group.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan |
| 268 | - | theme.Group.Base = lipgloss.NewStyle() | 274 | + | theme.Group.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue |
| 269 | - | theme.Group.Title = TitleStyle | |||
| 270 | - | theme.Group.Description = SubtitleStyle | |||
| 271 | 275 | ||||
| 272 | // Focused field styles | 276 | // Focused field styles | ||
| 273 | - | theme.Focused.Base = lipgloss.NewStyle() | 277 | + | theme.Focused.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan |
| 274 | - | theme.Focused.Title = lipgloss.NewStyle().Foreground(ColorBrightCyan).Bold(true) | 278 | + | theme.Focused.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue |
| 275 | - | theme.Focused.Description = lipgloss.NewStyle().Foreground(ColorPureBlue) | 279 | + | theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ") |
| 276 | - | theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ") | 280 | + | theme.Focused.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White |
| 277 | - | theme.Focused.Option = lipgloss.NewStyle().Foreground(ColorPrimaryText) | 281 | + | theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ") |
| 278 | - | theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ") | 282 | + | theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan |
| 279 | - | theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorBrightCyan) | 283 | + | theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).SetString("✓ ") // Bright green |
| 280 | - | theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(ColorSuccessGreen).SetString("✓ ") | 284 | + | theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White |
| 281 | - | theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorPrimaryText) | 285 | + | theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("○ ") // Grey |
| 282 | - | theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("○ ") | 286 | + | |
| 283 | - | theme.Focused.FocusedButton = lipgloss.NewStyle().Foreground(ColorPureWhite).Background(ColorOceanBlue).Padding(0, 2).Bold(true) | 287 | + | // Button styles with explicit background colors for TTY compatibility |
| 284 | - | theme.Focused.BlurredButton = lipgloss.NewStyle().Foreground(ColorDimmedText).Padding(0, 2) | 288 | + | // Active/focused button (currently selected - e.g., "Yes" when on Yes) |
| 285 | - | 289 | + | theme.Focused.FocusedButton = lipgloss.NewStyle(). | |
| 286 | - | // Blurred field styles | 290 | + | Foreground(lipgloss.Color("15")). // Bright white text |
| 287 | - | theme.Blurred.Base = lipgloss.NewStyle() | 291 | + | Background(lipgloss.Color("4")). // Blue background |
| 288 | - | theme.Blurred.Title = lipgloss.NewStyle().Foreground(ColorDimmedText) | 292 | + | Padding(0, 2). |
| 289 | - | theme.Blurred.Description = lipgloss.NewStyle().Foreground(ColorDimmedText) | 293 | + | Bold(true) |
| 290 | - | theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("> ") | 294 | + | |
| 291 | - | theme.Blurred.Option = lipgloss.NewStyle().Foreground(ColorDimmedText) | 295 | + | // Inactive/blurred button (not selected - e.g., "No" when on Yes) |
| 296 | + | theme.Focused.BlurredButton = lipgloss.NewStyle(). | |||
| 297 | + | Foreground(lipgloss.Color("7")). // White text | |||
| 298 | + | Background(lipgloss.Color("0")). // Black background | |||
| 299 | + | Padding(0, 2) | |||
| 300 | + | ||||
| 301 | + | // Blurred field styles (when entire field loses focus) | |||
| 302 | + | theme.Blurred.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 303 | + | theme.Blurred.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 304 | + | theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("> ") | |||
| 305 | + | theme.Blurred.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 306 | + | ||||
| 307 | + | // Override buttons in Blurred state too (inherited from Focused in Base16) | |||
| 308 | + | theme.Blurred.FocusedButton = theme.Focused.FocusedButton | |||
| 309 | + | theme.Blurred.BlurredButton = theme.Focused.BlurredButton | |||
| 292 | 310 | ||||
| 293 | // Text input styles | 311 | // Text input styles | ||
| 294 | - | theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(ColorBrightCyan) | 312 | + | theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan |
| 295 | - | theme.Focused.TextInput.Placeholder = PlaceholderStyle | 313 | + | theme.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey |
| 296 | - | theme.Focused.TextInput.Prompt = PromptStyle | 314 | + | theme.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan |
| 297 | 315 | ||||
| 298 | // Error styles | 316 | // Error styles | ||
| 299 | - | theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(ColorSoftRed).SetString("✗ ") | 317 | + | theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).SetString("✗ ") // Bright red |
| 300 | - | theme.Focused.ErrorMessage = ErrorStyle | 318 | + | theme.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Bright red |
| 301 | 319 | ||||
| 302 | return theme | 320 | return theme | ||
| 303 | } | 321 | } | ||