Files

1122 lines
33 KiB
Go
Raw Permalink 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 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
showModal bool
modalTitle string
modalBody string
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
closeModalBtn 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("Отключение...", 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 u.closeModalBtn.Clicked(gtx) {
u.hideModal()
}
for i := 0; i < minInt(len(u.configs.VLESS), len(u.vlessConnectBtns), len(u.vlessDeleteBtns), len(u.vlessPingBtns)); i++ {
for u.vlessConnectBtns[i].Clicked(gtx) {
if i < len(u.configs.VLESS) {
name := u.configs.VLESS[i].Name
u.runAction("Подключение к "+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("Тестирование "+cfg.Name+"...", func() error {
ok, latency, err := vless.PingServer(cfg.URL, 4*time.Second)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("сервер не найден")
}
u.showPingModal("<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>", cfg.Name, latency)
return nil
})
}
}
}
for i := 0; i < minInt(len(u.configs.WireGuard), len(u.wgConnectBtns), len(u.wgDeleteBtns)); i++ {
for u.wgConnectBtns[i].Clicked(gtx) {
if i < len(u.configs.WireGuard) {
name := u.configs.WireGuard[i].Name
u.runAction("Подключение 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 := 0; i < minInt(len(u.subs.Subscriptions), len(u.subUpdateBtns), len(u.subDeleteBtns), len(u.subTestBtns)); i++ {
for u.subUpdateBtns[i].Clicked(gtx) {
if i < len(u.subs.Subscriptions) {
name := u.subs.Subscriptions[i].Name
u.runAction("Обновление "+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("Тестирование конфигов из "+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(), "задержка ") {
u.message = err.Error()
u.messageColor = successColor
} else {
u.message = err.Error()
u.messageColor = dangerColor
}
} else {
u.message = "Действие выполнено."
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 = "Имя и URL VLESS не заполнены."
u.messageColor = dangerColor
return
}
if _, err := url.Parse(rawURL); err != nil {
u.message = "VLESS URL не действителен."
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 = "Конфиг с таким именем уже существует"
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 конфиг добавлен."
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 = "Конфиг уже удалён."
u.messageColor = warningColor
return
}
if err := config.SaveConfigs(cfgs); err != nil {
u.message = err.Error()
u.messageColor = dangerColor
return
}
u.message = "Конфиг удалён."
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 = "Имя и URL подписки не заполнены."
u.messageColor = dangerColor
return
}
if _, err := url.Parse(rawURL); err != nil {
u.message = "URL подписки не действителен."
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 = "Подписка с таким именем уже существует."
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 = "Подписка добавлена."
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 = "Подписка уже удалена."
u.messageColor = warningColor
return
}
if err := config.SaveSubscriptions(subs); err != nil {
u.message = err.Error()
u.messageColor = dangerColor
return
}
u.message = "Подписка удалена."
u.messageColor = successColor
u.refresh()
}
func (u *ui) testURL() {
rawURL := strings.TrimSpace(u.testURLEditor.Text())
if rawURL == "" {
u.message = "Вставьте VLESS ссылку для проверки."
u.messageColor = dangerColor
return
}
u.runAction("Пингуем кастомный URL...", func() error {
ok, latency, err := vless.PingServer(rawURL, 4*time.Second)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("сервер не найден")
}
u.showPingModal("<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>", "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> URL", latency)
return nil
})
}
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) showPingModal(title, target string, latency float64) {
u.async <- func() {
u.modalTitle = title
u.modalBody = fmt.Sprintf("%s\n\n<><6E><EFBFBD><EFBFBD>: %.0f <20><>", target, latency)
u.showModal = true
u.window.Invalidate()
}
}
func (u *ui) hideModal() {
u.showModal = false
u.modalTitle = ""
u.modalBody = ""
u.window.Invalidate()
}
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
fill(gtx, bgColor)
dims := 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),
)
})
})
if u.showModal {
u.layoutModal(gtx)
}
return dims
}
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), "POWERED BY NEVETIME")
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, "Современный клиент на Go для особой производительности и удобства")
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, "Обновить", accentSoftColor)
}),
layout.Rigid(spacerW(10)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.layoutButton(gtx, &u.openLogsBtn, "Логи", 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 := "Оффлайн"
statusColor := warningColor
details := "Нет активных туннелей. Откройте VLESS или WireGuard и подключитесь к одному из сохранённых конфигов."
if connected {
statusText = "Подключен"
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 = "Готов."
}
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, "Отключиться", 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, "Главная")
}),
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, "Подписки")
}),
)
}
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 конфиги", 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, "WireGuard конфиги", fmt.Sprintf("%d", len(u.configs.WireGuard)), accentSoftColor)
}),
layout.Rigid(spacerW(12)),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.metricCard(gtx, "Подписки", fmt.Sprintf("%d", len(u.subs.Subscriptions)), 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, "Быстрый пинг")
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, "Вставьте VLESS ссылку")
}),
layout.Rigid(spacerH(12)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.layoutButton(gtx, &u.testURLBtn, "Протестировать подключение", 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], "Подключение", accentColor)
}),
layout.Rigid(spacerW(8)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.layoutButton(gtx, &u.vlessPingBtns[i], "Пинг", accentSoftColor)
}),
layout.Rigid(spacerW(8)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.layoutButton(gtx, &u.vlessDeleteBtns[i], "Удалить", 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], "Обновить конфиги", accentColor)
}),
layout.Rigid(spacerW(8)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.layoutButton(gtx, &u.subDeleteBtns[i], "Удалить", 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 {
macro := op.Record(gtx.Ops)
dims := layout.Inset{
Top: vPad,
Bottom: vPad,
Left: hPad,
Right: hPad,
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Center.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)
})
})
call := macro.Stop()
minSize := image.Pt(gtx.Dp(unit.Dp(96)), gtx.Dp(unit.Dp(40)))
if dims.Size.X < minSize.X {
dims.Size.X = minSize.X
}
if dims.Size.Y < minSize.Y {
dims.Size.Y = minSize.Y
}
rr := gtx.Dp(unit.Dp(14))
defer clip.RRect{Rect: image.Rectangle{Max: dims.Size}, NW: rr, NE: rr, SW: rr, SE: rr}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, bg)
call.Add(gtx.Ops)
return dims
})
}
func (u *ui) card(gtx layout.Context, bg color.NRGBA, content layout.Widget) layout.Dimensions {
macro := op.Record(gtx.Ops)
dims := content(gtx)
call := macro.Stop()
rr := gtx.Dp(unit.Dp(20))
defer clip.RRect{Rect: image.Rectangle{Max: dims.Size}, NW: rr, NE: rr, SW: rr, SE: rr}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, bg)
call.Add(gtx.Ops)
return dims
}
func (u *ui) layoutModal(gtx layout.Context) {
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, color.NRGBA{A: 160})
layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = minInt(gtx.Constraints.Max.X, gtx.Dp(unit.Dp(420)))
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H5(u.theme, u.modalTitle)
lbl.Color = textColor
return lbl.Layout(gtx)
}),
layout.Rigid(spacerH(12)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(u.theme, u.modalBody)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(spacerH(20)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return u.layoutButton(gtx, &u.closeModalBtn, "OK", accentSoftColor)
})
}),
)
})
})
})
})
}
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}
}
func minInt(values ...int) int {
if len(values) == 0 {
return 0
}
min := values[0]
for _, value := range values[1:] {
if value < min {
min = value
}
}
return min
}