detached fc43554

chore(cleanup): remove wizard, bleu-theme remnants, deprecated shell configs and verify

commit
fc43554ee57dc06f2d8b377685f9ea768e5f7eee
author
bnema · · 2026-02-26 21:20 UTC
parent
4d54bdb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +0 -2021
D cmd/wizard.go +0 -65
@@ -1,65 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/infrastructure/executor"
9- "github.com/bnema/archup/internal/infrastructure/filesystem"
10- infralogger "github.com/bnema/archup/internal/infrastructure/logger"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/wizard/application/services"
13- "github.com/bnema/archup/internal/wizard/interfaces/tui"
14- tea "github.com/charmbracelet/bubbletea"
15- "github.com/spf13/cobra"
16-)
17-
18-func init() {
19- rootCmd.AddCommand(newWizardCmd())
20-}
21-
22-func newWizardCmd() *cobra.Command {
23- var dryRun bool
24- cmd := &cobra.Command{
25- Use: "wizard",
26- Short: "Run post-boot desktop wizard",
27- RunE: func(cmd *cobra.Command, args []string) error {
28- return runWizard(dryRun)
29- },
30- }
31- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show TUI but don't execute commands")
32- return cmd
33-}
34-
35-func runWizard(dryRun bool) error {
36- oldLog, err := logger.New(config.DefaultLogPath, dryRun)
37- if err != nil {
38- return fmt.Errorf("failed to create logger: %w", err)
39- }
40- defer func() {
41- if err := oldLog.Close(); err != nil {
42- fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
43- }
44- }()
45-
46- if dryRun {
47- oldLog.Info("Running wizard in DRY-RUN mode - commands will be logged but not executed")
48- }
49-
50- slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
51- fsAdapter := &filesystem.LocalFileSystem{}
52- shellExec := executor.NewShellExecutor(slogAdapter)
53-
54- wizardService := services.NewWizardService(fsAdapter, shellExec, slogAdapter)
55- app := tui.NewApp(wizardService, slogAdapter)
56-
57- oldLog.Info("Starting wizard TUI")
58- p := tea.NewProgram(app, tea.WithAltScreen())
59- if _, err := p.Run(); err != nil {
60- oldLog.Error("Error running wizard", "error", err)
61- return fmt.Errorf("run wizard: %w", err)
62- }
63-
64- return nil
65-}
@@ -1,65 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/infrastructure/executor"
9- "github.com/bnema/archup/internal/infrastructure/filesystem"
10- infralogger "github.com/bnema/archup/internal/infrastructure/logger"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/wizard/application/services"
13- "github.com/bnema/archup/internal/wizard/interfaces/tui"
14- tea "github.com/charmbracelet/bubbletea"
15- "github.com/spf13/cobra"
16-)
17-
18-func init() {
19- rootCmd.AddCommand(newWizardCmd())
20-}
21-
22-func newWizardCmd() *cobra.Command {
23- var dryRun bool
24- cmd := &cobra.Command{
25- Use: "wizard",
26- Short: "Run post-boot desktop wizard",
27- RunE: func(cmd *cobra.Command, args []string) error {
28- return runWizard(dryRun)
29- },
30- }
31- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show TUI but don't execute commands")
32- return cmd
33-}
34-
35-func runWizard(dryRun bool) error {
36- oldLog, err := logger.New(config.DefaultLogPath, dryRun)
37- if err != nil {
38- return fmt.Errorf("failed to create logger: %w", err)
39- }
40- defer func() {
41- if err := oldLog.Close(); err != nil {
42- fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
43- }
44- }()
45-
46- if dryRun {
47- oldLog.Info("Running wizard in DRY-RUN mode - commands will be logged but not executed")
48- }
49-
50- slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
51- fsAdapter := &filesystem.LocalFileSystem{}
52- shellExec := executor.NewShellExecutor(slogAdapter)
53-
54- wizardService := services.NewWizardService(fsAdapter, shellExec, slogAdapter)
55- app := tui.NewApp(wizardService, slogAdapter)
56-
57- oldLog.Info("Starting wizard TUI")
58- p := tea.NewProgram(app, tea.WithAltScreen())
59- if _, err := p.Run(); err != nil {
60- oldLog.Error("Error running wizard", "error", err)
61- return fmt.Errorf("run wizard: %w", err)
62- }
63-
64- return nil
65-}
M internal/phases/postinstall.go +0 -102
@@ -484,37 +484,6 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
484484 return fmt.Errorf("failed to set ownership: %w", err)
485485 }
486486
487- // Clone bleu-theme repository
488- p.SendOutput(progressChan, "Cloning bleu-theme repository...")
489- themesDir := filepath.Join(userHome, ".local", "share", "archup", "themes")
490- bleuThemeDir := filepath.Join(themesDir, "bleu")
491- currentDir := filepath.Join(userHome, ".local", "share", "archup", "current")
492-
493- cloneCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && git clone https://github.com/bnema/bleu-theme.git %s'",
494- username, themesDir, bleuThemeDir)
495- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cloneCmd); {
496- case err != nil:
497- p.SendOutput(progressChan, "[WARN] Failed to clone bleu-theme repository")
498- default:
499- p.SendOutput(progressChan, "[OK] Bleu-theme repository cloned")
500- }
501-
502- // Create current theme symlink
503- symlinkCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && ln -snf %s %s/theme'",
504- username, currentDir, bleuThemeDir, currentDir)
505- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, symlinkCmd); {
506- case err != nil:
507- p.SendOutput(progressChan, "[WARN] Failed to create theme symlink")
508- }
509-
510- // Apply themes to CLI tools
511- p.SendOutput(progressChan, "Applying themes to CLI tools...")
512- switch err := p.applyThemes(progressChan, username, userHome); {
513- case err != nil:
514- // Non-fatal, log and continue
515- p.SendOutput(progressChan, "[WARN] Some themes failed to apply")
516- }
517-
518487 // Configure git delta
519488 gitCommands := []string{
520489 fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
@@ -546,7 +515,6 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
546515 config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
547516 config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
548517 config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
549- // Note: starship.toml is now copied from bleu-theme in applyThemes()
550518 }
551519
552520 for template, dest := range templates {
@@ -581,76 +549,6 @@ func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
581549 return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)
582550 }
583551
584-// applyThemes applies bleu-theme to CLI tools
585-func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, username, userHome string) error {
586- themeDir := filepath.Join(userHome, ".local", "share", "archup", "current", "theme")
587- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
588-
589- // Starship theme - copy to archup default location
590- p.SendOutput(progressChan, "Configuring starship theme...")
591- starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",
592- username, themeDir, archupDefault)
593- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, starshipCmd); err != nil {
594- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure starship theme: %v", err))
595- }
596-
597- // Eza theme
598- p.SendOutput(progressChan, "Configuring eza theme...")
599- ezaConfigDir := fmt.Sprintf("%s/.config/eza", userHome)
600- ezaCommands := []string{
601- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, ezaConfigDir),
602- fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),
603- }
604- for _, cmd := range ezaCommands {
605- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
606- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure eza theme: %v", err))
607- }
608- }
609-
610- // Bat theme
611- p.SendOutput(progressChan, "Configuring bat theme...")
612- batThemeDir := fmt.Sprintf("%s/.config/bat/themes", userHome)
613- batCommands := []string{
614- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, batThemeDir),
615- fmt.Sprintf("su - %s -c 'cp %s/bat/bleu.tmTheme %s/'", username, themeDir, batThemeDir),
616- fmt.Sprintf("su - %s -c 'bat cache --build'", username),
617- }
618- for _, cmd := range batCommands {
619- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
620- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure bat theme: %v", err))
621- }
622- }
623-
624- // Btop theme
625- p.SendOutput(progressChan, "Configuring btop theme...")
626- btopThemeDir := fmt.Sprintf("%s/.config/btop/themes", userHome)
627- btopCommands := []string{
628- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, btopThemeDir),
629- fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),
630- }
631- for _, cmd := range btopCommands {
632- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
633- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure btop theme: %v", err))
634- }
635- }
636-
637- // Yazi theme
638- p.SendOutput(progressChan, "Configuring yazi theme...")
639- yaziFlavorDir := fmt.Sprintf("%s/.config/yazi/flavors/bleu", userHome)
640- yaziCommands := []string{
641- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, yaziFlavorDir),
642- fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),
643- }
644- for _, cmd := range yaziCommands {
645- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
646- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure yazi theme: %v", err))
647- }
648- }
649-
650- p.SendOutput(progressChan, "[OK] CLI themes configured")
651- return nil
652-}
653-
654552 // verifyInstallation performs comprehensive verification
655553 func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {
656554 p.SendOutput(progressChan, "Verifying installation components...")
@@ -484,37 +484,6 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
484 return fmt.Errorf("failed to set ownership: %w", err)484 return fmt.Errorf("failed to set ownership: %w", err)
485 }485 }
486 486
487- // Clone bleu-theme repository
488- p.SendOutput(progressChan, "Cloning bleu-theme repository...")
489- themesDir := filepath.Join(userHome, ".local", "share", "archup", "themes")
490- bleuThemeDir := filepath.Join(themesDir, "bleu")
491- currentDir := filepath.Join(userHome, ".local", "share", "archup", "current")
492-
493- cloneCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && git clone https://github.com/bnema/bleu-theme.git %s'",
494- username, themesDir, bleuThemeDir)
495- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cloneCmd); {
496- case err != nil:
497- p.SendOutput(progressChan, "[WARN] Failed to clone bleu-theme repository")
498- default:
499- p.SendOutput(progressChan, "[OK] Bleu-theme repository cloned")
500- }
501-
502- // Create current theme symlink
503- symlinkCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && ln -snf %s %s/theme'",
504- username, currentDir, bleuThemeDir, currentDir)
505- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, symlinkCmd); {
506- case err != nil:
507- p.SendOutput(progressChan, "[WARN] Failed to create theme symlink")
508- }
509-
510- // Apply themes to CLI tools
511- p.SendOutput(progressChan, "Applying themes to CLI tools...")
512- switch err := p.applyThemes(progressChan, username, userHome); {
513- case err != nil:
514- // Non-fatal, log and continue
515- p.SendOutput(progressChan, "[WARN] Some themes failed to apply")
516- }
517-
518 // Configure git delta487 // Configure git delta
519 gitCommands := []string{488 gitCommands := []string{
520 fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),489 fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
@@ -546,7 +515,6 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
546 config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),515 config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
547 config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),516 config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
548 config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),517 config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
549- // Note: starship.toml is now copied from bleu-theme in applyThemes()
550 }518 }
551 519
552 for template, dest := range templates {520 for template, dest := range templates {
@@ -581,76 +549,6 @@ func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
581 return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)549 return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)
582 }550 }
583 551
584-// applyThemes applies bleu-theme to CLI tools
585-func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, username, userHome string) error {
586- themeDir := filepath.Join(userHome, ".local", "share", "archup", "current", "theme")
587- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
588-
589- // Starship theme - copy to archup default location
590- p.SendOutput(progressChan, "Configuring starship theme...")
591- starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",
592- username, themeDir, archupDefault)
593- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, starshipCmd); err != nil {
594- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure starship theme: %v", err))
595- }
596-
597- // Eza theme
598- p.SendOutput(progressChan, "Configuring eza theme...")
599- ezaConfigDir := fmt.Sprintf("%s/.config/eza", userHome)
600- ezaCommands := []string{
601- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, ezaConfigDir),
602- fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),
603- }
604- for _, cmd := range ezaCommands {
605- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
606- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure eza theme: %v", err))
607- }
608- }
609-
610- // Bat theme
611- p.SendOutput(progressChan, "Configuring bat theme...")
612- batThemeDir := fmt.Sprintf("%s/.config/bat/themes", userHome)
613- batCommands := []string{
614- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, batThemeDir),
615- fmt.Sprintf("su - %s -c 'cp %s/bat/bleu.tmTheme %s/'", username, themeDir, batThemeDir),
616- fmt.Sprintf("su - %s -c 'bat cache --build'", username),
617- }
618- for _, cmd := range batCommands {
619- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
620- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure bat theme: %v", err))
621- }
622- }
623-
624- // Btop theme
625- p.SendOutput(progressChan, "Configuring btop theme...")
626- btopThemeDir := fmt.Sprintf("%s/.config/btop/themes", userHome)
627- btopCommands := []string{
628- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, btopThemeDir),
629- fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),
630- }
631- for _, cmd := range btopCommands {
632- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
633- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure btop theme: %v", err))
634- }
635- }
636-
637- // Yazi theme
638- p.SendOutput(progressChan, "Configuring yazi theme...")
639- yaziFlavorDir := fmt.Sprintf("%s/.config/yazi/flavors/bleu", userHome)
640- yaziCommands := []string{
641- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, yaziFlavorDir),
642- fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),
643- }
644- for _, cmd := range yaziCommands {
645- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
646- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure yazi theme: %v", err))
647- }
648- }
649-
650- p.SendOutput(progressChan, "[OK] CLI themes configured")
651- return nil
652-}
653-
654 // verifyInstallation performs comprehensive verification552 // verifyInstallation performs comprehensive verification
655 func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {553 func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {
656 p.SendOutput(progressChan, "Verifying installation components...")554 p.SendOutput(progressChan, "Verifying installation components...")
D internal/shell/themes.go +0 -17
@@ -1,17 +0,0 @@
1-package shell
2-
3-// ThemeResult holds the result of theme application
4-type ThemeResult struct {
5- Applied int
6- Failed []string
7- Warnings []string
8-}
9-
10-// applyThemes is a no-op. Theme setup is handled by cli-tools.sh at first boot.
11-func (c *Configurator) applyThemes(username, userHome string) ThemeResult {
12- return ThemeResult{
13- Applied: 0,
14- Failed: []string{},
15- Warnings: []string{},
16- }
17-}
@@ -1,17 +0,0 @@
1-package shell
2-
3-// ThemeResult holds the result of theme application
4-type ThemeResult struct {
5- Applied int
6- Failed []string
7- Warnings []string
8-}
9-
10-// applyThemes is a no-op. Theme setup is handled by cli-tools.sh at first boot.
11-func (c *Configurator) applyThemes(username, userHome string) ThemeResult {
12- return ThemeResult{
13- Applied: 0,
14- Failed: []string{},
15- Warnings: []string{},
16- }
17-}
D internal/verify/themes.go +0 -173
@@ -1,173 +0,0 @@
1-package verify
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10-)
11-
12-// ThemeVerification holds results of theme verification
13-type ThemeVerification struct {
14- BleuThemeCloned bool
15- ThemeFilesOK map[string]bool // Tool name -> exists
16- ConfigFilesOK map[string]bool // Tool name -> config file exists
17- BatCacheBuilt bool
18- StarshipValid bool
19- Warnings []string
20- Errors []string
21-}
22-
23-// NewThemeVerification creates a new theme verification result
24-func NewThemeVerification() *ThemeVerification {
25- return &ThemeVerification{
26- ThemeFilesOK: make(map[string]bool),
27- ConfigFilesOK: make(map[string]bool),
28- Warnings: []string{},
29- Errors: []string{},
30- }
31-}
32-
33-// ValidateBleuThemeClone verifies the bleu-theme repository was successfully cloned
34-func ValidateBleuThemeClone(fs interfaces.FileSystem, userHome string) bool {
35- bleuThemeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
36- gitDir := filepath.Join(bleuThemeDir, ".git")
37-
38- // Check if .git directory exists (indicates successful clone)
39- _, err := fs.Stat(gitDir)
40- return err == nil
41-}
42-
43-// ValidateThemeFiles checks if expected theme files exist after cloning
44-func ValidateThemeFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
45- result := NewThemeVerification()
46- themeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
47-
48- // Check clone first
49- result.BleuThemeCloned = ValidateBleuThemeClone(fs, userHome)
50- if !result.BleuThemeCloned {
51- result.Warnings = append(result.Warnings, "bleu-theme not cloned successfully")
52- return result
53- }
54-
55- // Theme file paths to check
56- themeFiles := map[string]string{
57- "starship": filepath.Join(themeDir, "starship", "bleu.toml"),
58- "eza": filepath.Join(themeDir, "eza", "theme.yml"),
59- "bat": filepath.Join(themeDir, "bat", "bleu.tmTheme"),
60- "btop": filepath.Join(themeDir, "btop", "bleu.theme"),
61- "yazi": filepath.Join(themeDir, "yazi", "bleu.toml"),
62- "fzf": filepath.Join(themeDir, "fzf", "bleu.sh"),
63- }
64-
65- for tool, path := range themeFiles {
66- _, err := fs.Stat(path)
67- exists := err == nil
68- result.ThemeFilesOK[tool] = exists
69- if !exists {
70- result.Warnings = append(result.Warnings, fmt.Sprintf("%s theme file missing: %s", tool, path))
71- }
72- }
73-
74- return result
75-}
76-
77-// ValidateConfigFiles checks if configuration files were successfully copied
78-func ValidateConfigFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
79- result := NewThemeVerification()
80-
81- configFiles := map[string]string{
82- "starship": filepath.Join(userHome, ".local", "share", "archup", "default", "starship.toml"),
83- "eza": filepath.Join(userHome, ".config", "eza", "theme.yml"),
84- "bat": filepath.Join(userHome, ".config", "bat", "themes", "bleu.tmTheme"),
85- "btop": filepath.Join(userHome, ".config", "btop", "themes", "bleu.theme"),
86- "yazi": filepath.Join(userHome, ".config", "yazi", "flavors", "bleu", "flavor.toml"),
87- }
88-
89- for tool, path := range configFiles {
90- _, err := fs.Stat(path)
91- exists := err == nil
92- result.ConfigFilesOK[tool] = exists
93- if !exists {
94- result.Warnings = append(result.Warnings, fmt.Sprintf("%s config not found: %s", tool, path))
95- }
96- }
97-
98- return result
99-}
100-
101-// ValidateBatCache checks if bat cache was built successfully
102-// This would require running `bat --list-themes` in chroot
103-func ValidateBatCache(chrExec interfaces.ChrootExecutor, logPath string) bool {
104- cmd := "bat --list-themes | grep -i bleu"
105- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
106- return err == nil
107-}
108-
109-// ValidateStarshipConfig tests if starship can load its configuration
110-func ValidateStarshipConfig(chrExec interfaces.ChrootExecutor, logPath string) bool {
111- cmd := "starship config"
112- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
113- return err == nil
114-}
115-
116-// GetThemeVerificationSummary returns a formatted summary of theme verification
117-func GetThemeVerificationSummary(result *ThemeVerification) string {
118- var summary []string
119-
120- if result.BleuThemeCloned {
121- summary = append(summary, "[OK] Bleu-theme repository cloned")
122- } else {
123- summary = append(summary, "[WARN] Bleu-theme repository not cloned")
124- }
125-
126- // Theme files summary
127- missingThemes := []string{}
128- for tool, ok := range result.ThemeFilesOK {
129- if !ok {
130- missingThemes = append(missingThemes, tool)
131- }
132- }
133- if len(missingThemes) > 0 {
134- summary = append(summary, fmt.Sprintf("[WARN] Missing theme files: %s", strings.Join(missingThemes, ", ")))
135- } else if len(result.ThemeFilesOK) > 0 {
136- summary = append(summary, "[OK] All theme files found")
137- }
138-
139- // Config files summary
140- missingConfigs := []string{}
141- for tool, ok := range result.ConfigFilesOK {
142- if !ok {
143- missingConfigs = append(missingConfigs, tool)
144- }
145- }
146- if len(missingConfigs) > 0 {
147- summary = append(summary, fmt.Sprintf("[WARN] Missing config files: %s", strings.Join(missingConfigs, ", ")))
148- } else if len(result.ConfigFilesOK) > 0 {
149- summary = append(summary, "[OK] All config files found")
150- }
151-
152- // Bat cache
153- if result.BatCacheBuilt {
154- summary = append(summary, "[OK] Bat cache built successfully")
155- }
156-
157- // Starship config
158- if result.StarshipValid {
159- summary = append(summary, "[OK] Starship config valid")
160- }
161-
162- // Warnings
163- for _, w := range result.Warnings {
164- summary = append(summary, fmt.Sprintf("[WARN] %s", w))
165- }
166-
167- // Errors
168- for _, e := range result.Errors {
169- summary = append(summary, fmt.Sprintf("[ERROR] %s", e))
170- }
171-
172- return strings.Join(summary, "\n")
173-}
@@ -1,173 +0,0 @@
1-package verify
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10-)
11-
12-// ThemeVerification holds results of theme verification
13-type ThemeVerification struct {
14- BleuThemeCloned bool
15- ThemeFilesOK map[string]bool // Tool name -> exists
16- ConfigFilesOK map[string]bool // Tool name -> config file exists
17- BatCacheBuilt bool
18- StarshipValid bool
19- Warnings []string
20- Errors []string
21-}
22-
23-// NewThemeVerification creates a new theme verification result
24-func NewThemeVerification() *ThemeVerification {
25- return &ThemeVerification{
26- ThemeFilesOK: make(map[string]bool),
27- ConfigFilesOK: make(map[string]bool),
28- Warnings: []string{},
29- Errors: []string{},
30- }
31-}
32-
33-// ValidateBleuThemeClone verifies the bleu-theme repository was successfully cloned
34-func ValidateBleuThemeClone(fs interfaces.FileSystem, userHome string) bool {
35- bleuThemeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
36- gitDir := filepath.Join(bleuThemeDir, ".git")
37-
38- // Check if .git directory exists (indicates successful clone)
39- _, err := fs.Stat(gitDir)
40- return err == nil
41-}
42-
43-// ValidateThemeFiles checks if expected theme files exist after cloning
44-func ValidateThemeFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
45- result := NewThemeVerification()
46- themeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
47-
48- // Check clone first
49- result.BleuThemeCloned = ValidateBleuThemeClone(fs, userHome)
50- if !result.BleuThemeCloned {
51- result.Warnings = append(result.Warnings, "bleu-theme not cloned successfully")
52- return result
53- }
54-
55- // Theme file paths to check
56- themeFiles := map[string]string{
57- "starship": filepath.Join(themeDir, "starship", "bleu.toml"),
58- "eza": filepath.Join(themeDir, "eza", "theme.yml"),
59- "bat": filepath.Join(themeDir, "bat", "bleu.tmTheme"),
60- "btop": filepath.Join(themeDir, "btop", "bleu.theme"),
61- "yazi": filepath.Join(themeDir, "yazi", "bleu.toml"),
62- "fzf": filepath.Join(themeDir, "fzf", "bleu.sh"),
63- }
64-
65- for tool, path := range themeFiles {
66- _, err := fs.Stat(path)
67- exists := err == nil
68- result.ThemeFilesOK[tool] = exists
69- if !exists {
70- result.Warnings = append(result.Warnings, fmt.Sprintf("%s theme file missing: %s", tool, path))
71- }
72- }
73-
74- return result
75-}
76-
77-// ValidateConfigFiles checks if configuration files were successfully copied
78-func ValidateConfigFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
79- result := NewThemeVerification()
80-
81- configFiles := map[string]string{
82- "starship": filepath.Join(userHome, ".local", "share", "archup", "default", "starship.toml"),
83- "eza": filepath.Join(userHome, ".config", "eza", "theme.yml"),
84- "bat": filepath.Join(userHome, ".config", "bat", "themes", "bleu.tmTheme"),
85- "btop": filepath.Join(userHome, ".config", "btop", "themes", "bleu.theme"),
86- "yazi": filepath.Join(userHome, ".config", "yazi", "flavors", "bleu", "flavor.toml"),
87- }
88-
89- for tool, path := range configFiles {
90- _, err := fs.Stat(path)
91- exists := err == nil
92- result.ConfigFilesOK[tool] = exists
93- if !exists {
94- result.Warnings = append(result.Warnings, fmt.Sprintf("%s config not found: %s", tool, path))
95- }
96- }
97-
98- return result
99-}
100-
101-// ValidateBatCache checks if bat cache was built successfully
102-// This would require running `bat --list-themes` in chroot
103-func ValidateBatCache(chrExec interfaces.ChrootExecutor, logPath string) bool {
104- cmd := "bat --list-themes | grep -i bleu"
105- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
106- return err == nil
107-}
108-
109-// ValidateStarshipConfig tests if starship can load its configuration
110-func ValidateStarshipConfig(chrExec interfaces.ChrootExecutor, logPath string) bool {
111- cmd := "starship config"
112- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
113- return err == nil
114-}
115-
116-// GetThemeVerificationSummary returns a formatted summary of theme verification
117-func GetThemeVerificationSummary(result *ThemeVerification) string {
118- var summary []string
119-
120- if result.BleuThemeCloned {
121- summary = append(summary, "[OK] Bleu-theme repository cloned")
122- } else {
123- summary = append(summary, "[WARN] Bleu-theme repository not cloned")
124- }
125-
126- // Theme files summary
127- missingThemes := []string{}
128- for tool, ok := range result.ThemeFilesOK {
129- if !ok {
130- missingThemes = append(missingThemes, tool)
131- }
132- }
133- if len(missingThemes) > 0 {
134- summary = append(summary, fmt.Sprintf("[WARN] Missing theme files: %s", strings.Join(missingThemes, ", ")))
135- } else if len(result.ThemeFilesOK) > 0 {
136- summary = append(summary, "[OK] All theme files found")
137- }
138-
139- // Config files summary
140- missingConfigs := []string{}
141- for tool, ok := range result.ConfigFilesOK {
142- if !ok {
143- missingConfigs = append(missingConfigs, tool)
144- }
145- }
146- if len(missingConfigs) > 0 {
147- summary = append(summary, fmt.Sprintf("[WARN] Missing config files: %s", strings.Join(missingConfigs, ", ")))
148- } else if len(result.ConfigFilesOK) > 0 {
149- summary = append(summary, "[OK] All config files found")
150- }
151-
152- // Bat cache
153- if result.BatCacheBuilt {
154- summary = append(summary, "[OK] Bat cache built successfully")
155- }
156-
157- // Starship config
158- if result.StarshipValid {
159- summary = append(summary, "[OK] Starship config valid")
160- }
161-
162- // Warnings
163- for _, w := range result.Warnings {
164- summary = append(summary, fmt.Sprintf("[WARN] %s", w))
165- }
166-
167- // Errors
168- for _, e := range result.Errors {
169- summary = append(summary, fmt.Sprintf("[ERROR] %s", e))
170- }
171-
172- return strings.Join(summary, "\n")
173-}
D internal/wizard/application/services/wizard_service.go +0 -814
@@ -1,814 +0,0 @@
1-package services
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "os"
8- osuser "os/user"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/archup/internal/defaults"
13- "github.com/bnema/archup/internal/domain/ports"
14- "github.com/bnema/archup/internal/wizard/domain"
15-)
16-
17-// WizardService orchestrates the desktop setup wizard flow.
18-type WizardService struct {
19- fs ports.FileSystem
20- cmdExec ports.CommandExecutor
21- logger ports.Logger
22-}
23-
24-// NewWizardService creates a new wizard service.
25-func NewWizardService(fs ports.FileSystem, cmdExec ports.CommandExecutor, logger ports.Logger) *WizardService {
26- return &WizardService{
27- fs: fs,
28- cmdExec: cmdExec,
29- logger: logger,
30- }
31-}
32-
33-// Start begins the wizard run.
34-func (s *WizardService) Start(ctx context.Context, config domain.DesktopConfig) error {
35- return s.Install(ctx, config)
36-}
37-
38-// Install performs package installation and theme setup.
39-func (s *WizardService) Install(ctx context.Context, config domain.DesktopConfig) error {
40- s.logger.Info("Wizard install", "compositor", config.Compositor)
41- packages, err := s.buildPackageList(config)
42- if err != nil {
43- return err
44- }
45- if err := s.installPackages(ctx, packages); err != nil {
46- return err
47- }
48- if err := s.ensureBleuTheme(ctx); err != nil {
49- return err
50- }
51- return nil
52-}
53-
54-// ApplyConfig writes compositor configuration.
55-func (s *WizardService) ApplyConfig(config domain.DesktopConfig) error {
56- return s.ApplyConfigWithMonitors(config, nil)
57-}
58-
59-// ApplyConfigWithMonitors writes compositor configuration and monitor layout.
60-func (s *WizardService) ApplyConfigWithMonitors(config domain.DesktopConfig, monitors []MonitorConfig) error {
61- if err := s.applyCompositorConfig(config); err != nil {
62- return err
63- }
64- if err := s.ensureHyprlockConfig(); err != nil {
65- return err
66- }
67- if err := s.ensureHypridleConfig(); err != nil {
68- return err
69- }
70- if len(monitors) > 0 {
71- if err := s.writeMonitorConfig(config, monitors); err != nil {
72- return err
73- }
74- }
75- if config.EnableSDDM {
76- if err := s.ensureSDDMTheme(); err != nil {
77- return err
78- }
79- if err := s.ensureSDDMConfig(config); err != nil {
80- return err
81- }
82- if err := s.enableSDDMService(); err != nil {
83- return err
84- }
85- }
86- if err := s.ensureWaybarConfig(); err != nil {
87- return err
88- }
89- if err := s.applyGtkTheme(); err != nil {
90- return err
91- }
92- if err := s.enableUserServices(); err != nil {
93- return err
94- }
95- return nil
96-}
97-
98-func (s *WizardService) buildPackageList(config domain.DesktopConfig) ([]string, error) {
99- packages := []string{}
100-
101- core, err := defaults.ReadPackageList("desktop-core")
102- if err != nil {
103- return nil, err
104- }
105- packages = append(packages, core...)
106-
107- video, err := defaults.ReadPackageList("video-accel")
108- if err != nil {
109- return nil, err
110- }
111- packages = append(packages, video...)
112-
113- gtk, err := defaults.ReadPackageList("gtk")
114- if err != nil {
115- return nil, err
116- }
117- packages = append(packages, gtk...)
118-
119- if config.Compositor == domain.CompositorNiri {
120- compositor, err := defaults.ReadPackageList("compositor-niri")
121- if err != nil {
122- return nil, err
123- }
124- packages = append(packages, compositor...)
125- }
126-
127- if config.Compositor == domain.CompositorHyprland {
128- compositor, err := defaults.ReadPackageList("compositor-hyprland")
129- if err != nil {
130- return nil, err
131- }
132- packages = append(packages, compositor...)
133- }
134-
135- if config.EnableSDDM {
136- sddm, err := defaults.ReadPackageList("sddm")
137- if err != nil {
138- return nil, err
139- }
140- packages = append(packages, sddm...)
141- }
142-
143- if config.InstallCliphist {
144- packages = append(packages, "cliphist")
145- }
146-
147- return uniquePackages(packages), nil
148-}
149-
150-func (s *WizardService) installPackages(ctx context.Context, packages []string) error {
151- if len(packages) == 0 {
152- s.logger.Info("No packages to install")
153- return nil
154- }
155-
156- args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
157- s.logger.Info("Installing desktop packages", "count", len(packages))
158- if _, err := s.cmdExec.Execute(ctx, "pacman", args...); err != nil {
159- return fmt.Errorf("install packages: %w", err)
160- }
161-
162- return nil
163-}
164-
165-func (s *WizardService) applyCompositorConfig(config domain.DesktopConfig) error {
166- home, err := os.UserHomeDir()
167- if err != nil {
168- return fmt.Errorf("resolve home directory: %w", err)
169- }
170-
171- switch config.Compositor {
172- case domain.CompositorHyprland:
173- return s.applyHyprlandConfig(home, config)
174- case domain.CompositorNiri:
175- return s.applyNiriConfig(home, config)
176- default:
177- return nil
178- }
179-}
180-
181-func (s *WizardService) ensureBleuTheme(ctx context.Context) error {
182- home, err := os.UserHomeDir()
183- if err != nil {
184- return fmt.Errorf("resolve home directory: %w", err)
185- }
186-
187- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
188- exists, err := s.fs.Exists(themeDir)
189- if err != nil {
190- return fmt.Errorf("check bleu theme directory: %w", err)
191- }
192- if !exists {
193- if err := s.fs.MkdirAll(filepath.Dir(themeDir), 0o755); err != nil {
194- return fmt.Errorf("create theme parent directory: %w", err)
195- }
196- if _, err := s.cmdExec.Execute(ctx, "git", "clone", "https://github.com/bnema/bleu-theme", themeDir); err != nil {
197- return fmt.Errorf("clone bleu-theme: %w", err)
198- }
199- }
200-
201- wallpaperSource := filepath.Join(themeDir, "wallpapers", "wp-bleu-1.png")
202- wallpaperTarget := filepath.Join(home, "Pictures", "Wallpapers", "wp-bleu-1.png")
203- if err := s.fs.MkdirAll(filepath.Dir(wallpaperTarget), 0o755); err != nil {
204- return fmt.Errorf("create wallpaper directory: %w", err)
205- }
206- data, err := s.fs.ReadFile(wallpaperSource)
207- if err != nil {
208- return fmt.Errorf("read wallpaper: %w", err)
209- }
210- if err := s.fs.WriteFile(wallpaperTarget, data, 0o644); err != nil {
211- return fmt.Errorf("write wallpaper: %w", err)
212- }
213-
214- return nil
215-}
216-
217-func (s *WizardService) writeMonitorConfig(config domain.DesktopConfig, monitors []MonitorConfig) error {
218- home, err := os.UserHomeDir()
219- if err != nil {
220- return fmt.Errorf("resolve home directory: %w", err)
221- }
222-
223- switch config.Compositor {
224- case domain.CompositorHyprland:
225- return s.writeHyprlandMonitorConfig(home, monitors)
226- case domain.CompositorNiri:
227- return s.writeNiriMonitorConfig(home, monitors)
228- default:
229- return nil
230- }
231-}
232-
233-func (s *WizardService) writeHyprlandMonitorConfig(home string, monitors []MonitorConfig) error {
234- configDir := filepath.Join(home, ".config", "hypr")
235- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
236- return fmt.Errorf("create hypr config dir: %w", err)
237- }
238-
239- lines := []string{"# ArchUp monitors"}
240- for _, monitor := range monitors {
241- if !monitor.Enabled {
242- lines = append(lines, fmt.Sprintf("monitor = %s,disable", monitor.Name))
243- continue
244- }
245- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
246- pos := fmt.Sprintf("%dx%d", monitor.PosX, monitor.PosY)
247- scale := fmt.Sprintf("%.2f", monitor.Scale)
248- lines = append(lines, fmt.Sprintf("monitor = %s,%s,%s,%s", monitor.Name, mode, pos, scale))
249- }
250- lines = append(lines, "")
251-
252- path := filepath.Join(configDir, "archup-monitors.conf")
253- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
254- return fmt.Errorf("write hyprland monitor config: %w", err)
255- }
256-
257- includeLine := "source = ~/.config/hypr/archup-monitors.conf"
258- mainPath := filepath.Join(configDir, "hyprland.conf")
259- contents, err := s.readFileIfExists(mainPath)
260- if err != nil {
261- return err
262- }
263- if !containsLine(contents, includeLine) {
264- contents = appendLine(contents, includeLine)
265- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
266- return fmt.Errorf("write hyprland config: %w", err)
267- }
268- }
269-
270- return nil
271-}
272-
273-func (s *WizardService) writeNiriMonitorConfig(home string, monitors []MonitorConfig) error {
274- configDir := filepath.Join(home, ".config", "niri")
275- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
276- return fmt.Errorf("create niri config dir: %w", err)
277- }
278-
279- lines := []string{"// ArchUp monitors"}
280- for _, monitor := range monitors {
281- if !monitor.Enabled {
282- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
283- lines = append(lines, " off")
284- lines = append(lines, "}")
285- lines = append(lines, "")
286- continue
287- }
288- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
289- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
290- lines = append(lines, fmt.Sprintf(" mode \"%s\"", mode))
291- lines = append(lines, fmt.Sprintf(" scale %.2f", monitor.Scale))
292- lines = append(lines, fmt.Sprintf(" position x=%d y=%d", monitor.PosX, monitor.PosY))
293- lines = append(lines, "}")
294- lines = append(lines, "")
295- }
296-
297- path := filepath.Join(configDir, "archup-monitors.kdl")
298- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
299- return fmt.Errorf("write niri monitor config: %w", err)
300- }
301-
302- includeLine := "include \"archup-monitors.kdl\""
303- mainPath := filepath.Join(configDir, "config.kdl")
304- contents, err := s.readFileIfExists(mainPath)
305- if err != nil {
306- return err
307- }
308- if !containsLine(contents, includeLine) {
309- contents = appendLine(contents, includeLine)
310- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
311- return fmt.Errorf("write niri config: %w", err)
312- }
313- }
314-
315- return nil
316-}
317-
318-func (s *WizardService) ensureHyprlockConfig() error {
319- home, err := os.UserHomeDir()
320- if err != nil {
321- return fmt.Errorf("resolve home directory: %w", err)
322- }
323-
324- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
325- configDir := filepath.Join(home, ".config", "hypr")
326- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
327- return fmt.Errorf("create hypr config dir: %w", err)
328- }
329-
330- source := filepath.Join(themeDir, "hyprlock", "hyprlock.conf")
331- target := filepath.Join(configDir, "hyprlock.conf")
332- data, err := s.fs.ReadFile(source)
333- if err != nil {
334- return fmt.Errorf("read hyprlock config: %w", err)
335- }
336- if err := s.writeIfChanged(target, data, 0o644); err != nil {
337- return fmt.Errorf("write hyprlock config: %w", err)
338- }
339-
340- return nil
341-}
342-
343-func (s *WizardService) ensureHypridleConfig() error {
344- home, err := os.UserHomeDir()
345- if err != nil {
346- return fmt.Errorf("resolve home directory: %w", err)
347- }
348-
349- configDir := filepath.Join(home, ".config", "hypr")
350- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
351- return fmt.Errorf("create hypr config dir: %w", err)
352- }
353-
354- configPath := filepath.Join(configDir, "hypridle.conf")
355- content := strings.Join([]string{
356- "general {",
357- " lock_cmd = hyprlock",
358- " before_sleep_cmd = loginctl lock-session",
359- " after_sleep_cmd = notify-send \"Welcome back\"",
360- "}",
361- "",
362- "listener {",
363- " timeout = 600",
364- " on-timeout = hyprlock",
365- " on-resume = notify-send \"Unlocked\"",
366- "}",
367- "",
368- }, "\n")
369-
370- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
371- return fmt.Errorf("write hypridle config: %w", err)
372- }
373-
374- return nil
375-}
376-
377-func (s *WizardService) ensureSDDMTheme() error {
378- home, err := os.UserHomeDir()
379- if err != nil {
380- return fmt.Errorf("resolve home directory: %w", err)
381- }
382-
383- source := filepath.Join(home, ".config", "themes", "bleu-theme", "sddm", "bleu-terminal")
384- if exists, err := s.fs.Exists(source); err != nil {
385- return fmt.Errorf("check sddm theme source: %w", err)
386- } else if !exists {
387- return fmt.Errorf("sddm theme source not found: %s", source)
388- }
389-
390- target := "/usr/share/sddm/themes/bleu-terminal"
391- if _, err := s.cmdExec.Execute(context.Background(), "cp", "-r", source, target); err != nil {
392- return fmt.Errorf("copy sddm theme: %w", err)
393- }
394-
395- return nil
396-}
397-
398-func (s *WizardService) ensureSDDMConfig(config domain.DesktopConfig) error {
399- configDir := "/etc/sddm.conf.d"
400- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
401- return fmt.Errorf("create sddm config dir: %w", err)
402- }
403-
404- lines := []string{
405- "[Theme]",
406- "Current=bleu-terminal",
407- }
408-
409- if config.AutoLogin {
410- userName := resolveLoginUser()
411- if userName != "" {
412- lines = append(lines, "")
413- lines = append(lines, "[Autologin]")
414- lines = append(lines, fmt.Sprintf("User=%s", userName))
415- lines = append(lines, fmt.Sprintf("Session=%s", sddmSessionName(config.Compositor)))
416- }
417- }
418-
419- content := strings.Join(lines, "\n") + "\n"
420- configPath := filepath.Join(configDir, "archup.conf")
421- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
422- return fmt.Errorf("write sddm config: %w", err)
423- }
424-
425- return nil
426-}
427-
428-func (s *WizardService) enableSDDMService() error {
429- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "enable", "--now", "sddm"); err != nil {
430- return fmt.Errorf("enable sddm service: %w", err)
431- }
432- return nil
433-}
434-
435-func resolveLoginUser() string {
436- if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
437- return sudoUser
438- }
439- current, err := osuser.Current()
440- if err != nil {
441- return ""
442- }
443- return current.Username
444-}
445-
446-func sddmSessionName(compositor domain.Compositor) string {
447- if compositor == domain.CompositorHyprland {
448- return "hyprland"
449- }
450- return "niri"
451-}
452-
453-func (s *WizardService) ensureWaybarConfig() error {
454- home, err := os.UserHomeDir()
455- if err != nil {
456- return fmt.Errorf("resolve home directory: %w", err)
457- }
458-
459- configDir := filepath.Join(home, ".config", "waybar")
460- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
461- return fmt.Errorf("create waybar config dir: %w", err)
462- }
463-
464- configPath := filepath.Join(configDir, "config")
465- templatePath := filepath.Join("install", "configs", "waybar", "config.json")
466- configData, err := s.fs.ReadFile(templatePath)
467- if err != nil {
468- return fmt.Errorf("read waybar config template: %w", err)
469- }
470- if err := s.writeIfChanged(configPath, configData, 0o644); err != nil {
471- return fmt.Errorf("write waybar config: %w", err)
472- }
473-
474- stylePath := filepath.Join(configDir, "style.css")
475- source := filepath.Join(home, ".config", "themes", "bleu-theme", "waybar", "bleu.css")
476- data, err := s.fs.ReadFile(source)
477- if err != nil {
478- return fmt.Errorf("read waybar style: %w", err)
479- }
480- if err := s.writeIfChanged(stylePath, data, 0o644); err != nil {
481- return fmt.Errorf("write waybar style: %w", err)
482- }
483-
484- scriptDir := filepath.Join(configDir, "scripts")
485- if err := s.fs.MkdirAll(scriptDir, 0o755); err != nil {
486- return fmt.Errorf("create waybar scripts dir: %w", err)
487- }
488- scriptPath := filepath.Join(scriptDir, "theme-toggle.sh")
489- scriptSource := filepath.Join("install", "configs", "waybar", "theme-toggle.sh")
490- scriptData, err := s.fs.ReadFile(scriptSource)
491- if err != nil {
492- return fmt.Errorf("read waybar theme toggle script: %w", err)
493- }
494- if err := s.writeIfChanged(scriptPath, scriptData, 0o755); err != nil {
495- return fmt.Errorf("write waybar theme toggle script: %w", err)
496- }
497-
498- return nil
499-}
500-
501-func (s *WizardService) writeIfChanged(path string, data []byte, perm os.FileMode) error {
502- exists, err := s.fs.Exists(path)
503- if err != nil {
504- return err
505- }
506- if exists {
507- existing, err := s.fs.ReadFile(path)
508- if err != nil {
509- return err
510- }
511- if string(existing) == string(data) {
512- return nil
513- }
514- }
515- return s.fs.WriteFile(path, data, perm)
516-}
517-
518-func (s *WizardService) applyGtkTheme() error {
519- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"); err != nil {
520- return fmt.Errorf("set GTK color scheme: %w", err)
521- }
522- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-dark"); err != nil {
523- return fmt.Errorf("set GTK theme: %w", err)
524- }
525- return nil
526-}
527-
528-func (s *WizardService) enableUserServices() error {
529- if err := s.ensureWaybarService(); err != nil {
530- return err
531- }
532-
533- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "daemon-reload"); err != nil {
534- return fmt.Errorf("reload user systemd: %w", err)
535- }
536-
537- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "waybar.service"); err != nil {
538- return fmt.Errorf("enable waybar service: %w", err)
539- }
540- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "hypridle.service"); err != nil {
541- return fmt.Errorf("enable hypridle service: %w", err)
542- }
543-
544- return nil
545-}
546-
547-func (s *WizardService) ensureWaybarService() error {
548- home, err := os.UserHomeDir()
549- if err != nil {
550- return fmt.Errorf("resolve home directory: %w", err)
551- }
552-
553- unitDir := filepath.Join(home, ".config", "systemd", "user")
554- if err := s.fs.MkdirAll(unitDir, 0o755); err != nil {
555- return fmt.Errorf("create user systemd dir: %w", err)
556- }
557-
558- unitPath := filepath.Join(unitDir, "waybar.service")
559- unit := strings.Join([]string{
560- "[Unit]",
561- "Description=Waybar",
562- "PartOf=graphical-session.target",
563- "After=graphical-session.target",
564- "",
565- "[Service]",
566- "ExecStart=/usr/bin/waybar",
567- "Restart=on-failure",
568- "",
569- "[Install]",
570- "WantedBy=graphical-session.target",
571- "",
572- }, "\n")
573-
574- if err := s.fs.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
575- return fmt.Errorf("write waybar service: %w", err)
576- }
577-
578- return nil
579-}
580-
581-func (s *WizardService) applyHyprlandConfig(home string, config domain.DesktopConfig) error {
582- configDir := filepath.Join(home, ".config", "hypr")
583- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
584- return fmt.Errorf("create hypr config dir: %w", err)
585- }
586-
587- configPath := filepath.Join(configDir, "hyprland.conf")
588- includeLine := "source = ~/.config/hypr/archup.conf"
589- contents, err := s.readFileIfExists(configPath)
590- if err != nil {
591- return err
592- }
593- if !containsLine(contents, includeLine) {
594- contents = appendLine(contents, includeLine)
595- }
596- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
597- return fmt.Errorf("write hyprland config: %w", err)
598- }
599-
600- archupPath := filepath.Join(configDir, "archup.conf")
601- managed := hyprlandManagedConfig(config)
602- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
603- return fmt.Errorf("write hyprland managed config: %w", err)
604- }
605-
606- return nil
607-}
608-
609-func (s *WizardService) applyNiriConfig(home string, config domain.DesktopConfig) error {
610- configDir := filepath.Join(home, ".config", "niri")
611- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
612- return fmt.Errorf("create niri config dir: %w", err)
613- }
614-
615- configPath := filepath.Join(configDir, "config.kdl")
616- includeLine := "include \"archup.kdl\""
617- contents, err := s.readFileIfExists(configPath)
618- if err != nil {
619- return err
620- }
621- if !containsLine(contents, includeLine) {
622- contents = appendLine(contents, includeLine)
623- }
624- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
625- return fmt.Errorf("write niri config: %w", err)
626- }
627-
628- archupPath := filepath.Join(configDir, "archup.kdl")
629- managed := niriManagedConfig(config)
630- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
631- return fmt.Errorf("write niri managed config: %w", err)
632- }
633-
634- return nil
635-}
636-
637-func (s *WizardService) readFileIfExists(path string) (string, error) {
638- exists, err := s.fs.Exists(path)
639- if err != nil {
640- return "", fmt.Errorf("check config exists: %w", err)
641- }
642- if !exists {
643- return "", nil
644- }
645- data, err := s.fs.ReadFile(path)
646- if err != nil {
647- return "", fmt.Errorf("read config: %w", err)
648- }
649- return string(data), nil
650-}
651-
652-func containsLine(contents string, line string) bool {
653- for _, existing := range strings.Split(contents, "\n") {
654- if strings.TrimSpace(existing) == strings.TrimSpace(line) {
655- return true
656- }
657- }
658- return false
659-}
660-
661-func appendLine(contents string, line string) string {
662- trimmed := strings.TrimRight(contents, "\n")
663- if trimmed == "" {
664- return line + "\n"
665- }
666- return trimmed + "\n" + line + "\n"
667-}
668-
669-func hyprlandManagedConfig(config domain.DesktopConfig) string {
670- lines := []string{
671- "# ArchUp managed config",
672- "exec-once = swww-daemon",
673- "exec-once = swww img ~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png",
674- "exec-once = waybar",
675- "exec-once = mako",
676- "exec-once = hypridle",
677- "exec-once = /usr/lib/polkit-kde-authentication-agent-1",
678- "",
679- "bind = SUPER, T, exec, foot",
680- "bind = SUPER, D, exec, fuzzel",
681- "bind = SUPER, Q, killactive",
682- "bind = SUPER, L, exec, hyprlock",
683- "",
684- }
685-
686- if config.InstallCliphist {
687- lines = append(lines, "exec-once = wl-paste --type text --watch cliphist store")
688- lines = append(lines, "exec-once = wl-paste --type image --watch cliphist store")
689- lines = append(lines, "")
690- }
691-
692- return strings.Join(lines, "\n")
693-}
694-
695-func niriManagedConfig(config domain.DesktopConfig) string {
696- lines := []string{
697- "// ArchUp managed config",
698- "environment {",
699- " DISPLAY \":1\"",
700- " ELECTRON_OZONE_PLATFORM_HINT \"auto\"",
701- " QT_QPA_PLATFORM \"wayland\"",
702- " QT_WAYLAND_DISABLE_WINDOWDECORATION \"1\"",
703- " XDG_SESSION_TYPE \"wayland\"",
704- " XDG_CURRENT_DESKTOP \"niri\"",
705- "}",
706- "spawn-at-startup \"swww-daemon\"",
707- "spawn-at-startup \"swww\" \"img\" \"~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png\"",
708- "spawn-at-startup \"waybar\"",
709- "spawn-at-startup \"mako\"",
710- "spawn-at-startup \"hypridle\"",
711- "spawn-at-startup \"/usr/lib/polkit-kde-authentication-agent-1\"",
712- "",
713- "binds {",
714- " Mod+T { spawn \"foot\"; }",
715- " Mod+D { spawn \"fuzzel\"; }",
716- " Mod+Q { close-window; }",
717- " Mod+L { spawn \"hyprlock\"; }",
718- "}",
719- "",
720- }
721-
722- if config.InstallCliphist {
723- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"text\" \"--watch\" \"cliphist\" \"store\"")
724- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"image\" \"--watch\" \"cliphist\" \"store\"")
725- lines = append(lines, "")
726- }
727-
728- return strings.Join(lines, "\n")
729-}
730-
731-func uniquePackages(packages []string) []string {
732- seen := make(map[string]struct{}, len(packages))
733- result := make([]string, 0, len(packages))
734- for _, pkg := range packages {
735- if pkg == "" {
736- continue
737- }
738- if _, ok := seen[pkg]; ok {
739- continue
740- }
741- seen[pkg] = struct{}{}
742- result = append(result, pkg)
743- }
744- return result
745-}
746-
747-// MonitorOutput represents a detected monitor output.
748-type MonitorOutput struct {
749- Name string `json:"name"`
750- Enabled bool `json:"enabled"`
751- CurrentMode *MonitorMode `json:"current_mode"`
752- Modes []MonitorMode `json:"modes"`
753-}
754-
755-// MonitorMode represents a display mode.
756-type MonitorMode struct {
757- Width int `json:"width"`
758- Height int `json:"height"`
759- Refresh float64 `json:"refresh"`
760-}
761-
762-// MonitorConfig represents a desired monitor configuration.
763-type MonitorConfig struct {
764- Name string
765- Enabled bool
766- Width int
767- Height int
768- Refresh float64
769- PosX int
770- PosY int
771- Scale float64
772-}
773-
774-// DetectMonitors returns monitor outputs from wlr-randr.
775-func (s *WizardService) DetectMonitors(ctx context.Context) ([]MonitorOutput, error) {
776- output, err := s.cmdExec.Execute(ctx, "wlr-randr", "--json")
777- if err != nil {
778- return nil, fmt.Errorf("detect monitors: %w", err)
779- }
780-
781- var monitors []MonitorOutput
782- if err := json.Unmarshal(output, &monitors); err != nil {
783- return nil, fmt.Errorf("parse monitor JSON: %w", err)
784- }
785- return monitors, nil
786-}
787-
788-// ApplyMonitorConfig applies monitor settings using wlr-randr.
789-func (s *WizardService) ApplyMonitorConfig(ctx context.Context, configs []MonitorConfig) error {
790- for _, cfg := range configs {
791- args := []string{"--output", cfg.Name}
792- if cfg.Enabled {
793- args = append(args, "--on")
794- if cfg.Width > 0 && cfg.Height > 0 {
795- mode := fmt.Sprintf("%dx%d", cfg.Width, cfg.Height)
796- if cfg.Refresh > 0 {
797- mode = fmt.Sprintf("%s@%.2fHz", mode, cfg.Refresh)
798- }
799- args = append(args, "--mode", mode)
800- }
801- args = append(args, "--pos", fmt.Sprintf("%d,%d", cfg.PosX, cfg.PosY))
802- if cfg.Scale > 0 {
803- args = append(args, "--scale", fmt.Sprintf("%.2f", cfg.Scale))
804- }
805- } else {
806- args = append(args, "--off")
807- }
808-
809- if _, err := s.cmdExec.Execute(ctx, "wlr-randr", args...); err != nil {
810- return fmt.Errorf("apply monitor %s: %w", cfg.Name, err)
811- }
812- }
813- return nil
814-}
@@ -1,814 +0,0 @@
1-package services
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "os"
8- osuser "os/user"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/archup/internal/defaults"
13- "github.com/bnema/archup/internal/domain/ports"
14- "github.com/bnema/archup/internal/wizard/domain"
15-)
16-
17-// WizardService orchestrates the desktop setup wizard flow.
18-type WizardService struct {
19- fs ports.FileSystem
20- cmdExec ports.CommandExecutor
21- logger ports.Logger
22-}
23-
24-// NewWizardService creates a new wizard service.
25-func NewWizardService(fs ports.FileSystem, cmdExec ports.CommandExecutor, logger ports.Logger) *WizardService {
26- return &WizardService{
27- fs: fs,
28- cmdExec: cmdExec,
29- logger: logger,
30- }
31-}
32-
33-// Start begins the wizard run.
34-func (s *WizardService) Start(ctx context.Context, config domain.DesktopConfig) error {
35- return s.Install(ctx, config)
36-}
37-
38-// Install performs package installation and theme setup.
39-func (s *WizardService) Install(ctx context.Context, config domain.DesktopConfig) error {
40- s.logger.Info("Wizard install", "compositor", config.Compositor)
41- packages, err := s.buildPackageList(config)
42- if err != nil {
43- return err
44- }
45- if err := s.installPackages(ctx, packages); err != nil {
46- return err
47- }
48- if err := s.ensureBleuTheme(ctx); err != nil {
49- return err
50- }
51- return nil
52-}
53-
54-// ApplyConfig writes compositor configuration.
55-func (s *WizardService) ApplyConfig(config domain.DesktopConfig) error {
56- return s.ApplyConfigWithMonitors(config, nil)
57-}
58-
59-// ApplyConfigWithMonitors writes compositor configuration and monitor layout.
60-func (s *WizardService) ApplyConfigWithMonitors(config domain.DesktopConfig, monitors []MonitorConfig) error {
61- if err := s.applyCompositorConfig(config); err != nil {
62- return err
63- }
64- if err := s.ensureHyprlockConfig(); err != nil {
65- return err
66- }
67- if err := s.ensureHypridleConfig(); err != nil {
68- return err
69- }
70- if len(monitors) > 0 {
71- if err := s.writeMonitorConfig(config, monitors); err != nil {
72- return err
73- }
74- }
75- if config.EnableSDDM {
76- if err := s.ensureSDDMTheme(); err != nil {
77- return err
78- }
79- if err := s.ensureSDDMConfig(config); err != nil {
80- return err
81- }
82- if err := s.enableSDDMService(); err != nil {
83- return err
84- }
85- }
86- if err := s.ensureWaybarConfig(); err != nil {
87- return err
88- }
89- if err := s.applyGtkTheme(); err != nil {
90- return err
91- }
92- if err := s.enableUserServices(); err != nil {
93- return err
94- }
95- return nil
96-}
97-
98-func (s *WizardService) buildPackageList(config domain.DesktopConfig) ([]string, error) {
99- packages := []string{}
100-
101- core, err := defaults.ReadPackageList("desktop-core")
102- if err != nil {
103- return nil, err
104- }
105- packages = append(packages, core...)
106-
107- video, err := defaults.ReadPackageList("video-accel")
108- if err != nil {
109- return nil, err
110- }
111- packages = append(packages, video...)
112-
113- gtk, err := defaults.ReadPackageList("gtk")
114- if err != nil {
115- return nil, err
116- }
117- packages = append(packages, gtk...)
118-
119- if config.Compositor == domain.CompositorNiri {
120- compositor, err := defaults.ReadPackageList("compositor-niri")
121- if err != nil {
122- return nil, err
123- }
124- packages = append(packages, compositor...)
125- }
126-
127- if config.Compositor == domain.CompositorHyprland {
128- compositor, err := defaults.ReadPackageList("compositor-hyprland")
129- if err != nil {
130- return nil, err
131- }
132- packages = append(packages, compositor...)
133- }
134-
135- if config.EnableSDDM {
136- sddm, err := defaults.ReadPackageList("sddm")
137- if err != nil {
138- return nil, err
139- }
140- packages = append(packages, sddm...)
141- }
142-
143- if config.InstallCliphist {
144- packages = append(packages, "cliphist")
145- }
146-
147- return uniquePackages(packages), nil
148-}
149-
150-func (s *WizardService) installPackages(ctx context.Context, packages []string) error {
151- if len(packages) == 0 {
152- s.logger.Info("No packages to install")
153- return nil
154- }
155-
156- args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
157- s.logger.Info("Installing desktop packages", "count", len(packages))
158- if _, err := s.cmdExec.Execute(ctx, "pacman", args...); err != nil {
159- return fmt.Errorf("install packages: %w", err)
160- }
161-
162- return nil
163-}
164-
165-func (s *WizardService) applyCompositorConfig(config domain.DesktopConfig) error {
166- home, err := os.UserHomeDir()
167- if err != nil {
168- return fmt.Errorf("resolve home directory: %w", err)
169- }
170-
171- switch config.Compositor {
172- case domain.CompositorHyprland:
173- return s.applyHyprlandConfig(home, config)
174- case domain.CompositorNiri:
175- return s.applyNiriConfig(home, config)
176- default:
177- return nil
178- }
179-}
180-
181-func (s *WizardService) ensureBleuTheme(ctx context.Context) error {
182- home, err := os.UserHomeDir()
183- if err != nil {
184- return fmt.Errorf("resolve home directory: %w", err)
185- }
186-
187- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
188- exists, err := s.fs.Exists(themeDir)
189- if err != nil {
190- return fmt.Errorf("check bleu theme directory: %w", err)
191- }
192- if !exists {
193- if err := s.fs.MkdirAll(filepath.Dir(themeDir), 0o755); err != nil {
194- return fmt.Errorf("create theme parent directory: %w", err)
195- }
196- if _, err := s.cmdExec.Execute(ctx, "git", "clone", "https://github.com/bnema/bleu-theme", themeDir); err != nil {
197- return fmt.Errorf("clone bleu-theme: %w", err)
198- }
199- }
200-
201- wallpaperSource := filepath.Join(themeDir, "wallpapers", "wp-bleu-1.png")
202- wallpaperTarget := filepath.Join(home, "Pictures", "Wallpapers", "wp-bleu-1.png")
203- if err := s.fs.MkdirAll(filepath.Dir(wallpaperTarget), 0o755); err != nil {
204- return fmt.Errorf("create wallpaper directory: %w", err)
205- }
206- data, err := s.fs.ReadFile(wallpaperSource)
207- if err != nil {
208- return fmt.Errorf("read wallpaper: %w", err)
209- }
210- if err := s.fs.WriteFile(wallpaperTarget, data, 0o644); err != nil {
211- return fmt.Errorf("write wallpaper: %w", err)
212- }
213-
214- return nil
215-}
216-
217-func (s *WizardService) writeMonitorConfig(config domain.DesktopConfig, monitors []MonitorConfig) error {
218- home, err := os.UserHomeDir()
219- if err != nil {
220- return fmt.Errorf("resolve home directory: %w", err)
221- }
222-
223- switch config.Compositor {
224- case domain.CompositorHyprland:
225- return s.writeHyprlandMonitorConfig(home, monitors)
226- case domain.CompositorNiri:
227- return s.writeNiriMonitorConfig(home, monitors)
228- default:
229- return nil
230- }
231-}
232-
233-func (s *WizardService) writeHyprlandMonitorConfig(home string, monitors []MonitorConfig) error {
234- configDir := filepath.Join(home, ".config", "hypr")
235- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
236- return fmt.Errorf("create hypr config dir: %w", err)
237- }
238-
239- lines := []string{"# ArchUp monitors"}
240- for _, monitor := range monitors {
241- if !monitor.Enabled {
242- lines = append(lines, fmt.Sprintf("monitor = %s,disable", monitor.Name))
243- continue
244- }
245- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
246- pos := fmt.Sprintf("%dx%d", monitor.PosX, monitor.PosY)
247- scale := fmt.Sprintf("%.2f", monitor.Scale)
248- lines = append(lines, fmt.Sprintf("monitor = %s,%s,%s,%s", monitor.Name, mode, pos, scale))
249- }
250- lines = append(lines, "")
251-
252- path := filepath.Join(configDir, "archup-monitors.conf")
253- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
254- return fmt.Errorf("write hyprland monitor config: %w", err)
255- }
256-
257- includeLine := "source = ~/.config/hypr/archup-monitors.conf"
258- mainPath := filepath.Join(configDir, "hyprland.conf")
259- contents, err := s.readFileIfExists(mainPath)
260- if err != nil {
261- return err
262- }
263- if !containsLine(contents, includeLine) {
264- contents = appendLine(contents, includeLine)
265- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
266- return fmt.Errorf("write hyprland config: %w", err)
267- }
268- }
269-
270- return nil
271-}
272-
273-func (s *WizardService) writeNiriMonitorConfig(home string, monitors []MonitorConfig) error {
274- configDir := filepath.Join(home, ".config", "niri")
275- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
276- return fmt.Errorf("create niri config dir: %w", err)
277- }
278-
279- lines := []string{"// ArchUp monitors"}
280- for _, monitor := range monitors {
281- if !monitor.Enabled {
282- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
283- lines = append(lines, " off")
284- lines = append(lines, "}")
285- lines = append(lines, "")
286- continue
287- }
288- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
289- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
290- lines = append(lines, fmt.Sprintf(" mode \"%s\"", mode))
291- lines = append(lines, fmt.Sprintf(" scale %.2f", monitor.Scale))
292- lines = append(lines, fmt.Sprintf(" position x=%d y=%d", monitor.PosX, monitor.PosY))
293- lines = append(lines, "}")
294- lines = append(lines, "")
295- }
296-
297- path := filepath.Join(configDir, "archup-monitors.kdl")
298- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
299- return fmt.Errorf("write niri monitor config: %w", err)
300- }
301-
302- includeLine := "include \"archup-monitors.kdl\""
303- mainPath := filepath.Join(configDir, "config.kdl")
304- contents, err := s.readFileIfExists(mainPath)
305- if err != nil {
306- return err
307- }
308- if !containsLine(contents, includeLine) {
309- contents = appendLine(contents, includeLine)
310- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
311- return fmt.Errorf("write niri config: %w", err)
312- }
313- }
314-
315- return nil
316-}
317-
318-func (s *WizardService) ensureHyprlockConfig() error {
319- home, err := os.UserHomeDir()
320- if err != nil {
321- return fmt.Errorf("resolve home directory: %w", err)
322- }
323-
324- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
325- configDir := filepath.Join(home, ".config", "hypr")
326- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
327- return fmt.Errorf("create hypr config dir: %w", err)
328- }
329-
330- source := filepath.Join(themeDir, "hyprlock", "hyprlock.conf")
331- target := filepath.Join(configDir, "hyprlock.conf")
332- data, err := s.fs.ReadFile(source)
333- if err != nil {
334- return fmt.Errorf("read hyprlock config: %w", err)
335- }
336- if err := s.writeIfChanged(target, data, 0o644); err != nil {
337- return fmt.Errorf("write hyprlock config: %w", err)
338- }
339-
340- return nil
341-}
342-
343-func (s *WizardService) ensureHypridleConfig() error {
344- home, err := os.UserHomeDir()
345- if err != nil {
346- return fmt.Errorf("resolve home directory: %w", err)
347- }
348-
349- configDir := filepath.Join(home, ".config", "hypr")
350- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
351- return fmt.Errorf("create hypr config dir: %w", err)
352- }
353-
354- configPath := filepath.Join(configDir, "hypridle.conf")
355- content := strings.Join([]string{
356- "general {",
357- " lock_cmd = hyprlock",
358- " before_sleep_cmd = loginctl lock-session",
359- " after_sleep_cmd = notify-send \"Welcome back\"",
360- "}",
361- "",
362- "listener {",
363- " timeout = 600",
364- " on-timeout = hyprlock",
365- " on-resume = notify-send \"Unlocked\"",
366- "}",
367- "",
368- }, "\n")
369-
370- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
371- return fmt.Errorf("write hypridle config: %w", err)
372- }
373-
374- return nil
375-}
376-
377-func (s *WizardService) ensureSDDMTheme() error {
378- home, err := os.UserHomeDir()
379- if err != nil {
380- return fmt.Errorf("resolve home directory: %w", err)
381- }
382-
383- source := filepath.Join(home, ".config", "themes", "bleu-theme", "sddm", "bleu-terminal")
384- if exists, err := s.fs.Exists(source); err != nil {
385- return fmt.Errorf("check sddm theme source: %w", err)
386- } else if !exists {
387- return fmt.Errorf("sddm theme source not found: %s", source)
388- }
389-
390- target := "/usr/share/sddm/themes/bleu-terminal"
391- if _, err := s.cmdExec.Execute(context.Background(), "cp", "-r", source, target); err != nil {
392- return fmt.Errorf("copy sddm theme: %w", err)
393- }
394-
395- return nil
396-}
397-
398-func (s *WizardService) ensureSDDMConfig(config domain.DesktopConfig) error {
399- configDir := "/etc/sddm.conf.d"
400- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
401- return fmt.Errorf("create sddm config dir: %w", err)
402- }
403-
404- lines := []string{
405- "[Theme]",
406- "Current=bleu-terminal",
407- }
408-
409- if config.AutoLogin {
410- userName := resolveLoginUser()
411- if userName != "" {
412- lines = append(lines, "")
413- lines = append(lines, "[Autologin]")
414- lines = append(lines, fmt.Sprintf("User=%s", userName))
415- lines = append(lines, fmt.Sprintf("Session=%s", sddmSessionName(config.Compositor)))
416- }
417- }
418-
419- content := strings.Join(lines, "\n") + "\n"
420- configPath := filepath.Join(configDir, "archup.conf")
421- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
422- return fmt.Errorf("write sddm config: %w", err)
423- }
424-
425- return nil
426-}
427-
428-func (s *WizardService) enableSDDMService() error {
429- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "enable", "--now", "sddm"); err != nil {
430- return fmt.Errorf("enable sddm service: %w", err)
431- }
432- return nil
433-}
434-
435-func resolveLoginUser() string {
436- if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
437- return sudoUser
438- }
439- current, err := osuser.Current()
440- if err != nil {
441- return ""
442- }
443- return current.Username
444-}
445-
446-func sddmSessionName(compositor domain.Compositor) string {
447- if compositor == domain.CompositorHyprland {
448- return "hyprland"
449- }
450- return "niri"
451-}
452-
453-func (s *WizardService) ensureWaybarConfig() error {
454- home, err := os.UserHomeDir()
455- if err != nil {
456- return fmt.Errorf("resolve home directory: %w", err)
457- }
458-
459- configDir := filepath.Join(home, ".config", "waybar")
460- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
461- return fmt.Errorf("create waybar config dir: %w", err)
462- }
463-
464- configPath := filepath.Join(configDir, "config")
465- templatePath := filepath.Join("install", "configs", "waybar", "config.json")
466- configData, err := s.fs.ReadFile(templatePath)
467- if err != nil {
468- return fmt.Errorf("read waybar config template: %w", err)
469- }
470- if err := s.writeIfChanged(configPath, configData, 0o644); err != nil {
471- return fmt.Errorf("write waybar config: %w", err)
472- }
473-
474- stylePath := filepath.Join(configDir, "style.css")
475- source := filepath.Join(home, ".config", "themes", "bleu-theme", "waybar", "bleu.css")
476- data, err := s.fs.ReadFile(source)
477- if err != nil {
478- return fmt.Errorf("read waybar style: %w", err)
479- }
480- if err := s.writeIfChanged(stylePath, data, 0o644); err != nil {
481- return fmt.Errorf("write waybar style: %w", err)
482- }
483-
484- scriptDir := filepath.Join(configDir, "scripts")
485- if err := s.fs.MkdirAll(scriptDir, 0o755); err != nil {
486- return fmt.Errorf("create waybar scripts dir: %w", err)
487- }
488- scriptPath := filepath.Join(scriptDir, "theme-toggle.sh")
489- scriptSource := filepath.Join("install", "configs", "waybar", "theme-toggle.sh")
490- scriptData, err := s.fs.ReadFile(scriptSource)
491- if err != nil {
492- return fmt.Errorf("read waybar theme toggle script: %w", err)
493- }
494- if err := s.writeIfChanged(scriptPath, scriptData, 0o755); err != nil {
495- return fmt.Errorf("write waybar theme toggle script: %w", err)
496- }
497-
498- return nil
499-}
500-
501-func (s *WizardService) writeIfChanged(path string, data []byte, perm os.FileMode) error {
502- exists, err := s.fs.Exists(path)
503- if err != nil {
504- return err
505- }
506- if exists {
507- existing, err := s.fs.ReadFile(path)
508- if err != nil {
509- return err
510- }
511- if string(existing) == string(data) {
512- return nil
513- }
514- }
515- return s.fs.WriteFile(path, data, perm)
516-}
517-
518-func (s *WizardService) applyGtkTheme() error {
519- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"); err != nil {
520- return fmt.Errorf("set GTK color scheme: %w", err)
521- }
522- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-dark"); err != nil {
523- return fmt.Errorf("set GTK theme: %w", err)
524- }
525- return nil
526-}
527-
528-func (s *WizardService) enableUserServices() error {
529- if err := s.ensureWaybarService(); err != nil {
530- return err
531- }
532-
533- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "daemon-reload"); err != nil {
534- return fmt.Errorf("reload user systemd: %w", err)
535- }
536-
537- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "waybar.service"); err != nil {
538- return fmt.Errorf("enable waybar service: %w", err)
539- }
540- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "hypridle.service"); err != nil {
541- return fmt.Errorf("enable hypridle service: %w", err)
542- }
543-
544- return nil
545-}
546-
547-func (s *WizardService) ensureWaybarService() error {
548- home, err := os.UserHomeDir()
549- if err != nil {
550- return fmt.Errorf("resolve home directory: %w", err)
551- }
552-
553- unitDir := filepath.Join(home, ".config", "systemd", "user")
554- if err := s.fs.MkdirAll(unitDir, 0o755); err != nil {
555- return fmt.Errorf("create user systemd dir: %w", err)
556- }
557-
558- unitPath := filepath.Join(unitDir, "waybar.service")
559- unit := strings.Join([]string{
560- "[Unit]",
561- "Description=Waybar",
562- "PartOf=graphical-session.target",
563- "After=graphical-session.target",
564- "",
565- "[Service]",
566- "ExecStart=/usr/bin/waybar",
567- "Restart=on-failure",
568- "",
569- "[Install]",
570- "WantedBy=graphical-session.target",
571- "",
572- }, "\n")
573-
574- if err := s.fs.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
575- return fmt.Errorf("write waybar service: %w", err)
576- }
577-
578- return nil
579-}
580-
581-func (s *WizardService) applyHyprlandConfig(home string, config domain.DesktopConfig) error {
582- configDir := filepath.Join(home, ".config", "hypr")
583- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
584- return fmt.Errorf("create hypr config dir: %w", err)
585- }
586-
587- configPath := filepath.Join(configDir, "hyprland.conf")
588- includeLine := "source = ~/.config/hypr/archup.conf"
589- contents, err := s.readFileIfExists(configPath)
590- if err != nil {
591- return err
592- }
593- if !containsLine(contents, includeLine) {
594- contents = appendLine(contents, includeLine)
595- }
596- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
597- return fmt.Errorf("write hyprland config: %w", err)
598- }
599-
600- archupPath := filepath.Join(configDir, "archup.conf")
601- managed := hyprlandManagedConfig(config)
602- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
603- return fmt.Errorf("write hyprland managed config: %w", err)
604- }
605-
606- return nil
607-}
608-
609-func (s *WizardService) applyNiriConfig(home string, config domain.DesktopConfig) error {
610- configDir := filepath.Join(home, ".config", "niri")
611- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
612- return fmt.Errorf("create niri config dir: %w", err)
613- }
614-
615- configPath := filepath.Join(configDir, "config.kdl")
616- includeLine := "include \"archup.kdl\""
617- contents, err := s.readFileIfExists(configPath)
618- if err != nil {
619- return err
620- }
621- if !containsLine(contents, includeLine) {
622- contents = appendLine(contents, includeLine)
623- }
624- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
625- return fmt.Errorf("write niri config: %w", err)
626- }
627-
628- archupPath := filepath.Join(configDir, "archup.kdl")
629- managed := niriManagedConfig(config)
630- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
631- return fmt.Errorf("write niri managed config: %w", err)
632- }
633-
634- return nil
635-}
636-
637-func (s *WizardService) readFileIfExists(path string) (string, error) {
638- exists, err := s.fs.Exists(path)
639- if err != nil {
640- return "", fmt.Errorf("check config exists: %w", err)
641- }
642- if !exists {
643- return "", nil
644- }
645- data, err := s.fs.ReadFile(path)
646- if err != nil {
647- return "", fmt.Errorf("read config: %w", err)
648- }
649- return string(data), nil
650-}
651-
652-func containsLine(contents string, line string) bool {
653- for _, existing := range strings.Split(contents, "\n") {
654- if strings.TrimSpace(existing) == strings.TrimSpace(line) {
655- return true
656- }
657- }
658- return false
659-}
660-
661-func appendLine(contents string, line string) string {
662- trimmed := strings.TrimRight(contents, "\n")
663- if trimmed == "" {
664- return line + "\n"
665- }
666- return trimmed + "\n" + line + "\n"
667-}
668-
669-func hyprlandManagedConfig(config domain.DesktopConfig) string {
670- lines := []string{
671- "# ArchUp managed config",
672- "exec-once = swww-daemon",
673- "exec-once = swww img ~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png",
674- "exec-once = waybar",
675- "exec-once = mako",
676- "exec-once = hypridle",
677- "exec-once = /usr/lib/polkit-kde-authentication-agent-1",
678- "",
679- "bind = SUPER, T, exec, foot",
680- "bind = SUPER, D, exec, fuzzel",
681- "bind = SUPER, Q, killactive",
682- "bind = SUPER, L, exec, hyprlock",
683- "",
684- }
685-
686- if config.InstallCliphist {
687- lines = append(lines, "exec-once = wl-paste --type text --watch cliphist store")
688- lines = append(lines, "exec-once = wl-paste --type image --watch cliphist store")
689- lines = append(lines, "")
690- }
691-
692- return strings.Join(lines, "\n")
693-}
694-
695-func niriManagedConfig(config domain.DesktopConfig) string {
696- lines := []string{
697- "// ArchUp managed config",
698- "environment {",
699- " DISPLAY \":1\"",
700- " ELECTRON_OZONE_PLATFORM_HINT \"auto\"",
701- " QT_QPA_PLATFORM \"wayland\"",
702- " QT_WAYLAND_DISABLE_WINDOWDECORATION \"1\"",
703- " XDG_SESSION_TYPE \"wayland\"",
704- " XDG_CURRENT_DESKTOP \"niri\"",
705- "}",
706- "spawn-at-startup \"swww-daemon\"",
707- "spawn-at-startup \"swww\" \"img\" \"~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png\"",
708- "spawn-at-startup \"waybar\"",
709- "spawn-at-startup \"mako\"",
710- "spawn-at-startup \"hypridle\"",
711- "spawn-at-startup \"/usr/lib/polkit-kde-authentication-agent-1\"",
712- "",
713- "binds {",
714- " Mod+T { spawn \"foot\"; }",
715- " Mod+D { spawn \"fuzzel\"; }",
716- " Mod+Q { close-window; }",
717- " Mod+L { spawn \"hyprlock\"; }",
718- "}",
719- "",
720- }
721-
722- if config.InstallCliphist {
723- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"text\" \"--watch\" \"cliphist\" \"store\"")
724- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"image\" \"--watch\" \"cliphist\" \"store\"")
725- lines = append(lines, "")
726- }
727-
728- return strings.Join(lines, "\n")
729-}
730-
731-func uniquePackages(packages []string) []string {
732- seen := make(map[string]struct{}, len(packages))
733- result := make([]string, 0, len(packages))
734- for _, pkg := range packages {
735- if pkg == "" {
736- continue
737- }
738- if _, ok := seen[pkg]; ok {
739- continue
740- }
741- seen[pkg] = struct{}{}
742- result = append(result, pkg)
743- }
744- return result
745-}
746-
747-// MonitorOutput represents a detected monitor output.
748-type MonitorOutput struct {
749- Name string `json:"name"`
750- Enabled bool `json:"enabled"`
751- CurrentMode *MonitorMode `json:"current_mode"`
752- Modes []MonitorMode `json:"modes"`
753-}
754-
755-// MonitorMode represents a display mode.
756-type MonitorMode struct {
757- Width int `json:"width"`
758- Height int `json:"height"`
759- Refresh float64 `json:"refresh"`
760-}
761-
762-// MonitorConfig represents a desired monitor configuration.
763-type MonitorConfig struct {
764- Name string
765- Enabled bool
766- Width int
767- Height int
768- Refresh float64
769- PosX int
770- PosY int
771- Scale float64
772-}
773-
774-// DetectMonitors returns monitor outputs from wlr-randr.
775-func (s *WizardService) DetectMonitors(ctx context.Context) ([]MonitorOutput, error) {
776- output, err := s.cmdExec.Execute(ctx, "wlr-randr", "--json")
777- if err != nil {
778- return nil, fmt.Errorf("detect monitors: %w", err)
779- }
780-
781- var monitors []MonitorOutput
782- if err := json.Unmarshal(output, &monitors); err != nil {
783- return nil, fmt.Errorf("parse monitor JSON: %w", err)
784- }
785- return monitors, nil
786-}
787-
788-// ApplyMonitorConfig applies monitor settings using wlr-randr.
789-func (s *WizardService) ApplyMonitorConfig(ctx context.Context, configs []MonitorConfig) error {
790- for _, cfg := range configs {
791- args := []string{"--output", cfg.Name}
792- if cfg.Enabled {
793- args = append(args, "--on")
794- if cfg.Width > 0 && cfg.Height > 0 {
795- mode := fmt.Sprintf("%dx%d", cfg.Width, cfg.Height)
796- if cfg.Refresh > 0 {
797- mode = fmt.Sprintf("%s@%.2fHz", mode, cfg.Refresh)
798- }
799- args = append(args, "--mode", mode)
800- }
801- args = append(args, "--pos", fmt.Sprintf("%d,%d", cfg.PosX, cfg.PosY))
802- if cfg.Scale > 0 {
803- args = append(args, "--scale", fmt.Sprintf("%.2f", cfg.Scale))
804- }
805- } else {
806- args = append(args, "--off")
807- }
808-
809- if _, err := s.cmdExec.Execute(ctx, "wlr-randr", args...); err != nil {
810- return fmt.Errorf("apply monitor %s: %w", cfg.Name, err)
811- }
812- }
813- return nil
814-}
D internal/wizard/domain/compositor.go +0 -9
@@ -1,9 +0,0 @@
1-package domain
2-
3-// Compositor represents supported Wayland compositors.
4-type Compositor string
5-
6-const (
7- CompositorNiri Compositor = "niri"
8- CompositorHyprland Compositor = "hyprland"
9-)
@@ -1,9 +0,0 @@
1-package domain
2-
3-// Compositor represents supported Wayland compositors.
4-type Compositor string
5-
6-const (
7- CompositorNiri Compositor = "niri"
8- CompositorHyprland Compositor = "hyprland"
9-)
D internal/wizard/domain/desktop.go +0 -21
@@ -1,21 +0,0 @@
1-package domain
2-
3-// DesktopConfig represents wizard selections for desktop setup.
4-type DesktopConfig struct {
5- Compositor Compositor
6- EnableSDDM bool
7- AutoLogin bool
8- AutoLoginUser string
9- InstallCliphist bool
10-}
11-
12-// NewDesktopConfig returns defaults for a new wizard run.
13-func NewDesktopConfig() DesktopConfig {
14- return DesktopConfig{
15- Compositor: CompositorNiri,
16- EnableSDDM: true,
17- AutoLogin: false,
18- AutoLoginUser: "",
19- InstallCliphist: false,
20- }
21-}
@@ -1,21 +0,0 @@
1-package domain
2-
3-// DesktopConfig represents wizard selections for desktop setup.
4-type DesktopConfig struct {
5- Compositor Compositor
6- EnableSDDM bool
7- AutoLogin bool
8- AutoLoginUser string
9- InstallCliphist bool
10-}
11-
12-// NewDesktopConfig returns defaults for a new wizard run.
13-func NewDesktopConfig() DesktopConfig {
14- return DesktopConfig{
15- Compositor: CompositorNiri,
16- EnableSDDM: true,
17- AutoLogin: false,
18- AutoLoginUser: "",
19- InstallCliphist: false,
20- }
21-}
D internal/wizard/domain/packages.go +0 -7
@@ -1,7 +0,0 @@
1-package domain
2-
3-// PackageSet defines a named list of packages.
4-type PackageSet struct {
5- Name string
6- Packages []string
7-}
@@ -1,7 +0,0 @@
1-package domain
2-
3-// PackageSet defines a named list of packages.
4-type PackageSet struct {
5- Name string
6- Packages []string
7-}
D internal/wizard/interfaces/tui/views/apply_config.go +0 -23
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderApplyConfig renders the config apply step.
10-func RenderApplyConfig() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Applying Configuration"))
18- b.WriteString("\n\n")
19- b.WriteString("Writing compositor config and enabling services...\n\n")
20- b.WriteString(muted.Render("Please wait."))
21-
22- return b.String()
23-}
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderApplyConfig renders the config apply step.
10-func RenderApplyConfig() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Applying Configuration"))
18- b.WriteString("\n\n")
19- b.WriteString("Writing compositor config and enabling services...\n\n")
20- b.WriteString(muted.Render("Please wait."))
21-
22- return b.String()
23-}
D internal/wizard/interfaces/tui/views/complete.go +0 -34
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderComplete renders completion screen.
11-func RenderComplete(config domain.DesktopConfig, errMsg string) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- alert := lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
17-
18- b.WriteString("\n")
19- if errMsg != "" {
20- b.WriteString(alert.Render("Wizard finished with errors"))
21- b.WriteString("\n\n")
22- b.WriteString(alert.Render(errMsg))
23- b.WriteString("\n\n")
24- } else {
25- b.WriteString(title.Render("Wizard Complete"))
26- b.WriteString("\n\n")
27- b.WriteString("Desktop setup started for: " + string(config.Compositor))
28- b.WriteString("\n\n")
29- }
30-
31- b.WriteString(muted.Render("Press Enter to exit."))
32-
33- return b.String()
34-}
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderComplete renders completion screen.
11-func RenderComplete(config domain.DesktopConfig, errMsg string) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- alert := lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
17-
18- b.WriteString("\n")
19- if errMsg != "" {
20- b.WriteString(alert.Render("Wizard finished with errors"))
21- b.WriteString("\n\n")
22- b.WriteString(alert.Render(errMsg))
23- b.WriteString("\n\n")
24- } else {
25- b.WriteString(title.Render("Wizard Complete"))
26- b.WriteString("\n\n")
27- b.WriteString("Desktop setup started for: " + string(config.Compositor))
28- b.WriteString("\n\n")
29- }
30-
31- b.WriteString(muted.Render("Press Enter to exit."))
32-
33- return b.String()
34-}
D internal/wizard/interfaces/tui/views/compositor.go +0 -39
@@ -1,39 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderCompositor renders compositor selection.
11-func RenderCompositor(config domain.DesktopConfig, selected int) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17-
18- options := []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
19-
20- b.WriteString("\n")
21- b.WriteString(title.Render("Select Compositor"))
22- b.WriteString("\n\n")
23-
24- for i, option := range options {
25- label := string(option)
26- if i == selected {
27- b.WriteString(active.Render("› " + label))
28- } else {
29- b.WriteString(" " + label)
30- }
31- b.WriteString("\n")
32- }
33-
34- b.WriteString("\n")
35- b.WriteString(muted.Render("↑↓ Move • Enter Select • Ctrl+C Quit"))
36-
37- _ = config
38- return b.String()
39-}
@@ -1,39 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderCompositor renders compositor selection.
11-func RenderCompositor(config domain.DesktopConfig, selected int) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17-
18- options := []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
19-
20- b.WriteString("\n")
21- b.WriteString(title.Render("Select Compositor"))
22- b.WriteString("\n\n")
23-
24- for i, option := range options {
25- label := string(option)
26- if i == selected {
27- b.WriteString(active.Render("› " + label))
28- } else {
29- b.WriteString(" " + label)
30- }
31- b.WriteString("\n")
32- }
33-
34- b.WriteString("\n")
35- b.WriteString(muted.Render("↑↓ Move • Enter Select • Ctrl+C Quit"))
36-
37- _ = config
38- return b.String()
39-}
D internal/wizard/interfaces/tui/views/confirm.go +0 -42
@@ -1,42 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderConfirm renders confirmation screen.
11-func RenderConfirm(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "no"
18- if config.InstallCliphist {
19- cliphistStatus = "yes"
20- }
21-
22- sddmStatus := "no"
23- if config.EnableSDDM {
24- sddmStatus = "yes"
25- }
26-
27- autoLoginStatus := "no"
28- if config.AutoLogin {
29- autoLoginStatus = "yes"
30- }
31-
32- b.WriteString("\n")
33- b.WriteString(title.Render("Confirm Selections"))
34- b.WriteString("\n\n")
35- b.WriteString("Compositor: " + string(config.Compositor) + "\n")
36- b.WriteString("SDDM: " + sddmStatus + "\n")
37- b.WriteString("Auto-login: " + autoLoginStatus + "\n")
38- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
39- b.WriteString(muted.Render("Enter Install • B Back"))
40-
41- return b.String()
42-}
@@ -1,42 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderConfirm renders confirmation screen.
11-func RenderConfirm(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "no"
18- if config.InstallCliphist {
19- cliphistStatus = "yes"
20- }
21-
22- sddmStatus := "no"
23- if config.EnableSDDM {
24- sddmStatus = "yes"
25- }
26-
27- autoLoginStatus := "no"
28- if config.AutoLogin {
29- autoLoginStatus = "yes"
30- }
31-
32- b.WriteString("\n")
33- b.WriteString(title.Render("Confirm Selections"))
34- b.WriteString("\n\n")
35- b.WriteString("Compositor: " + string(config.Compositor) + "\n")
36- b.WriteString("SDDM: " + sddmStatus + "\n")
37- b.WriteString("Auto-login: " + autoLoginStatus + "\n")
38- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
39- b.WriteString(muted.Render("Enter Install • B Back"))
40-
41- return b.String()
42-}
D internal/wizard/interfaces/tui/views/install.go +0 -23
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderInstall renders a placeholder install screen.
10-func RenderInstall() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Installing"))
18- b.WriteString("\n\n")
19- b.WriteString("Wizard install is not implemented yet.\n\n")
20- b.WriteString(muted.Render("Running setup..."))
21-
22- return b.String()
23-}
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderInstall renders a placeholder install screen.
10-func RenderInstall() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Installing"))
18- b.WriteString("\n\n")
19- b.WriteString("Wizard install is not implemented yet.\n\n")
20- b.WriteString(muted.Render("Running setup..."))
21-
22- return b.String()
23-}
D internal/wizard/interfaces/tui/views/monitors.go +0 -80
@@ -1,80 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/archup/internal/wizard/application/services"
8- "github.com/charmbracelet/lipgloss"
9-)
10-
11-// RenderMonitors renders monitor configuration info.
12-func RenderMonitors(monitors []services.MonitorOutput, configs []services.MonitorConfig, selected int, applying bool, dirty bool, errMsg string) string {
13- var b strings.Builder
14-
15- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
16- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
17-
18- b.WriteString("\n")
19- b.WriteString(title.Render("Monitor Configuration"))
20- b.WriteString("\n\n")
21-
22- if len(monitors) == 0 {
23- b.WriteString("No monitors detected.\n\n")
24- b.WriteString(muted.Render("Press Enter to continue."))
25- return b.String()
26- }
27-
28- selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
29- for i, monitor := range monitors {
30- status := "off"
31- if monitor.Enabled {
32- status = "on"
33- }
34- label := fmt.Sprintf("%s (%s)", monitor.Name, status)
35- if i == selected {
36- label = selectedStyle.Render("› " + label)
37- } else {
38- label = " " + label
39- }
40- b.WriteString(label)
41- modeWidth, modeHeight, modeRefresh := 0, 0, 0.0
42- if i < len(configs) {
43- modeWidth = configs[i].Width
44- modeHeight = configs[i].Height
45- modeRefresh = configs[i].Refresh
46- } else if monitor.CurrentMode != nil {
47- modeWidth = monitor.CurrentMode.Width
48- modeHeight = monitor.CurrentMode.Height
49- modeRefresh = monitor.CurrentMode.Refresh
50- }
51- if modeWidth > 0 && modeHeight > 0 {
52- b.WriteString(fmt.Sprintf(" - %dx%d @ %.2fHz", modeWidth, modeHeight, modeRefresh))
53- }
54- b.WriteString("\n")
55-
56- if i < len(configs) {
57- cfg := configs[i]
58- b.WriteString(muted.Render(fmt.Sprintf(" pos=%d,%d scale=%.2f", cfg.PosX, cfg.PosY, cfg.Scale)))
59- b.WriteString("\n")
60- }
61- }
62-
63- if errMsg != "" {
64- b.WriteString("\n")
65- b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(errMsg))
66- }
67-
68- b.WriteString("\n")
69- if applying {
70- b.WriteString(muted.Render("Applying changes..."))
71- } else if dirty {
72- b.WriteString(muted.Render("Changes pending..."))
73- } else {
74- b.WriteString(muted.Render("Detected via wlr-randr --json"))
75- }
76- b.WriteString("\n")
77- b.WriteString(muted.Render("↑↓ Select • H/L X-pos • J/K Y-pos • +/- Scale • M Cycle Mode • Space Toggle • Enter Next"))
78-
79- return b.String()
80-}
@@ -1,80 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/archup/internal/wizard/application/services"
8- "github.com/charmbracelet/lipgloss"
9-)
10-
11-// RenderMonitors renders monitor configuration info.
12-func RenderMonitors(monitors []services.MonitorOutput, configs []services.MonitorConfig, selected int, applying bool, dirty bool, errMsg string) string {
13- var b strings.Builder
14-
15- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
16- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
17-
18- b.WriteString("\n")
19- b.WriteString(title.Render("Monitor Configuration"))
20- b.WriteString("\n\n")
21-
22- if len(monitors) == 0 {
23- b.WriteString("No monitors detected.\n\n")
24- b.WriteString(muted.Render("Press Enter to continue."))
25- return b.String()
26- }
27-
28- selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
29- for i, monitor := range monitors {
30- status := "off"
31- if monitor.Enabled {
32- status = "on"
33- }
34- label := fmt.Sprintf("%s (%s)", monitor.Name, status)
35- if i == selected {
36- label = selectedStyle.Render("› " + label)
37- } else {
38- label = " " + label
39- }
40- b.WriteString(label)
41- modeWidth, modeHeight, modeRefresh := 0, 0, 0.0
42- if i < len(configs) {
43- modeWidth = configs[i].Width
44- modeHeight = configs[i].Height
45- modeRefresh = configs[i].Refresh
46- } else if monitor.CurrentMode != nil {
47- modeWidth = monitor.CurrentMode.Width
48- modeHeight = monitor.CurrentMode.Height
49- modeRefresh = monitor.CurrentMode.Refresh
50- }
51- if modeWidth > 0 && modeHeight > 0 {
52- b.WriteString(fmt.Sprintf(" - %dx%d @ %.2fHz", modeWidth, modeHeight, modeRefresh))
53- }
54- b.WriteString("\n")
55-
56- if i < len(configs) {
57- cfg := configs[i]
58- b.WriteString(muted.Render(fmt.Sprintf(" pos=%d,%d scale=%.2f", cfg.PosX, cfg.PosY, cfg.Scale)))
59- b.WriteString("\n")
60- }
61- }
62-
63- if errMsg != "" {
64- b.WriteString("\n")
65- b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(errMsg))
66- }
67-
68- b.WriteString("\n")
69- if applying {
70- b.WriteString(muted.Render("Applying changes..."))
71- } else if dirty {
72- b.WriteString(muted.Render("Changes pending..."))
73- } else {
74- b.WriteString(muted.Render("Detected via wlr-randr --json"))
75- }
76- b.WriteString("\n")
77- b.WriteString(muted.Render("↑↓ Select • H/L X-pos • J/K Y-pos • +/- Scale • M Cycle Mode • Space Toggle • Enter Next"))
78-
79- return b.String()
80-}
D internal/wizard/interfaces/tui/views/optional.go +0 -29
@@ -1,29 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderOptional renders optional component toggles.
11-func RenderOptional(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "off"
18- if config.InstallCliphist {
19- cliphistStatus = "on"
20- }
21-
22- b.WriteString("\n")
23- b.WriteString(title.Render("Optional Components"))
24- b.WriteString("\n\n")
25- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
26- b.WriteString(muted.Render("Space Toggle • Enter Next"))
27-
28- return b.String()
29-}
@@ -1,29 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderOptional renders optional component toggles.
11-func RenderOptional(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "off"
18- if config.InstallCliphist {
19- cliphistStatus = "on"
20- }
21-
22- b.WriteString("\n")
23- b.WriteString(title.Render("Optional Components"))
24- b.WriteString("\n\n")
25- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
26- b.WriteString(muted.Render("Space Toggle • Enter Next"))
27-
28- return b.String()
29-}
D internal/wizard/interfaces/tui/views/sddm.go +0 -34
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderSDDM renders the SDDM selection screen.
11-func RenderSDDM(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- sddmStatus := "disabled"
18- if config.EnableSDDM {
19- sddmStatus = "enabled"
20- }
21- autoLoginStatus := "off"
22- if config.AutoLogin {
23- autoLoginStatus = "on"
24- }
25-
26- b.WriteString("\n")
27- b.WriteString(title.Render("Display Manager"))
28- b.WriteString("\n\n")
29- b.WriteString("SDDM: " + sddmStatus + "\n")
30- b.WriteString("Auto-login: " + autoLoginStatus + "\n\n")
31- b.WriteString(muted.Render("Space Toggle SDDM • A Toggle Auto-login • Enter Next"))
32-
33- return b.String()
34-}
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderSDDM renders the SDDM selection screen.
11-func RenderSDDM(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- sddmStatus := "disabled"
18- if config.EnableSDDM {
19- sddmStatus = "enabled"
20- }
21- autoLoginStatus := "off"
22- if config.AutoLogin {
23- autoLoginStatus = "on"
24- }
25-
26- b.WriteString("\n")
27- b.WriteString(title.Render("Display Manager"))
28- b.WriteString("\n\n")
29- b.WriteString("SDDM: " + sddmStatus + "\n")
30- b.WriteString("Auto-login: " + autoLoginStatus + "\n\n")
31- b.WriteString(muted.Render("Space Toggle SDDM • A Toggle Auto-login • Enter Next"))
32-
33- return b.String()
34-}
D internal/wizard/interfaces/tui/views/welcome.go +0 -25
@@ -1,25 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderWelcome renders the wizard welcome screen.
11-func RenderWelcome(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- b.WriteString("\n")
18- b.WriteString(title.Render("ArchUp Desktop Wizard"))
19- b.WriteString("\n\n")
20- b.WriteString("This wizard will install a Wayland desktop and apply defaults.\n")
21- b.WriteString("You can re-run it safely to reconfigure.\n\n")
22- b.WriteString(muted.Render("Default compositor: " + string(config.Compositor) + "\n"))
23- b.WriteString(muted.Render("Press Enter to begin or Ctrl+C to exit."))
24- return b.String()
25-}
@@ -1,25 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderWelcome renders the wizard welcome screen.
11-func RenderWelcome(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- b.WriteString("\n")
18- b.WriteString(title.Render("ArchUp Desktop Wizard"))
19- b.WriteString("\n\n")
20- b.WriteString("This wizard will install a Wayland desktop and apply defaults.\n")
21- b.WriteString("You can re-run it safely to reconfigure.\n\n")
22- b.WriteString(muted.Render("Default compositor: " + string(config.Compositor) + "\n"))
23- b.WriteString(muted.Render("Press Enter to begin or Ctrl+C to exit."))
24- return b.String()
25-}
D internal/wizard/interfaces/tui/wizard_app.go +0 -484
@@ -1,484 +0,0 @@
1-package tui
2-
3-import (
4- "context"
5- "time"
6-
7- "github.com/bnema/archup/internal/domain/ports"
8- "github.com/bnema/archup/internal/wizard/application/services"
9- "github.com/bnema/archup/internal/wizard/domain"
10- "github.com/bnema/archup/internal/wizard/interfaces/tui/views"
11- tea "github.com/charmbracelet/bubbletea"
12-)
13-
14-// App is the wizard TUI model.
15-type App struct {
16- service *services.WizardService
17- logger ports.Logger
18- config domain.DesktopConfig
19- screen Screen
20- errMsg string
21-
22- compositorIndex int
23- monitors []services.MonitorOutput
24- monitorConfigs []services.MonitorConfig
25- monitorSelected int
26- monitorModeIdx map[string]int
27- monitorDirty bool
28- monitorApplySeq int
29- monitorApplying bool
30- monitorErr string
31-}
32-
33-// Screen represents wizard screens.
34-type Screen string
35-
36-const (
37- ScreenWelcome Screen = "welcome"
38- ScreenCompositor Screen = "compositor"
39- ScreenSDDM Screen = "sddm"
40- ScreenOptional Screen = "optional"
41- ScreenConfirm Screen = "confirm"
42- ScreenInstall Screen = "install"
43- ScreenMonitor Screen = "monitor"
44- ScreenConfig Screen = "config"
45- ScreenComplete Screen = "complete"
46-)
47-
48-// NewApp creates a new wizard TUI app.
49-func NewApp(service *services.WizardService, logger ports.Logger) *App {
50- return &App{
51- service: service,
52- logger: logger,
53- config: domain.NewDesktopConfig(),
54- screen: ScreenWelcome,
55- }
56-}
57-
58-// Init initializes the TUI.
59-func (a *App) Init() tea.Cmd {
60- return nil
61-}
62-
63-// Update handles input events.
64-func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65- switch msg := msg.(type) {
66- case wizardInstallMsg:
67- if msg.err != nil {
68- a.errMsg = msg.err.Error()
69- a.screen = ScreenComplete
70- return a, nil
71- }
72- a.screen = ScreenMonitor
73- return a, a.detectMonitorsCmd()
74- case wizardMonitorMsg:
75- if msg.err != nil {
76- a.errMsg = msg.err.Error()
77- a.screen = ScreenComplete
78- return a, nil
79- }
80- a.monitors = msg.monitors
81- a.monitorConfigs = buildMonitorConfigs(msg.monitors)
82- a.monitorModeIdx = buildMonitorModeIndex(msg.monitors)
83- a.monitorSelected = 0
84- a.monitorDirty = false
85- a.monitorApplying = false
86- a.monitorErr = ""
87- return a, nil
88- case monitorApplyTickMsg:
89- if msg.seq != a.monitorApplySeq || !a.monitorDirty {
90- return a, nil
91- }
92- a.monitorDirty = false
93- a.monitorApplying = true
94- return a, a.applyMonitorConfigCmd(msg.seq)
95- case monitorAppliedMsg:
96- if msg.seq != a.monitorApplySeq {
97- return a, nil
98- }
99- a.monitorApplying = false
100- a.monitorErr = ""
101- if msg.err != nil {
102- a.monitorErr = msg.err.Error()
103- a.monitorDirty = true
104- return a, a.scheduleMonitorApply()
105- }
106- return a, nil
107- case wizardConfigMsg:
108- if msg.err != nil {
109- a.errMsg = msg.err.Error()
110- }
111- a.screen = ScreenComplete
112- return a, nil
113- case tea.KeyMsg:
114- return a.handleKey(msg)
115- }
116- return a, nil
117-}
118-
119-// View renders the wizard screen.
120-func (a *App) View() string {
121- switch a.screen {
122- case ScreenWelcome:
123- return views.RenderWelcome(a.config)
124- case ScreenCompositor:
125- return views.RenderCompositor(a.config, a.compositorIndex)
126- case ScreenSDDM:
127- return views.RenderSDDM(a.config)
128- case ScreenOptional:
129- return views.RenderOptional(a.config)
130- case ScreenConfirm:
131- return views.RenderConfirm(a.config)
132- case ScreenInstall:
133- return views.RenderInstall()
134- case ScreenMonitor:
135- return views.RenderMonitors(a.monitors, a.monitorConfigs, a.monitorSelected, a.monitorApplying, a.monitorDirty, a.monitorErr)
136- case ScreenConfig:
137- return views.RenderApplyConfig()
138- case ScreenComplete:
139- return views.RenderComplete(a.config, a.errMsg)
140- default:
141- return views.RenderWelcome(a.config)
142- }
143-}
144-
145-func (a *App) startCmd() tea.Cmd {
146- return func() tea.Msg {
147- err := a.service.Install(context.Background(), a.config)
148- if err != nil {
149- a.logger.Error("Wizard start failed", "error", err)
150- }
151- return wizardInstallMsg{err: err}
152- }
153-}
154-
155-func (a *App) detectMonitorsCmd() tea.Cmd {
156- return func() tea.Msg {
157- monitors, err := a.service.DetectMonitors(context.Background())
158- if err != nil {
159- a.logger.Error("Monitor detection failed", "error", err)
160- }
161- return wizardMonitorMsg{monitors: monitors, err: err}
162- }
163-}
164-
165-func (a *App) applyConfigCmd() tea.Cmd {
166- return func() tea.Msg {
167- err := a.service.ApplyConfigWithMonitors(a.config, a.monitorConfigs)
168- if err != nil {
169- a.logger.Error("Wizard config apply failed", "error", err)
170- }
171- return wizardConfigMsg{err: err}
172- }
173-}
174-
175-func (a *App) applyMonitorConfigCmd(seq int) tea.Cmd {
176- configs := append([]services.MonitorConfig{}, a.monitorConfigs...)
177- return func() tea.Msg {
178- err := a.service.ApplyMonitorConfig(context.Background(), configs)
179- return monitorAppliedMsg{seq: seq, err: err}
180- }
181-}
182-
183-func (a *App) scheduleMonitorApply() tea.Cmd {
184- a.monitorApplySeq++
185- seq := a.monitorApplySeq
186- return tea.Tick(250*time.Millisecond, func(time.Time) tea.Msg {
187- return monitorApplyTickMsg{seq: seq}
188- })
189-}
190-
191-func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
192- switch msg.String() {
193- case "q", "ctrl+c":
194- return a, tea.Quit
195- }
196-
197- switch a.screen {
198- case ScreenWelcome:
199- if msg.String() == "enter" {
200- a.screen = ScreenCompositor
201- }
202- case ScreenCompositor:
203- switch msg.String() {
204- case "up", "shift+tab":
205- a.moveCompositor(-1)
206- case "down", "tab":
207- a.moveCompositor(1)
208- case "enter":
209- a.applyCompositorSelection()
210- a.screen = ScreenSDDM
211- }
212- case ScreenSDDM:
213- switch msg.String() {
214- case " ":
215- a.config.EnableSDDM = !a.config.EnableSDDM
216- if !a.config.EnableSDDM {
217- a.config.AutoLogin = false
218- return a, nil
219- }
220- case "a":
221- if a.config.EnableSDDM {
222- a.config.AutoLogin = !a.config.AutoLogin
223- }
224- case "enter":
225- a.screen = ScreenOptional
226- }
227- case ScreenOptional:
228- switch msg.String() {
229- case " ":
230- a.config.InstallCliphist = !a.config.InstallCliphist
231- case "enter":
232- a.screen = ScreenConfirm
233- }
234- case ScreenConfirm:
235- switch msg.String() {
236- case "enter":
237- a.screen = ScreenInstall
238- return a, a.startCmd()
239- case "b":
240- a.screen = ScreenOptional
241- }
242- case ScreenInstall:
243- return a, nil
244- case ScreenMonitor:
245- return a.handleMonitorKey(msg)
246- case ScreenConfig:
247- return a, nil
248- case ScreenComplete:
249- if msg.String() == "enter" {
250- return a, tea.Quit
251- }
252- }
253-
254- return a, nil
255-}
256-
257-func (a *App) moveCompositor(delta int) {
258- options := compositorOptions()
259- count := len(options)
260- if count == 0 {
261- return
262- }
263- idx := (a.compositorIndex + delta) % count
264- if idx < 0 {
265- idx = count - 1
266- }
267- a.compositorIndex = idx
268-}
269-
270-func (a *App) applyCompositorSelection() {
271- options := compositorOptions()
272- if len(options) == 0 {
273- return
274- }
275- if a.compositorIndex < 0 || a.compositorIndex >= len(options) {
276- a.compositorIndex = 0
277- }
278- a.config.Compositor = options[a.compositorIndex]
279-}
280-
281-func compositorOptions() []domain.Compositor {
282- return []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
283-}
284-
285-func buildMonitorConfigs(monitors []services.MonitorOutput) []services.MonitorConfig {
286- configs := make([]services.MonitorConfig, 0, len(monitors))
287- for _, monitor := range monitors {
288- cfg := services.MonitorConfig{
289- Name: monitor.Name,
290- Enabled: monitor.Enabled,
291- Scale: 1,
292- }
293- if monitor.CurrentMode != nil {
294- cfg.Width = monitor.CurrentMode.Width
295- cfg.Height = monitor.CurrentMode.Height
296- cfg.Refresh = monitor.CurrentMode.Refresh
297- }
298- configs = append(configs, cfg)
299- }
300- return configs
301-}
302-
303-func buildMonitorModeIndex(monitors []services.MonitorOutput) map[string]int {
304- index := make(map[string]int, len(monitors))
305- for _, monitor := range monitors {
306- idx := 0
307- if monitor.CurrentMode != nil {
308- idx = findModeIndex(monitor.Modes, *monitor.CurrentMode)
309- }
310- index[monitor.Name] = idx
311- }
312- return index
313-}
314-
315-func findModeIndex(modes []services.MonitorMode, current services.MonitorMode) int {
316- for i, mode := range modes {
317- if mode.Width == current.Width && mode.Height == current.Height && floatEquals(mode.Refresh, current.Refresh) {
318- return i
319- }
320- }
321- return 0
322-}
323-
324-func floatEquals(a, b float64) bool {
325- if a == b {
326- return true
327- }
328- if a > b {
329- return a-b < 0.05
330- }
331- return b-a < 0.05
332-}
333-
334-func (a *App) cycleMonitorMode(delta int) {
335- selected := a.selectedMonitor()
336- if selected == nil {
337- return
338- }
339- var modes []services.MonitorMode
340- for _, monitor := range a.monitors {
341- if monitor.Name == selected.Name {
342- modes = monitor.Modes
343- break
344- }
345- }
346- if len(modes) == 0 {
347- return
348- }
349- current := a.monitorModeIdx[selected.Name]
350- current = (current + delta) % len(modes)
351- if current < 0 {
352- current = len(modes) - 1
353- }
354- a.monitorModeIdx[selected.Name] = current
355- mode := modes[current]
356- selected.Width = mode.Width
357- selected.Height = mode.Height
358- selected.Refresh = mode.Refresh
359-}
360-
361-func (a *App) handleMonitorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
362- switch msg.String() {
363- case "enter":
364- a.screen = ScreenConfig
365- return a, a.applyConfigCmd()
366- case "up", "shift+tab":
367- a.monitorSelected = max(0, a.monitorSelected-1)
368- case "down", "tab":
369- if len(a.monitorConfigs) > 0 {
370- a.monitorSelected = min(len(a.monitorConfigs)-1, a.monitorSelected+1)
371- }
372- case " ":
373- if a.selectedMonitor() != nil {
374- a.selectedMonitor().Enabled = !a.selectedMonitor().Enabled
375- return a, a.markMonitorDirty()
376- }
377- case "m":
378- if a.selectedMonitor() != nil {
379- a.cycleMonitorMode(1)
380- return a, a.markMonitorDirty()
381- }
382- case "shift+m":
383- if a.selectedMonitor() != nil {
384- a.cycleMonitorMode(-1)
385- return a, a.markMonitorDirty()
386- }
387- case "left", "h":
388- if a.selectedMonitor() != nil {
389- a.selectedMonitor().PosX -= 50
390- return a, a.markMonitorDirty()
391- }
392- case "right", "l":
393- if a.selectedMonitor() != nil {
394- a.selectedMonitor().PosX += 50
395- return a, a.markMonitorDirty()
396- }
397- case "k":
398- if a.selectedMonitor() != nil {
399- a.selectedMonitor().PosY -= 50
400- return a, a.markMonitorDirty()
401- }
402- case "j":
403- if a.selectedMonitor() != nil {
404- a.selectedMonitor().PosY += 50
405- return a, a.markMonitorDirty()
406- }
407- case "r":
408- if a.selectedMonitor() != nil {
409- a.selectedMonitor().PosX = 0
410- a.selectedMonitor().PosY = 0
411- return a, a.markMonitorDirty()
412- }
413- case "+", "=":
414- if a.selectedMonitor() != nil {
415- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale + 0.1)
416- return a, a.markMonitorDirty()
417- }
418- case "-", "_":
419- if a.selectedMonitor() != nil {
420- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale - 0.1)
421- return a, a.markMonitorDirty()
422- }
423- }
424-
425- return a, nil
426-}
427-
428-func (a *App) markMonitorDirty() tea.Cmd {
429- a.monitorDirty = true
430- return a.scheduleMonitorApply()
431-}
432-
433-func (a *App) selectedMonitor() *services.MonitorConfig {
434- if a.monitorSelected < 0 || a.monitorSelected >= len(a.monitorConfigs) {
435- return nil
436- }
437- return &a.monitorConfigs[a.monitorSelected]
438-}
439-
440-func clampScale(scale float64) float64 {
441- if scale < 0.5 {
442- return 0.5
443- }
444- if scale > 3.0 {
445- return 3.0
446- }
447- return scale
448-}
449-
450-func min(a, b int) int {
451- if a < b {
452- return a
453- }
454- return b
455-}
456-
457-func max(a, b int) int {
458- if a > b {
459- return a
460- }
461- return b
462-}
463-
464-type wizardInstallMsg struct {
465- err error
466-}
467-
468-type wizardMonitorMsg struct {
469- monitors []services.MonitorOutput
470- err error
471-}
472-
473-type wizardConfigMsg struct {
474- err error
475-}
476-
477-type monitorApplyTickMsg struct {
478- seq int
479-}
480-
481-type monitorAppliedMsg struct {
482- seq int
483- err error
484-}
@@ -1,484 +0,0 @@
1-package tui
2-
3-import (
4- "context"
5- "time"
6-
7- "github.com/bnema/archup/internal/domain/ports"
8- "github.com/bnema/archup/internal/wizard/application/services"
9- "github.com/bnema/archup/internal/wizard/domain"
10- "github.com/bnema/archup/internal/wizard/interfaces/tui/views"
11- tea "github.com/charmbracelet/bubbletea"
12-)
13-
14-// App is the wizard TUI model.
15-type App struct {
16- service *services.WizardService
17- logger ports.Logger
18- config domain.DesktopConfig
19- screen Screen
20- errMsg string
21-
22- compositorIndex int
23- monitors []services.MonitorOutput
24- monitorConfigs []services.MonitorConfig
25- monitorSelected int
26- monitorModeIdx map[string]int
27- monitorDirty bool
28- monitorApplySeq int
29- monitorApplying bool
30- monitorErr string
31-}
32-
33-// Screen represents wizard screens.
34-type Screen string
35-
36-const (
37- ScreenWelcome Screen = "welcome"
38- ScreenCompositor Screen = "compositor"
39- ScreenSDDM Screen = "sddm"
40- ScreenOptional Screen = "optional"
41- ScreenConfirm Screen = "confirm"
42- ScreenInstall Screen = "install"
43- ScreenMonitor Screen = "monitor"
44- ScreenConfig Screen = "config"
45- ScreenComplete Screen = "complete"
46-)
47-
48-// NewApp creates a new wizard TUI app.
49-func NewApp(service *services.WizardService, logger ports.Logger) *App {
50- return &App{
51- service: service,
52- logger: logger,
53- config: domain.NewDesktopConfig(),
54- screen: ScreenWelcome,
55- }
56-}
57-
58-// Init initializes the TUI.
59-func (a *App) Init() tea.Cmd {
60- return nil
61-}
62-
63-// Update handles input events.
64-func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65- switch msg := msg.(type) {
66- case wizardInstallMsg:
67- if msg.err != nil {
68- a.errMsg = msg.err.Error()
69- a.screen = ScreenComplete
70- return a, nil
71- }
72- a.screen = ScreenMonitor
73- return a, a.detectMonitorsCmd()
74- case wizardMonitorMsg:
75- if msg.err != nil {
76- a.errMsg = msg.err.Error()
77- a.screen = ScreenComplete
78- return a, nil
79- }
80- a.monitors = msg.monitors
81- a.monitorConfigs = buildMonitorConfigs(msg.monitors)
82- a.monitorModeIdx = buildMonitorModeIndex(msg.monitors)
83- a.monitorSelected = 0
84- a.monitorDirty = false
85- a.monitorApplying = false
86- a.monitorErr = ""
87- return a, nil
88- case monitorApplyTickMsg:
89- if msg.seq != a.monitorApplySeq || !a.monitorDirty {
90- return a, nil
91- }
92- a.monitorDirty = false
93- a.monitorApplying = true
94- return a, a.applyMonitorConfigCmd(msg.seq)
95- case monitorAppliedMsg:
96- if msg.seq != a.monitorApplySeq {
97- return a, nil
98- }
99- a.monitorApplying = false
100- a.monitorErr = ""
101- if msg.err != nil {
102- a.monitorErr = msg.err.Error()
103- a.monitorDirty = true
104- return a, a.scheduleMonitorApply()
105- }
106- return a, nil
107- case wizardConfigMsg:
108- if msg.err != nil {
109- a.errMsg = msg.err.Error()
110- }
111- a.screen = ScreenComplete
112- return a, nil
113- case tea.KeyMsg:
114- return a.handleKey(msg)
115- }
116- return a, nil
117-}
118-
119-// View renders the wizard screen.
120-func (a *App) View() string {
121- switch a.screen {
122- case ScreenWelcome:
123- return views.RenderWelcome(a.config)
124- case ScreenCompositor:
125- return views.RenderCompositor(a.config, a.compositorIndex)
126- case ScreenSDDM:
127- return views.RenderSDDM(a.config)
128- case ScreenOptional:
129- return views.RenderOptional(a.config)
130- case ScreenConfirm:
131- return views.RenderConfirm(a.config)
132- case ScreenInstall:
133- return views.RenderInstall()
134- case ScreenMonitor:
135- return views.RenderMonitors(a.monitors, a.monitorConfigs, a.monitorSelected, a.monitorApplying, a.monitorDirty, a.monitorErr)
136- case ScreenConfig:
137- return views.RenderApplyConfig()
138- case ScreenComplete:
139- return views.RenderComplete(a.config, a.errMsg)
140- default:
141- return views.RenderWelcome(a.config)
142- }
143-}
144-
145-func (a *App) startCmd() tea.Cmd {
146- return func() tea.Msg {
147- err := a.service.Install(context.Background(), a.config)
148- if err != nil {
149- a.logger.Error("Wizard start failed", "error", err)
150- }
151- return wizardInstallMsg{err: err}
152- }
153-}
154-
155-func (a *App) detectMonitorsCmd() tea.Cmd {
156- return func() tea.Msg {
157- monitors, err := a.service.DetectMonitors(context.Background())
158- if err != nil {
159- a.logger.Error("Monitor detection failed", "error", err)
160- }
161- return wizardMonitorMsg{monitors: monitors, err: err}
162- }
163-}
164-
165-func (a *App) applyConfigCmd() tea.Cmd {
166- return func() tea.Msg {
167- err := a.service.ApplyConfigWithMonitors(a.config, a.monitorConfigs)
168- if err != nil {
169- a.logger.Error("Wizard config apply failed", "error", err)
170- }
171- return wizardConfigMsg{err: err}
172- }
173-}
174-
175-func (a *App) applyMonitorConfigCmd(seq int) tea.Cmd {
176- configs := append([]services.MonitorConfig{}, a.monitorConfigs...)
177- return func() tea.Msg {
178- err := a.service.ApplyMonitorConfig(context.Background(), configs)
179- return monitorAppliedMsg{seq: seq, err: err}
180- }
181-}
182-
183-func (a *App) scheduleMonitorApply() tea.Cmd {
184- a.monitorApplySeq++
185- seq := a.monitorApplySeq
186- return tea.Tick(250*time.Millisecond, func(time.Time) tea.Msg {
187- return monitorApplyTickMsg{seq: seq}
188- })
189-}
190-
191-func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
192- switch msg.String() {
193- case "q", "ctrl+c":
194- return a, tea.Quit
195- }
196-
197- switch a.screen {
198- case ScreenWelcome:
199- if msg.String() == "enter" {
200- a.screen = ScreenCompositor
201- }
202- case ScreenCompositor:
203- switch msg.String() {
204- case "up", "shift+tab":
205- a.moveCompositor(-1)
206- case "down", "tab":
207- a.moveCompositor(1)
208- case "enter":
209- a.applyCompositorSelection()
210- a.screen = ScreenSDDM
211- }
212- case ScreenSDDM:
213- switch msg.String() {
214- case " ":
215- a.config.EnableSDDM = !a.config.EnableSDDM
216- if !a.config.EnableSDDM {
217- a.config.AutoLogin = false
218- return a, nil
219- }
220- case "a":
221- if a.config.EnableSDDM {
222- a.config.AutoLogin = !a.config.AutoLogin
223- }
224- case "enter":
225- a.screen = ScreenOptional
226- }
227- case ScreenOptional:
228- switch msg.String() {
229- case " ":
230- a.config.InstallCliphist = !a.config.InstallCliphist
231- case "enter":
232- a.screen = ScreenConfirm
233- }
234- case ScreenConfirm:
235- switch msg.String() {
236- case "enter":
237- a.screen = ScreenInstall
238- return a, a.startCmd()
239- case "b":
240- a.screen = ScreenOptional
241- }
242- case ScreenInstall:
243- return a, nil
244- case ScreenMonitor:
245- return a.handleMonitorKey(msg)
246- case ScreenConfig:
247- return a, nil
248- case ScreenComplete:
249- if msg.String() == "enter" {
250- return a, tea.Quit
251- }
252- }
253-
254- return a, nil
255-}
256-
257-func (a *App) moveCompositor(delta int) {
258- options := compositorOptions()
259- count := len(options)
260- if count == 0 {
261- return
262- }
263- idx := (a.compositorIndex + delta) % count
264- if idx < 0 {
265- idx = count - 1
266- }
267- a.compositorIndex = idx
268-}
269-
270-func (a *App) applyCompositorSelection() {
271- options := compositorOptions()
272- if len(options) == 0 {
273- return
274- }
275- if a.compositorIndex < 0 || a.compositorIndex >= len(options) {
276- a.compositorIndex = 0
277- }
278- a.config.Compositor = options[a.compositorIndex]
279-}
280-
281-func compositorOptions() []domain.Compositor {
282- return []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
283-}
284-
285-func buildMonitorConfigs(monitors []services.MonitorOutput) []services.MonitorConfig {
286- configs := make([]services.MonitorConfig, 0, len(monitors))
287- for _, monitor := range monitors {
288- cfg := services.MonitorConfig{
289- Name: monitor.Name,
290- Enabled: monitor.Enabled,
291- Scale: 1,
292- }
293- if monitor.CurrentMode != nil {
294- cfg.Width = monitor.CurrentMode.Width
295- cfg.Height = monitor.CurrentMode.Height
296- cfg.Refresh = monitor.CurrentMode.Refresh
297- }
298- configs = append(configs, cfg)
299- }
300- return configs
301-}
302-
303-func buildMonitorModeIndex(monitors []services.MonitorOutput) map[string]int {
304- index := make(map[string]int, len(monitors))
305- for _, monitor := range monitors {
306- idx := 0
307- if monitor.CurrentMode != nil {
308- idx = findModeIndex(monitor.Modes, *monitor.CurrentMode)
309- }
310- index[monitor.Name] = idx
311- }
312- return index
313-}
314-
315-func findModeIndex(modes []services.MonitorMode, current services.MonitorMode) int {
316- for i, mode := range modes {
317- if mode.Width == current.Width && mode.Height == current.Height && floatEquals(mode.Refresh, current.Refresh) {
318- return i
319- }
320- }
321- return 0
322-}
323-
324-func floatEquals(a, b float64) bool {
325- if a == b {
326- return true
327- }
328- if a > b {
329- return a-b < 0.05
330- }
331- return b-a < 0.05
332-}
333-
334-func (a *App) cycleMonitorMode(delta int) {
335- selected := a.selectedMonitor()
336- if selected == nil {
337- return
338- }
339- var modes []services.MonitorMode
340- for _, monitor := range a.monitors {
341- if monitor.Name == selected.Name {
342- modes = monitor.Modes
343- break
344- }
345- }
346- if len(modes) == 0 {
347- return
348- }
349- current := a.monitorModeIdx[selected.Name]
350- current = (current + delta) % len(modes)
351- if current < 0 {
352- current = len(modes) - 1
353- }
354- a.monitorModeIdx[selected.Name] = current
355- mode := modes[current]
356- selected.Width = mode.Width
357- selected.Height = mode.Height
358- selected.Refresh = mode.Refresh
359-}
360-
361-func (a *App) handleMonitorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
362- switch msg.String() {
363- case "enter":
364- a.screen = ScreenConfig
365- return a, a.applyConfigCmd()
366- case "up", "shift+tab":
367- a.monitorSelected = max(0, a.monitorSelected-1)
368- case "down", "tab":
369- if len(a.monitorConfigs) > 0 {
370- a.monitorSelected = min(len(a.monitorConfigs)-1, a.monitorSelected+1)
371- }
372- case " ":
373- if a.selectedMonitor() != nil {
374- a.selectedMonitor().Enabled = !a.selectedMonitor().Enabled
375- return a, a.markMonitorDirty()
376- }
377- case "m":
378- if a.selectedMonitor() != nil {
379- a.cycleMonitorMode(1)
380- return a, a.markMonitorDirty()
381- }
382- case "shift+m":
383- if a.selectedMonitor() != nil {
384- a.cycleMonitorMode(-1)
385- return a, a.markMonitorDirty()
386- }
387- case "left", "h":
388- if a.selectedMonitor() != nil {
389- a.selectedMonitor().PosX -= 50
390- return a, a.markMonitorDirty()
391- }
392- case "right", "l":
393- if a.selectedMonitor() != nil {
394- a.selectedMonitor().PosX += 50
395- return a, a.markMonitorDirty()
396- }
397- case "k":
398- if a.selectedMonitor() != nil {
399- a.selectedMonitor().PosY -= 50
400- return a, a.markMonitorDirty()
401- }
402- case "j":
403- if a.selectedMonitor() != nil {
404- a.selectedMonitor().PosY += 50
405- return a, a.markMonitorDirty()
406- }
407- case "r":
408- if a.selectedMonitor() != nil {
409- a.selectedMonitor().PosX = 0
410- a.selectedMonitor().PosY = 0
411- return a, a.markMonitorDirty()
412- }
413- case "+", "=":
414- if a.selectedMonitor() != nil {
415- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale + 0.1)
416- return a, a.markMonitorDirty()
417- }
418- case "-", "_":
419- if a.selectedMonitor() != nil {
420- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale - 0.1)
421- return a, a.markMonitorDirty()
422- }
423- }
424-
425- return a, nil
426-}
427-
428-func (a *App) markMonitorDirty() tea.Cmd {
429- a.monitorDirty = true
430- return a.scheduleMonitorApply()
431-}
432-
433-func (a *App) selectedMonitor() *services.MonitorConfig {
434- if a.monitorSelected < 0 || a.monitorSelected >= len(a.monitorConfigs) {
435- return nil
436- }
437- return &a.monitorConfigs[a.monitorSelected]
438-}
439-
440-func clampScale(scale float64) float64 {
441- if scale < 0.5 {
442- return 0.5
443- }
444- if scale > 3.0 {
445- return 3.0
446- }
447- return scale
448-}
449-
450-func min(a, b int) int {
451- if a < b {
452- return a
453- }
454- return b
455-}
456-
457-func max(a, b int) int {
458- if a > b {
459- return a
460- }
461- return b
462-}
463-
464-type wizardInstallMsg struct {
465- err error
466-}
467-
468-type wizardMonitorMsg struct {
469- monitors []services.MonitorOutput
470- err error
471-}
472-
473-type wizardConfigMsg struct {
474- err error
475-}
476-
477-type monitorApplyTickMsg struct {
478- seq int
479-}
480-
481-type monitorAppliedMsg struct {
482- seq int
483- err error
484-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close