package gui import ( "fmt" "image" "image/color" "net/url" "os/exec" "path/filepath" "runtime" "strings" "time" "gioui.org/app" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "vpn-client/internal/config" "vpn-client/internal/subscription" "vpn-client/internal/vless" "vpn-client/internal/vpn" "vpn-client/internal/wireguard" ) type tabID string const ( tabDashboard tabID = "dashboard" tabVLESS tabID = "vless" tabWireGuard tabID = "wireguard" tabSubscriptions tabID = "subscriptions" ) var ( bgColor = rgb(0x0A, 0x0F, 0x1F) panelColor = rgb(0x12, 0x19, 0x2E) panelAltColor = rgb(0x16, 0x22, 0x3B) accentColor = rgb(0x58, 0xE1, 0xC1) accentSoftColor = rgb(0x5E, 0x9D, 0xFF) textColor = rgb(0xF4, 0xF7, 0xFB) mutedColor = rgb(0x9C, 0xA9, 0xC2) dangerColor = rgb(0xFF, 0x7C, 0x7C) warningColor = rgb(0xFF, 0xC8, 0x57) successColor = rgb(0x6E, 0xF0, 0xAE) borderColor = rgb(0x24, 0x31, 0x4D) ) type ui struct { window *app.Window theme *material.Theme tab tabID statusState *config.ConnectionState configs *config.Configs subs *config.Subscriptions message string messageColor color.NRGBA busy bool async chan func() tabButtons map[tabID]*widget.Clickable refreshBtn widget.Clickable disconnectBtn widget.Clickable openLogsBtn widget.Clickable addConfigBtn widget.Clickable addSubBtn widget.Clickable testURLBtn widget.Clickable addWGManualBtn widget.Clickable addWGFileBtn widget.Clickable vlessConnectBtns []widget.Clickable vlessDeleteBtns []widget.Clickable vlessPingBtns []widget.Clickable wgConnectBtns []widget.Clickable wgDeleteBtns []widget.Clickable subUpdateBtns []widget.Clickable subDeleteBtns []widget.Clickable subTestBtns []widget.Clickable contentList layout.List vlessList layout.List wgList layout.List subList layout.List configNameEditor widget.Editor configURLEditor widget.Editor subNameEditor widget.Editor subURLEditor widget.Editor testURLEditor widget.Editor wgNameEditor widget.Editor wgConfigEditor widget.Editor wgFileNameEditor widget.Editor wgFilePathEditor widget.Editor } func Run(window *app.Window) error { model := newUI(window) var ops op.Ops for { switch evt := window.Event().(type) { case app.DestroyEvent: return evt.Err case app.FrameEvent: model.applyAsync() gtx := app.NewContext(&ops, evt) model.handleClicks(gtx) model.layout(gtx) evt.Frame(gtx.Ops) } } } func newUI(window *app.Window) *ui { th := material.NewTheme() th.Palette = material.Palette{ Bg: bgColor, Fg: textColor, ContrastBg: accentSoftColor, ContrastFg: textColor, } model := &ui{ window: window, theme: th, tab: tabDashboard, async: make(chan func(), 128), tabButtons: map[tabID]*widget.Clickable{ tabDashboard: new(widget.Clickable), tabVLESS: new(widget.Clickable), tabWireGuard: new(widget.Clickable), tabSubscriptions: new(widget.Clickable), }, contentList: layout.List{Axis: layout.Vertical}, vlessList: layout.List{Axis: layout.Vertical}, wgList: layout.List{Axis: layout.Vertical}, subList: layout.List{Axis: layout.Vertical}, } model.configNameEditor.SingleLine = true model.configURLEditor.SingleLine = true model.subNameEditor.SingleLine = true model.subURLEditor.SingleLine = true model.testURLEditor.SingleLine = true model.wgNameEditor.SingleLine = true model.wgFileNameEditor.SingleLine = true model.wgFilePathEditor.SingleLine = true model.configNameEditor.Submit = true model.configURLEditor.Submit = true model.subNameEditor.Submit = true model.subURLEditor.Submit = true model.testURLEditor.Submit = true model.wgNameEditor.Submit = true model.wgConfigEditor.Submit = true model.wgFileNameEditor.Submit = true model.wgFilePathEditor.Submit = true model.configURLEditor.SetText("vless://") model.testURLEditor.SetText("vless://") model.refresh() return model } func (u *ui) applyAsync() { for { select { case fn := <-u.async: fn() default: return } } } func (u *ui) handleClicks(gtx layout.Context) { for id, btn := range u.tabButtons { for btn.Clicked(gtx) { u.tab = id } } for u.refreshBtn.Clicked(gtx) { u.refresh() } for u.disconnectBtn.Clicked(gtx) { u.runAction("Disconnecting...", func() error { return vpn.Disconnect(config.LogsDir) }) } for u.openLogsBtn.Clicked(gtx) { u.openLogs() } for u.addConfigBtn.Clicked(gtx) { u.addVLESSConfig() } for u.addSubBtn.Clicked(gtx) { u.addSubscription() } for u.testURLBtn.Clicked(gtx) { u.testURL() } for u.addWGManualBtn.Clicked(gtx) { u.addWireGuardManual() } for u.addWGFileBtn.Clicked(gtx) { u.addWireGuardFromFile() } for i := range u.vlessConnectBtns { for u.vlessConnectBtns[i].Clicked(gtx) { if i < len(u.configs.VLESS) { name := u.configs.VLESS[i].Name u.runAction("Connecting to "+name+"...", func() error { return vless.Connect(name, config.LogsDir, config.XrayDir) }) } } for u.vlessDeleteBtns[i].Clicked(gtx) { if i < len(u.configs.VLESS) { name := u.configs.VLESS[i].Name u.deleteVLESSConfig(name) } } for u.vlessPingBtns[i].Clicked(gtx) { if i < len(u.configs.VLESS) { cfg := u.configs.VLESS[i] u.runAction("Testing "+cfg.Name+"...", func() error { ok, latency, err := vless.PingServer(cfg.URL, 4*time.Second) if err != nil { return err } if !ok { return fmt.Errorf("server is unreachable") } return fmt.Errorf("latency %.0f ms", latency) }) } } } for i := range u.wgConnectBtns { for u.wgConnectBtns[i].Clicked(gtx) { if i < len(u.configs.WireGuard) { name := u.configs.WireGuard[i].Name u.runAction("Connecting WireGuard "+name+"...", func() error { return wireguard.Connect(name, config.LogsDir) }) } } for u.wgDeleteBtns[i].Clicked(gtx) { if i < len(u.configs.WireGuard) { name := u.configs.WireGuard[i].Name u.deleteWireGuardConfig(name) } } } for i := range u.subUpdateBtns { for u.subUpdateBtns[i].Clicked(gtx) { if i < len(u.subs.Subscriptions) { name := u.subs.Subscriptions[i].Name u.runAction("Updating "+name+"...", func() error { return subscription.UpdateSubscription(name, config.LogsDir) }) } } for u.subDeleteBtns[i].Clicked(gtx) { if i < len(u.subs.Subscriptions) { name := u.subs.Subscriptions[i].Name u.deleteSubscription(name) } } for u.subTestBtns[i].Clicked(gtx) { if i < len(u.subs.Subscriptions) { name := u.subs.Subscriptions[i].Name u.runAction("Testing configs from "+name+"...", func() error { return u.testSubscriptionConfigs(name) }) } } } } func (u *ui) runAction(progress string, fn func() error) { if u.busy { return } u.busy = true u.message = progress u.messageColor = warningColor u.window.Invalidate() go func() { err := fn() u.async <- func() { u.busy = false if err != nil { if strings.HasPrefix(err.Error(), "latency ") { u.message = err.Error() u.messageColor = successColor } else { u.message = err.Error() u.messageColor = dangerColor } } else { u.message = "Action completed." u.messageColor = successColor } u.refresh() u.window.Invalidate() } }() } func (u *ui) refresh() { state, _ := vpn.GetStatus() cfgs, err := config.LoadConfigs() if err != nil || cfgs == nil { cfgs = &config.Configs{} } subs, err := config.LoadSubscriptions() if err != nil || subs == nil { subs = &config.Subscriptions{} } u.statusState = state u.configs = cfgs u.subs = subs u.ensureButtons() } func (u *ui) ensureButtons() { u.vlessConnectBtns = ensureClickables(u.vlessConnectBtns, len(u.configs.VLESS)) u.vlessDeleteBtns = ensureClickables(u.vlessDeleteBtns, len(u.configs.VLESS)) u.vlessPingBtns = ensureClickables(u.vlessPingBtns, len(u.configs.VLESS)) u.wgConnectBtns = ensureClickables(u.wgConnectBtns, len(u.configs.WireGuard)) u.wgDeleteBtns = ensureClickables(u.wgDeleteBtns, len(u.configs.WireGuard)) u.subUpdateBtns = ensureClickables(u.subUpdateBtns, len(u.subs.Subscriptions)) u.subDeleteBtns = ensureClickables(u.subDeleteBtns, len(u.subs.Subscriptions)) u.subTestBtns = ensureClickables(u.subTestBtns, len(u.subs.Subscriptions)) } func ensureClickables(list []widget.Clickable, count int) []widget.Clickable { if len(list) >= count { return list[:count] } return append(list, make([]widget.Clickable, count-len(list))...) } func (u *ui) addVLESSConfig() { name := strings.TrimSpace(u.configNameEditor.Text()) rawURL := strings.TrimSpace(u.configURLEditor.Text()) if name == "" || rawURL == "" { u.message = "Name and VLESS URL are required." u.messageColor = dangerColor return } if _, err := url.Parse(rawURL); err != nil { u.message = "The VLESS URL is not valid." u.messageColor = dangerColor return } cfgs, err := config.LoadConfigs() if err != nil { u.message = err.Error() u.messageColor = dangerColor return } for _, item := range cfgs.VLESS { if item.Name == name { u.message = "A config with this name already exists." u.messageColor = dangerColor return } } cfgs.VLESS = append(cfgs.VLESS, config.VLESSConfig{ Name: name, URL: rawURL, Protocol: "VLESS", }) if err := config.SaveConfigs(cfgs); err != nil { u.message = err.Error() u.messageColor = dangerColor return } u.configNameEditor.SetText("") u.configURLEditor.SetText("vless://") u.message = "VLESS config added." u.messageColor = successColor u.refresh() } func (u *ui) deleteVLESSConfig(name string) { cfgs, err := config.LoadConfigs() if err != nil { u.message = err.Error() u.messageColor = dangerColor return } filtered := cfgs.VLESS[:0] found := false for _, item := range cfgs.VLESS { if item.Name == name { found = true continue } filtered = append(filtered, item) } cfgs.VLESS = filtered if !found { u.message = "Config was already removed." u.messageColor = warningColor return } if err := config.SaveConfigs(cfgs); err != nil { u.message = err.Error() u.messageColor = dangerColor return } u.message = "Config removed." u.messageColor = successColor u.refresh() } func (u *ui) addSubscription() { name := strings.TrimSpace(u.subNameEditor.Text()) rawURL := strings.TrimSpace(u.subURLEditor.Text()) if name == "" || rawURL == "" { u.message = "Subscription name and URL are required." u.messageColor = dangerColor return } if _, err := url.Parse(rawURL); err != nil { u.message = "The subscription URL is not valid." u.messageColor = dangerColor return } subs, err := config.LoadSubscriptions() if err != nil { u.message = err.Error() u.messageColor = dangerColor return } for _, item := range subs.Subscriptions { if item.Name == name { u.message = "A subscription with this name already exists." u.messageColor = dangerColor return } } subs.Subscriptions = append(subs.Subscriptions, config.Subscription{ Name: name, URL: rawURL, }) if err := config.SaveSubscriptions(subs); err != nil { u.message = err.Error() u.messageColor = dangerColor return } u.subNameEditor.SetText("") u.subURLEditor.SetText("") u.message = "Subscription added." u.messageColor = successColor u.refresh() } func (u *ui) deleteSubscription(name string) { subs, err := config.LoadSubscriptions() if err != nil { u.message = err.Error() u.messageColor = dangerColor return } filtered := subs.Subscriptions[:0] found := false for _, item := range subs.Subscriptions { if item.Name == name { found = true continue } filtered = append(filtered, item) } subs.Subscriptions = filtered if !found { u.message = "Subscription was already removed." u.messageColor = warningColor return } if err := config.SaveSubscriptions(subs); err != nil { u.message = err.Error() u.messageColor = dangerColor return } u.message = "Subscription removed." u.messageColor = successColor u.refresh() } func (u *ui) testURL() { rawURL := strings.TrimSpace(u.testURLEditor.Text()) if rawURL == "" { u.message = "Paste a VLESS URL to test." u.messageColor = dangerColor return } u.runAction("Testing custom URL...", func() error { ok, latency, err := vless.PingServer(rawURL, 4*time.Second) if err != nil { return err } if !ok { return fmt.Errorf("server is unreachable") } return fmt.Errorf("latency %.0f ms", latency) }) } func (u *ui) openLogs() { path := filepath.Clean(config.LogsDir) var cmd *exec.Cmd switch runtime.GOOS { case "windows": cmd = exec.Command("explorer", path) case "darwin": cmd = exec.Command("open", path) default: cmd = exec.Command("xdg-open", path) } if err := cmd.Start(); err != nil { u.message = "Could not open logs folder." u.messageColor = dangerColor return } u.message = "Logs folder opened." u.messageColor = successColor } func (u *ui) layout(gtx layout.Context) layout.Dimensions { fill(gtx, bgColor) return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return u.contentList.Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(u.layoutHeader), layout.Rigid(spacerH(18)), layout.Rigid(u.layoutStatusBanner), layout.Rigid(spacerH(18)), layout.Flexed(1, u.layoutBody), ) }) }) } func (u *ui) layoutHeader(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceBetween, Alignment: layout.Middle}.Layout( gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(15), "GIO DESKTOP") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H3(u.theme, "Go VPN Client") lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, "A modern control surface for VLESS, subscriptions, and live connection state.") lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.refreshBtn, "Refresh", accentSoftColor) }), layout.Rigid(spacerW(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.openLogsBtn, "Logs", panelAltColor) }), ) }), ) } func (u *ui) layoutStatusBanner(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { inset := layout.UniformInset(unit.Dp(18)) return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { connected := u.statusState != nil && u.statusState.Connected statusText := "Offline" statusColor := warningColor details := "No active tunnel." if connected { statusText = "Connected" statusColor = successColor details = fmt.Sprintf("%s via %s", u.statusState.ConfigName, strings.ToUpper(u.statusState.ConfigType)) } return layout.Flex{Spacing: layout.SpaceBetween, Alignment: layout.Middle}.Layout( gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, "Connection status") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H4(u.theme, statusText) lbl.Color = statusColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, details) lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { msg := u.message if msg == "" { msg = "Ready." } lbl := material.Body2(u.theme, msg) lbl.Color = u.messageColor if lbl.Color == (color.NRGBA{}) { lbl.Color = mutedColor } return lbl.Layout(gtx) }), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.disconnectBtn, "Disconnect", dangerColor) }), ) }) }) } func (u *ui) layoutBody(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(u.layoutTabs), layout.Rigid(spacerH(16)), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { switch u.tab { case tabVLESS: return u.layoutVLESS(gtx) case tabWireGuard: return u.layoutWireGuard(gtx) case tabSubscriptions: return u.layoutSubscriptions(gtx) default: return u.layoutDashboard(gtx) } }), ) } func (u *ui) layoutTabs(gtx layout.Context) layout.Dimensions { return layout.Flex{}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutTab(gtx, tabDashboard, "Overview") }), layout.Rigid(spacerW(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutTab(gtx, tabVLESS, "VLESS") }), layout.Rigid(spacerW(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutTab(gtx, tabWireGuard, "WireGuard") }), layout.Rigid(spacerW(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutTab(gtx, tabSubscriptions, "Subscriptions") }), ) } func (u *ui) layoutTab(gtx layout.Context, id tabID, label string) layout.Dimensions { bg := panelAltColor fg := mutedColor if u.tab == id { bg = accentSoftColor fg = textColor } return u.buttonLike(gtx, u.tabButtons[id], label, bg, fg, unit.Dp(16), unit.Dp(10)) } func (u *ui) layoutDashboard(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceBetween}.Layout( gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.metricCard(gtx, "VLESS configs", fmt.Sprintf("%d", len(u.configs.VLESS)), accentColor) }), layout.Rigid(spacerW(12)), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.metricCard(gtx, "Subscriptions", fmt.Sprintf("%d", len(u.subs.Subscriptions)), accentSoftColor) }), layout.Rigid(spacerW(12)), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { state := "Offline" if u.statusState != nil && u.statusState.Connected { state = strings.ToUpper(u.statusState.ConfigType) } return u.metricCard(gtx, "Active mode", state, successColor) }), ) }), layout.Rigid(spacerH(16)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H5(u.theme, "Quick latency probe") lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(spacerH(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.testURLEditor, "Paste a VLESS URL") }), layout.Rigid(spacerH(12)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.testURLBtn, "Test URL", accentColor) }), ) }) }) }), ) } func (u *ui) layoutVLESS(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H5(u.theme, "Add VLESS config") lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(spacerH(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.configNameEditor, "Display name") }), layout.Rigid(spacerH(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.configURLEditor, "VLESS URL") }), layout.Rigid(spacerH(12)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.addConfigBtn, "Save config", accentColor) }), ) }) }) }), layout.Rigid(spacerH(16)), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return u.vlessList.Layout(gtx, len(u.configs.VLESS), func(gtx layout.Context, i int) layout.Dimensions { cfg := u.configs.VLESS[i] return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H6(u.theme, cfg.Name) lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { meta := material.Body2(u.theme, cfg.Protocol) meta.Color = accentColor return meta.Layout(gtx) }), layout.Rigid(spacerH(6)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { value := material.Body2(u.theme, trimForPreview(cfg.URL, 96)) value.Color = mutedColor return value.Layout(gtx) }), layout.Rigid(spacerH(12)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.vlessConnectBtns[i], "Connect", accentColor) }), layout.Rigid(spacerW(8)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.vlessPingBtns[i], "Ping", accentSoftColor) }), layout.Rigid(spacerW(8)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.vlessDeleteBtns[i], "Delete", dangerColor) }), ) }), ) }) }) }) }) }) }) }), ) } func (u *ui) layoutSubscriptions(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H5(u.theme, "Add subscription") lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(spacerH(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.subNameEditor, "Subscription name") }), layout.Rigid(spacerH(10)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.subURLEditor, "Subscription URL") }), layout.Rigid(spacerH(12)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.addSubBtn, "Save subscription", accentColor) }), ) }) }) }), layout.Rigid(spacerH(16)), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return u.subList.Layout(gtx, len(u.subs.Subscriptions), func(gtx layout.Context, i int) layout.Dimensions { sub := u.subs.Subscriptions[i] return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H6(u.theme, sub.Name) lbl.Color = textColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { value := material.Body2(u.theme, trimForPreview(sub.URL, 92)) value.Color = mutedColor return value.Layout(gtx) }), layout.Rigid(spacerH(12)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.subUpdateBtns[i], "Update configs", accentColor) }), layout.Rigid(spacerW(8)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutButton(gtx, &u.subDeleteBtns[i], "Delete", dangerColor) }), ) }), ) }) }) }) }) }) }) }), ) } func (u *ui) metricCard(gtx layout.Context, label, value string, glow color.NRGBA) layout.Dimensions { return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout( gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body2(u.theme, strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(spacerH(8)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H4(u.theme, value) lbl.Color = glow return lbl.Layout(gtx) }), ) }) }) } func (u *ui) layoutEditor(gtx layout.Context, ed *widget.Editor, hint string) layout.Dimensions { return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions { border := clip.RRect{ Rect: image.Rectangle{Max: gtx.Constraints.Min}, SE: 14, SW: 14, NW: 14, NE: 14, } _ = border return layout.UniformInset(unit.Dp(14)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return material.Editor(u.theme, ed, hint).Layout(gtx) }) }) } func (u *ui) layoutButton(gtx layout.Context, btn *widget.Clickable, label string, bg color.NRGBA) layout.Dimensions { return u.buttonLike(gtx, btn, label, bg, textColor, unit.Dp(14), unit.Dp(10)) } func (u *ui) buttonLike(gtx layout.Context, btn *widget.Clickable, label string, bg, fg color.NRGBA, hPad, vPad unit.Dp) layout.Dimensions { return btn.Layout(gtx, func(gtx layout.Context) layout.Dimensions { min := image.Pt(gtx.Dp(unit.Dp(96)), gtx.Dp(unit.Dp(40))) if gtx.Constraints.Min.X < min.X { gtx.Constraints.Min.X = min.X } if gtx.Constraints.Min.Y < min.Y { gtx.Constraints.Min.Y = min.Y } rr := gtx.Dp(unit.Dp(14)) defer clip.RRect{Rect: image.Rectangle{Max: gtx.Constraints.Min}, NW: rr, NE: rr, SW: rr, SE: rr}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, bg) return layout.Inset{ Top: vPad, Bottom: vPad, Left: hPad, Right: hPad, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), label) lbl.Color = fg lbl.Alignment = text.Middle return lbl.Layout(gtx) }) }) } func (u *ui) card(gtx layout.Context, bg color.NRGBA, content layout.Widget) layout.Dimensions { return layout.Stack{}.Layout( gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { rr := gtx.Dp(unit.Dp(20)) defer clip.RRect{Rect: image.Rectangle{Max: gtx.Constraints.Max}, NW: rr, NE: rr, SW: rr, SE: rr}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, bg) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(content), ) } func fill(gtx layout.Context, c color.NRGBA) { defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, c) } func spacerH(v unit.Dp) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: v}.Layout(gtx) } } func spacerW(v unit.Dp) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Width: v}.Layout(gtx) } } func trimForPreview(value string, limit int) string { if len(value) <= limit { return value } return value[:limit-3] + "..." } func rgb(r, g, b uint8) color.NRGBA { return color.NRGBA{R: r, G: g, B: b, A: 255} }