feat: add UI layer (theme, components, screens, main window)

- theme: MinecraftTheme — dark palette with green accents
- components: ServerCard, ProgressBar, PlayButton, SettingsButton, LogoutButton, AvatarImage
- screens: MainScreen (BorderLayout: sidebar + center + bottom bar), LoginScreen (modal form), SettingsScreen (RAM slider + JVM args)
- ui.Launch: wires theme, session, settings into Fyne app
- main.go: delegates to ui.Launch, loads config
- fix: nil-session guard in MainScreen username display

Co-Authored-By: OWL <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:22:41 +03:00
parent 070b5c0262
commit 1487360215
5 changed files with 436 additions and 20 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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()
}