Fiw-Gentoo-Dots/cmd/dots/main.go

403 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type selection struct {
Name string `json:"name"`
Profile string `json:"profile"`
Groups []string `json:"groups"`
Configs []string `json:"configs"`
Kernel string `json:"kernel"`
Bootloader string `json:"bootloader"`
Extras []string `json:"extras"`
Firefox bool `json:"firefox_privacy"`
Spotify bool `json:"spotify_custom"`
}
type config struct {
Label string `json:"label"`
Group string `json:"group"`
}
type catalog struct {
Groups []string `json:"groups"`
Configs map[string]config `json:"configs"`
Extras map[string]string `json:"extras"`
Stock selection `json:"stock"`
Ryzen selection `json:"ryzen"`
}
type row struct {
id, label string
checked bool
}
type previewMsg struct {
content string
err error
}
type model struct {
repo string
catalog catalog
selection selection
rows []row
stage, cursor, height, scroll int
preview, errorText, action string
done, loading bool
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func (m model) Init() tea.Cmd { return nil }
func (m *model) prepare() {
m.cursor, m.scroll = 0, 0
m.rows = nil
switch m.stage {
case 0:
m.rows = []row{{"stock", "Stock — binary kernel, portable build settings", m.selection.Profile == "stock"}, {"fiw-ryzen", "Fiw's Ryzen — current Zen 5 tuning, custom + binary fallback", m.selection.Profile == "fiw-ryzen"}}
if m.selection.Profile == "fiw-ryzen" {
m.cursor = 1
}
case 1:
for _, id := range m.catalog.Groups {
label := id
if id == "tidewm" {
label += " (optional)"
}
m.rows = append(m.rows, row{id, label, contains(m.selection.Groups, id)})
}
case 2:
ids := make([]string, 0, len(m.catalog.Configs))
for id := range m.catalog.Configs {
if id != "autostart" {
ids = append(ids, id)
}
}
sort.Strings(ids)
for _, id := range ids {
c := m.catalog.Configs[id]
m.rows = append(m.rows, row{id, c.Label, contains(m.selection.Configs, id)})
}
case 3:
m.rows = []row{
{"binary", "Kernel: generic binary", m.selection.Kernel == "binary"},
{"custom", "Kernel: custom + generic binary fallback", m.selection.Kernel == "custom"},
{"keep", "Bootloader: keep existing", m.selection.Bootloader == "keep"},
{"limine", "Bootloader: optional Limine", m.selection.Bootloader == "limine"},
{"grub", "Bootloader: optional GRUB", m.selection.Bootloader == "grub"},
}
for _, id := range []string{"nvidia", "btrfs", "filelight"} {
m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)})
}
m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify})
}
}
func (m *model) remember() {
switch m.stage {
case 1:
m.selection.Groups = []string{}
for _, r := range m.rows {
if r.checked {
m.selection.Groups = append(m.selection.Groups, r.id)
}
}
case 2:
autostart := contains(m.selection.Configs, "autostart")
m.selection.Configs = []string{}
for _, r := range m.rows {
if r.checked {
m.selection.Configs = append(m.selection.Configs, r.id)
}
}
if autostart {
m.selection.Configs = append(m.selection.Configs, "autostart")
}
case 3:
m.selection.Extras = []string{}
for _, r := range m.rows {
if _, ok := m.catalog.Extras[r.id]; ok && r.checked {
m.selection.Extras = append(m.selection.Extras, r.id)
}
if r.checked && (r.id == "binary" || r.id == "custom") {
m.selection.Kernel = r.id
}
if r.checked && (r.id == "keep" || r.id == "limine" || r.id == "grub") {
m.selection.Bootloader = r.id
}
if r.id == "firefox" {
m.selection.Firefox = r.checked
}
if r.id == "spotify" {
m.selection.Spotify = r.checked
}
}
configNames := []string{}
for _, id := range m.selection.Configs {
if id != "autostart" {
configNames = append(configNames, id)
}
}
for _, r := range m.rows {
if r.id == "autostart" && r.checked {
configNames = append(configNames, "autostart")
}
}
m.selection.Configs = configNames
}
}
func (m model) makePreview() tea.Cmd {
return func() tea.Msg {
file, err := os.CreateTemp("", "fiw-dots-selection-*.json")
if err != nil {
return previewMsg{err: err}
}
defer os.Remove(file.Name())
data, err := json.Marshal(m.selection)
if err == nil {
_, err = file.Write(data)
}
file.Close()
if err != nil {
return previewMsg{err: err}
}
cmd := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--plan", "--selection", file.Name())
output, err := cmd.CombinedOutput()
return previewMsg{string(output), err}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch v := msg.(type) {
case tea.WindowSizeMsg:
m.height = v.Height
case previewMsg:
m.loading = false
m.preview = v.content
if v.err != nil {
m.errorText = v.err.Error()
}
case tea.KeyMsg:
key := v.String()
if key == "ctrl+c" || key == "q" {
return m, tea.Quit
}
if m.loading {
return m, nil
}
if m.stage == 4 {
switch key {
case "up", "k":
if m.scroll > 0 {
m.scroll--
}
case "down", "j":
if m.scroll < len(strings.Split(m.preview, "\n"))-1 {
m.scroll++
}
case "esc":
m.stage--
m.prepare()
case "enter", "s", "a", "i":
if m.errorText == "" {
m.done = true
m.action = key
return m, tea.Quit
}
}
return m, nil
}
switch key {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
case " ":
if len(m.rows) == 0 {
break
}
if m.stage == 0 {
if m.rows[m.cursor].id == "stock" {
m.selection = m.catalog.Stock
} else {
m.selection = m.catalog.Ryzen
}
m.prepare()
} else {
id := m.rows[m.cursor].id
if m.stage == 3 && (id == "binary" || id == "custom") {
for i := range m.rows {
if m.rows[i].id == "binary" || m.rows[i].id == "custom" {
m.rows[i].checked = false
}
}
m.rows[m.cursor].checked = true
} else if m.stage == 3 && (id == "keep" || id == "limine" || id == "grub") {
for i := range m.rows {
if m.rows[i].id == "keep" || m.rows[i].id == "limine" || m.rows[i].id == "grub" {
m.rows[i].checked = false
}
}
m.rows[m.cursor].checked = true
} else {
m.rows[m.cursor].checked = !m.rows[m.cursor].checked
}
}
case "enter":
if m.stage == 0 {
if m.rows[m.cursor].id == "stock" {
m.selection = m.catalog.Stock
} else {
m.selection = m.catalog.Ryzen
}
}
m.remember()
m.stage++
if m.stage == 4 {
m.loading = true
m.errorText = ""
return m, m.makePreview()
}
m.prepare()
case "esc":
if m.stage > 0 {
m.remember()
m.stage--
m.prepare()
}
}
}
return m, nil
}
func (m model) View() string {
const purple = "\033[38;2;157;143;217m"
const reset = "\033[0m"
titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Final preview"}
text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n"
budget := m.height - 10
if budget < 5 {
budget = 5
}
if m.stage == 4 {
if m.loading {
return text + " Preparing preview…\n"
}
lines := strings.Split(m.preview, "\n")
end := m.scroll + budget
if end > len(lines) {
end = len(lines)
}
for _, line := range lines[m.scroll:end] {
text += " " + line + "\n"
}
text += "\n ↑/↓ scroll · Esc back · Enter save plan · a restore configs · i install packages\n q cancel\n"
if m.errorText != "" {
text += " " + m.errorText + "\n"
}
return text
}
start := 0
if m.cursor >= budget {
start = m.cursor - budget + 1
}
end := start + budget
if end > len(m.rows) {
end = len(m.rows)
}
for i := start; i < end; i++ {
r := m.rows[i]
mark := "[ ]"
if r.checked {
mark = "[✓]"
}
cursor := " "
if m.cursor == i {
cursor = "› "
}
line := " " + cursor + mark + " " + r.label
if m.cursor == i {
line = purple + line + reset
}
text += line + "\n"
}
return text + "\n ↑/↓ move · Space select · Enter next · Esc back · q cancel\n"
}
func main() {
repo, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output, err := exec.Command("python3", filepath.Join(repo, "lib", "rice.py"), "--catalog").Output()
if err != nil {
fmt.Fprintln(os.Stderr, "Cannot load the installer catalogue:", err)
os.Exit(1)
}
var c catalog
if err := json.Unmarshal(output, &c); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := model{repo: repo, catalog: c, selection: c.Stock, height: 28}
m.prepare()
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
final := result.(model)
if !final.done {
return
}
path := filepath.Join(repo, "local", "selection.json")
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
data, _ := json.MarshalIndent(final.selection, "", " ")
if err := os.WriteFile(path, append(data, '\n'), 0600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(final.preview)
fmt.Println("\nSaved selection:", path)
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path}
switch final.action {
case "a":
args = append(args, "--apply-configs")
case "i":
args = append([]string{"python3"}, args...)
args = append(args, "--install-packages")
default:
fmt.Println("Review the setup notes in docs/setup.md before applying.")
return
}
program := "python3"
if final.action == "i" {
program = "sudo"
}
cmd := exec.Command(program, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}