diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index c355036..0740267 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -1,15 +1,11 @@ package main import ( - "fmt" "log" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" - "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui" ) // version is set via -ldflags at build time. @@ -24,7 +20,11 @@ func main() { } log.Printf("Data directory: %s", root) - // Try to restore an existing session. + settings, err := config.Load() + if err != nil { + log.Fatalf("Failed to load settings: %v", err) + } + client, err := auth.NewFromConfig() if err != nil { log.Fatalf("Failed to create auth client: %v", err) @@ -41,15 +41,5 @@ func main() { log.Println("No valid session — login required") } - // Bootstrap Fyne UI. - a := app.New() - w := a.NewWindow(fmt.Sprintf("MrixsCraft %s", version)) - w.Resize(fyne.NewSize(900, 600)) - w.CenterOnScreen() - - w.SetContent(container.NewVBox( - widget.NewLabel(fmt.Sprintf("MrixsCraft Launcher %s", version)), - )) - - w.ShowAndRun() + ui.Launch(client, sess, settings) } diff --git a/internal/ui/components/components.go b/internal/ui/components/components.go index ecb3040..a7f9ec5 100644 --- a/internal/ui/components/components.go +++ b/internal/ui/components/components.go @@ -1,2 +1,94 @@ -// package components provides reusable Fyne widgets (avatar, progress bar). +// package components provides reusable Fyne widgets for the launcher UI. package components + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// ServerCard displays a single modpack entry in the sidebar. +func ServerCard(name string, selected bool, onTap func()) fyne.CanvasObject { + bg := canvas.NewRectangle(color.Transparent) + bg.SetMinSize(fyne.NewSize(180, 48)) + + label := widget.NewLabel(name) + label.TextStyle.Bold = selected + + content := container.NewBorder(nil, nil, + nil, nil, + label, + ) + + card := container.NewStack(bg, content) + + btn := widget.NewButton("", onTap) + btn.Importance = widget.LowImportance + + return container.NewStack(card, btn) +} + +// ProgressBar wraps widget.ProgressBar with show/hide helpers. +type ProgressBar struct { + *widget.ProgressBar + hidden bool +} + +// NewProgressBar creates a new progress bar initialized to 0 and hidden. +func NewProgressBar() *ProgressBar { + pb := widget.NewProgressBar() + pb.Hide() + return &ProgressBar{ + ProgressBar: pb, + hidden: true, + } +} + +// SetProgress sets the value [0…1] and shows the bar. +func (pb *ProgressBar) SetProgress(v float64) { + pb.SetValue(v) + if pb.hidden { + pb.Show() + pb.hidden = false + } +} + +// Done hides the progress bar and resets it. +func (pb *ProgressBar) Done() { + pb.SetValue(0) + pb.Hide() + pb.hidden = true +} + +// PlayButton creates the accent "PLAY" button. +func PlayButton(onTap func()) *widget.Button { + btn := widget.NewButton("▶ PLAY", onTap) + btn.Importance = widget.HighImportance + return btn +} + +// SettingsButton creates the small settings (gear) button. +func SettingsButton(onTap func()) *widget.Button { + btn := widget.NewButton("⚙", onTap) + btn.Importance = widget.LowImportance + return btn +} + +// LogoutButton creates the logout button. +func LogoutButton(onTap func()) *widget.Button { + btn := widget.NewButton("Выйти", onTap) + btn.Importance = widget.LowImportance + return btn +} + +// AvatarImage returns a placeholder avatar (64×64 colored square). +// TODO: render 8×64 face from skin PNG. +func AvatarImage() *canvas.Image { + img := canvas.NewImageFromResource(nil) + img.SetMinSize(fyne.NewSize(64, 64)) + img.FillMode = canvas.ImageFillContain + return img +} diff --git a/internal/ui/screens/screens.go b/internal/ui/screens/screens.go index a265dbd..8c45863 100644 --- a/internal/ui/screens/screens.go +++ b/internal/ui/screens/screens.go @@ -1,2 +1,193 @@ // package screens implements application screens (login, main menu, settings). package screens + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/components" +) + +// Modpack represents a single server/modpack entry from the backend. +type Modpack struct { + Slug string + Name string +} + +// MainScreen builds the primary launcher window content. +func MainScreen( + w fyne.Window, + client *auth.Client, + session *auth.Session, + serverList []Modpack, + onPlay func(), + onSettings func(), +) fyne.CanvasObject { + + // ── Sidebar (Left) ────────────────────────────────────── + serverLabels := make([]*widget.Label, len(serverList)) + serverContainer := container.NewVBox() + + for i, sp := range serverList { + label := widget.NewLabel(sp.Name) + serverLabels[i] = label + index := i + card := components.ServerCard(sp.Name, index == 0, func() { + for j, l := range serverLabels { + l.TextStyle.Bold = (j == index) + l.Refresh() + } + }) + serverContainer.Add(card) + } + + sidebar := container.NewVScroll(serverContainer) + + // ── Center (background + info) ────────────────────────── + bg := widget.NewRichTextFromMarkdown( + "# Welcome to MrixsCraft\n\n" + + "Select a server from the left panel and press **PLAY**.\n\n" + + "---\n\n" + + "*Version: dev*", + ) + center := container.NewMax(widget.NewCard("", "", bg)) + + // ── Bottom bar ────────────────────────────────────────── + avatar := components.AvatarImage() + + displayName := "Not logged in" + if session != nil && session.Username != "" { + displayName = session.Username + } + usernameLabel := widget.NewLabel(displayName) + + logoutBtn := components.LogoutButton(func() { + dialog.ShowConfirm( + "Logout", + "Are you sure you want to log out?", + func(confirmed bool) { + if confirmed { + _ = auth.Delete() + w.Close() + } + }, + w, + ) + }) + + bottomLeft := container.NewHBox( + avatar, + usernameLabel, + layout.NewSpacer(), + logoutBtn, + ) + + progress := components.NewProgressBar() + + playBtn := components.PlayButton(onPlay) + settingsBtn := components.SettingsButton(onSettings) + + bottomRight := container.NewHBox( + settingsBtn, + widget.NewSeparator(), + playBtn, + ) + + bottomCenter := container.NewMax(progress) + + bottom := container.NewBorder( + nil, nil, + bottomLeft, + bottomRight, + bottomCenter, + ) + + // ── Assemble ──────────────────────────────────────────── + return container.NewBorder( + nil, + bottom, + sidebar, + nil, + center, + ) +} + +// LoginScreen builds a modal login form. +func LoginScreen(w fyne.Window, client *auth.Client, onLogin func(*auth.Session)) { + username := widget.NewEntry() + username.SetPlaceHolder("Username or email") + + password := widget.NewPasswordEntry() + password.SetPlaceHolder("Password") + + form := widget.NewForm( + widget.NewFormItem("Username", username), + widget.NewFormItem("Password", password), + ) + + dialog.ShowCustomConfirm( + "Login — MrixsCraft", + "Login", + "Cancel", + form, + func(confirmed bool) { + if !confirmed { + return + } + sess, err := client.Authenticate(username.Text, password.Text) + if err != nil { + dialog.ShowError(err, w) + return + } + if err := sess.Save(); err != nil { + dialog.ShowError(err, w) + return + } + onLogin(sess) + }, + w, + ) +} + +// SettingsScreen builds the settings modal. +func SettingsScreen(w fyne.Window, onSave func(memoryMB int, extraArgs string)) { + memorySlider := widget.NewSlider(1024, 16384) + memorySlider.Step = 512 + memorySlider.Value = 4096 + + memLabel := widget.NewLabel("4096 MB") + memorySlider.OnChanged = func(v float64) { + memLabel.SetText(fmt.Sprintf("%.0f MB", v)) + } + + extraArgs := widget.NewMultiLineEntry() + extraArgs.SetPlaceHolder("e.g. -XX:+UseG1GC -XX:MaxGCPauseMillis=50") + + content := container.NewVBox( + widget.NewLabelWithStyle("Memory (RAM)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + memLabel, + memorySlider, + widget.NewSeparator(), + widget.NewLabelWithStyle("Extra JVM arguments", fyne.TextAlignLeading, fyne.TextStyle{}), + extraArgs, + ) + + dialog.ShowCustomConfirm( + "Settings", + "Save", + "Cancel", + content, + func(confirmed bool) { + if confirmed { + onSave(int(memorySlider.Value), extraArgs.Text) + } + }, + w, + ) +} diff --git a/internal/ui/theme/theme.go b/internal/ui/theme/theme.go index 4657750..ca8d9da 100644 --- a/internal/ui/theme/theme.go +++ b/internal/ui/theme/theme.go @@ -1,2 +1,92 @@ -// package theme defines the custom Fyne theme (Minecraft-style colors, fonts). +// package theme defines a custom Fyne theme with Minecraft-inspired colors. package theme + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// MinecraftTheme is a dark theme inspired by Minecraft's UI palette. +type MinecraftTheme struct{} + +var _ fyne.Theme = (*MinecraftTheme)(nil) + +// Color returns the named color from the Minecraft palette. +func (t *MinecraftTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { + switch name { + case theme.ColorNameBackground: + return color.RGBA{R: 30, G: 30, B: 30, A: 255} // dark stone + case theme.ColorNameButton: + return color.RGBA{R: 100, G: 100, B: 100, A: 255} // stone gray + case theme.ColorNameDisabled: + return color.RGBA{R: 80, G: 80, B: 80, A: 255} + case theme.ColorNameDisabledButton: + return color.RGBA{R: 60, G: 60, B: 60, A: 255} + case theme.ColorNameError: + return color.RGBA{R: 200, G: 50, B: 50, A: 255} + case theme.ColorNameFocus: + return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green accent + case theme.ColorNameForeground: + return color.RGBA{R: 220, G: 220, B: 220, A: 255} // light gray + case theme.ColorNameHover: + return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green + case theme.ColorNameInputBackground: + return color.RGBA{R: 50, G: 50, B: 50, A: 255} + case theme.ColorNameInputBorder: + return color.RGBA{R: 80, G: 80, B: 80, A: 255} + case theme.ColorNameMenuBackground: + return color.RGBA{R: 40, G: 40, B: 40, A: 255} + case theme.ColorNameOverlayBackground: + return color.RGBA{R: 0, G: 0, B: 0, A: 180} + case theme.ColorNamePlaceHolder: + return color.RGBA{R: 150, G: 150, B: 150, A: 255} + case theme.ColorNamePressed: + return color.RGBA{R: 56, G: 142, B: 60, A: 255} // dark green + case theme.ColorNamePrimary: + return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green + case theme.ColorNameScrollBar: + return color.RGBA{R: 80, G: 80, B: 80, A: 200} + case theme.ColorNameSeparator: + return color.RGBA{R: 60, G: 60, B: 60, A: 255} + case theme.ColorNameShadow: + return color.RGBA{R: 0, G: 0, B: 0, A: 128} + default: + return theme.DefaultTheme().Color(name, variant) + } +} + +// Font returns the default font (custom TTF can be added later). +func (t *MinecraftTheme) Font(style fyne.TextStyle) fyne.Resource { + return theme.DefaultTheme().Font(style) +} + +// Icon returns the named icon resource. +func (t *MinecraftTheme) Icon(name fyne.ThemeIconName) fyne.Resource { + return theme.DefaultTheme().Icon(name) +} + +// Size returns the named size value. +func (t *MinecraftTheme) Size(name fyne.ThemeSizeName) float32 { + switch name { + case theme.SizeNameCaptionText: + return 11 + case theme.SizeNameInlineIcon: + return 20 + case theme.SizeNamePadding: + return 4 + case theme.SizeNameScrollBar: + return 12 + case theme.SizeNameScrollBarSmall: + return 4 + case theme.SizeNameText: + return 14 + case theme.SizeNameHeadingText: + return 24 + case theme.SizeNameSubHeadingText: + return 18 + default: + return theme.DefaultTheme().Size(name) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index f915cce..2c4e93a 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,2 +1,55 @@ -// package ui contains Fyne GUI code (main window, theme, navigation). +// package ui contains the Fyne GUI bootstrap and wiring. package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/screens" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/theme" +) + +// Launch initialises the Fyne application with the Minecraft theme and shows the main window. +func Launch(client *auth.Client, session *auth.Session, settings config.Settings) { + a := app.New() + a.Settings().SetTheme(&theme.MinecraftTheme{}) + + w := a.NewWindow("MrixsCraft") + w.Resize(fyne.NewSize(float32(settings.Width), float32(settings.Height))) + w.CenterOnScreen() + + // TODO: fetch from /api/servers.json + serverList := []screens.Modpack{ + {Slug: "hitech", Name: "HiTech 1.21"}, + {Slug: "vanilla", Name: "Vanilla 1.20"}, + } + + onPlay := func() { + // TODO: launch selected modpack + } + + onSettings := func() { + screens.SettingsScreen(w, func(memoryMB int, extraArgs string) { + settings.MemoryMB = memoryMB + settings.ExtraArgs = extraArgs + _ = config.Save(settings) + }) + } + + content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings) + w.SetContent(content) + + // If no session, show login modal after the window is rendered. + if session == nil { + w.Show() + screens.LoginScreen(w, client, func(sess *auth.Session) { + // Refresh UI with logged-in state. + content := screens.MainScreen(w, client, sess, serverList, onPlay, onSettings) + w.SetContent(content) + }) + } + + w.ShowAndRun() +}