mirror of
https://github.com/xaionaro-go/streamctl.git
synced 2025-09-27 03:45:52 +08:00
Initial commit, pt. 39
This commit is contained in:
3
Makefile
3
Makefile
@@ -25,5 +25,8 @@ streampanel-windows: builddir
|
||||
streamd-linux-amd64: builddir
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o build/streamd-linux-amd64 ./cmd/streamd
|
||||
|
||||
streamcli-linux-amd64: builddir
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o build/streamcli-linux-amd64 ./cmd/streamcli
|
||||
|
||||
builddir:
|
||||
mkdir -p build
|
||||
|
@@ -1,9 +1,11 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/facebookincubator/go-belt/tool/logger"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
twitch "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch/types"
|
||||
youtube "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube/types"
|
||||
"github.com/xaionaro-go/streamctl/pkg/streamd/client"
|
||||
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,24 +36,49 @@ var (
|
||||
},
|
||||
}
|
||||
|
||||
Stream = &cobra.Command{
|
||||
Use: "stream",
|
||||
}
|
||||
|
||||
StreamSetup = &cobra.Command{
|
||||
Use: "stream-setup",
|
||||
Use: "setup",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Run: streamSetup,
|
||||
}
|
||||
|
||||
StreamStatus = &cobra.Command{
|
||||
Use: "stream-status",
|
||||
Use: "status",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Run: streamStatus,
|
||||
}
|
||||
|
||||
Variables = &cobra.Command{
|
||||
Use: "variables",
|
||||
}
|
||||
|
||||
VariablesGet = &cobra.Command{
|
||||
Use: "get",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: variablesGet,
|
||||
}
|
||||
|
||||
VariablesSet = &cobra.Command{
|
||||
Use: "set",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: variablesSet,
|
||||
}
|
||||
|
||||
LoggerLevel = logger.LevelWarning
|
||||
)
|
||||
|
||||
func init() {
|
||||
Root.AddCommand(StreamSetup)
|
||||
Root.AddCommand(StreamStatus)
|
||||
Root.AddCommand(Stream)
|
||||
Stream.AddCommand(StreamSetup)
|
||||
Stream.AddCommand(StreamStatus)
|
||||
|
||||
Root.AddCommand(Variables)
|
||||
Variables.AddCommand(VariablesGet)
|
||||
Variables.AddCommand(VariablesSet)
|
||||
|
||||
Root.PersistentFlags().Var(&LoggerLevel, "log-level", "")
|
||||
Root.PersistentFlags().String("remote-addr", "localhost:3594", "the path to the config file")
|
||||
@@ -132,3 +160,33 @@ func streamStatus(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("%10s: %s\n", platID, statusJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func variablesGet(cmd *cobra.Command, args []string) {
|
||||
variableKey := args[0]
|
||||
ctx := cmd.Context()
|
||||
|
||||
remoteAddr, err := cmd.Flags().GetString("remote-addr")
|
||||
assertNoError(ctx, err)
|
||||
streamD := client.New(remoteAddr)
|
||||
|
||||
b, err := streamD.GetVariable(ctx, consts.VarKey(variableKey))
|
||||
assertNoError(ctx, err)
|
||||
|
||||
_, err = io.Copy(os.Stdout, bytes.NewReader(b))
|
||||
assertNoError(ctx, err)
|
||||
}
|
||||
|
||||
func variablesSet(cmd *cobra.Command, args []string) {
|
||||
variableKey := args[0]
|
||||
ctx := cmd.Context()
|
||||
|
||||
remoteAddr, err := cmd.Flags().GetString("remote-addr")
|
||||
assertNoError(ctx, err)
|
||||
streamD := client.New(remoteAddr)
|
||||
|
||||
value, err := io.ReadAll(os.Stdin)
|
||||
assertNoError(ctx, err)
|
||||
|
||||
err = streamD.SetVariable(ctx, consts.VarKey(variableKey), value)
|
||||
assertNoError(ctx, err)
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE QtCreatorProject>
|
||||
<!-- Written by QtDesignStudio 4.5.1, 2024-07-02T22:36:24. -->
|
||||
<!-- Written by QtDesignStudio 4.5.1, 2024-07-07T20:06:09. -->
|
||||
<qtcreator>
|
||||
<data>
|
||||
<variable>EnvironmentId</variable>
|
||||
|
@@ -18,9 +18,16 @@ func Screenshot(cfg Config) (*image.RGBA, error) {
|
||||
return nil, fmt.Errorf("unable to screenshot screen %d: %w", cfg.DisplayID, err)
|
||||
}
|
||||
|
||||
rgbaCropped, ok := rgbaFull.SubImage(cfg.Bounds).(*image.RGBA)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("got type %T, but expected %T", rgbaCropped, (*image.RGBA)(nil))
|
||||
var rgbaCropped *image.RGBA
|
||||
resizeTo := cfg.Bounds
|
||||
if resizeTo.Max.X > 0 && resizeTo.Max.Y > 0 {
|
||||
var ok bool
|
||||
rgbaCropped, ok = rgbaFull.SubImage(resizeTo).(*image.RGBA)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("got type %T, but expected %T", rgbaCropped, (*image.RGBA)(nil))
|
||||
}
|
||||
} else {
|
||||
rgbaCropped = rgbaFull
|
||||
}
|
||||
|
||||
return rgbaCropped, nil
|
||||
|
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
|
||||
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
|
||||
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
|
||||
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
|
||||
)
|
||||
|
||||
type GRPCServer struct {
|
||||
@@ -474,3 +475,31 @@ func (grpc *GRPCServer) SubscribeToOAuthRequests(
|
||||
func (grpc *GRPCServer) OpenOAuthURL(authURL string) {
|
||||
grpc.OAuthURLBroadcaster.Submit(authURL)
|
||||
}
|
||||
|
||||
func (grpc *GRPCServer) GetVariable(
|
||||
ctx context.Context,
|
||||
req *streamd_grpc.GetVariableRequest,
|
||||
) (*streamd_grpc.GetVariableReply, error) {
|
||||
key := consts.VarKey(req.GetKey())
|
||||
b, err := grpc.StreamD.GetVariable(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get variable '%s': %w", key, err)
|
||||
}
|
||||
|
||||
return &streamd_grpc.GetVariableReply{
|
||||
Key: string(key),
|
||||
Value: b,
|
||||
}, nil
|
||||
}
|
||||
func (grpc *GRPCServer) SetVariable(
|
||||
ctx context.Context,
|
||||
req *streamd_grpc.SetVariableRequest,
|
||||
) (*streamd_grpc.SetVariableReply, error) {
|
||||
key := consts.VarKey(req.GetKey())
|
||||
err := grpc.StreamD.SetVariable(ctx, key, req.GetValue())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to set variable '%s': %w", key, err)
|
||||
}
|
||||
|
||||
return &streamd_grpc.SetVariableReply{}, nil
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ type ImageID string
|
||||
|
||||
const (
|
||||
ImageScreenshot = ImageID("screenshot")
|
||||
ImageChat = ImageID("chat")
|
||||
)
|
||||
|
||||
type VarKey string
|
||||
|
@@ -5,10 +5,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/facebookincubator/go-belt/tool/logger"
|
||||
"github.com/nfnt/resize"
|
||||
"github.com/xaionaro-go/streamctl/pkg/screenshot"
|
||||
"github.com/xaionaro-go/streamctl/pkg/screenshoter"
|
||||
@@ -57,7 +60,18 @@ func (p *Panel) getImage(
|
||||
return nil, fmt.Errorf("unable to get a screenshot: %w", err)
|
||||
}
|
||||
|
||||
img, err := webp.Decode(bytes.NewReader(b))
|
||||
mimeType := http.DetectContentType(b)
|
||||
|
||||
var img image.Image
|
||||
err = nil
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
img, err = png.Decode(bytes.NewReader(b))
|
||||
case "image/webp":
|
||||
img, err = webp.Decode(bytes.NewReader(b))
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected image type %s", mimeType)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to decode the screenshot: %w", err)
|
||||
}
|
||||
@@ -75,6 +89,17 @@ func (p *Panel) setScreenshot(
|
||||
screenshot image.Image,
|
||||
) {
|
||||
bounds := screenshot.Bounds()
|
||||
logger.Tracef(ctx, "screenshot bounds: %#+v", bounds)
|
||||
if bounds.Max.X == 0 || bounds.Max.Y == 0 {
|
||||
p.DisplayError(fmt.Errorf("received an empty screenshot"))
|
||||
p.screenshoterLocker.Lock()
|
||||
if p.screenshoterClose != nil {
|
||||
p.screenshoterClose()
|
||||
}
|
||||
p.screenshoterLocker.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if bounds.Max.X > ScreenshotMaxWidth || bounds.Max.Y > ScreenshotMaxHeight {
|
||||
factor := 1.0
|
||||
factor = math.Min(factor, float64(ScreenshotMaxWidth)/float64(bounds.Max.X))
|
||||
@@ -82,12 +107,16 @@ func (p *Panel) setScreenshot(
|
||||
newWidth := uint(float64(bounds.Max.X) * factor)
|
||||
newHeight := uint(float64(bounds.Max.Y) * factor)
|
||||
screenshot = resize.Resize(newWidth, newHeight, screenshot, resize.Lanczos3)
|
||||
logger.Tracef(ctx, "rescaled the screenshot from %#+v to %#+v", bounds, screenshot.Bounds())
|
||||
}
|
||||
|
||||
p.setImage(ctx, consts.VarKeyImage(consts.ImageScreenshot), screenshot)
|
||||
}
|
||||
|
||||
func (p *Panel) reinitScreenshoter(ctx context.Context) {
|
||||
logger.Debugf(ctx, "reinitScreenshoter")
|
||||
defer logger.Debugf(ctx, "/reinitScreenshoter")
|
||||
|
||||
p.screenshoterLocker.Lock()
|
||||
defer p.screenshoterLocker.Unlock()
|
||||
if p.screenshoterClose != nil {
|
90
pkg/streampanel/monitor.go
Normal file
90
pkg/streampanel/monitor.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package streampanel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"github.com/facebookincubator/go-belt/tool/logger"
|
||||
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
|
||||
)
|
||||
|
||||
func (p *Panel) startMonitorPage(
|
||||
ctx context.Context,
|
||||
) {
|
||||
logger.Debugf(ctx, "startMonitorPage")
|
||||
defer logger.Debugf(ctx, "/startMonitorPage")
|
||||
|
||||
p.monitorPageUpdaterLocker.Lock()
|
||||
defer p.monitorPageUpdaterLocker.Unlock()
|
||||
ctx, cancelFn := context.WithCancel(ctx)
|
||||
p.monitorPageUpdaterCancel = cancelFn
|
||||
go func(ctx context.Context) {
|
||||
p.updateMonitorPage(ctx)
|
||||
|
||||
t := time.NewTicker(time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
|
||||
p.updateMonitorPage(ctx)
|
||||
}
|
||||
}(ctx)
|
||||
}
|
||||
|
||||
func (p *Panel) stopMonitorPage(
|
||||
ctx context.Context,
|
||||
) {
|
||||
logger.Debugf(ctx, "stopMonitorPage")
|
||||
defer logger.Debugf(ctx, "/stopMonitorPage")
|
||||
|
||||
p.monitorPageUpdaterLocker.Lock()
|
||||
defer p.monitorPageUpdaterLocker.Unlock()
|
||||
|
||||
if p.monitorPageUpdaterCancel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.monitorPageUpdaterCancel()
|
||||
p.monitorPageUpdaterCancel = nil
|
||||
}
|
||||
|
||||
func (p *Panel) updateMonitorPage(
|
||||
ctx context.Context,
|
||||
) {
|
||||
logger.Tracef(ctx, "updateMonitorPage")
|
||||
defer logger.Tracef(ctx, "/updateMonitorPage")
|
||||
|
||||
{
|
||||
img, err := p.getImage(ctx, consts.VarKeyImage(consts.ImageScreenshot))
|
||||
if err != nil {
|
||||
logger.Error(ctx, err)
|
||||
} else {
|
||||
imgFyne := canvas.NewImageFromImage(img)
|
||||
imgFyne.FillMode = canvas.ImageFillOriginal
|
||||
|
||||
p.screenshotContainer.Layout = layout.NewBorderLayout(imgFyne, nil, nil, nil)
|
||||
p.screenshotContainer.Objects = p.screenshotContainer.Objects[:0]
|
||||
p.screenshotContainer.Objects = append(p.screenshotContainer.Objects, imgFyne)
|
||||
p.screenshotContainer.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
img, err := p.getImage(ctx, consts.VarKeyImage(consts.ImageChat))
|
||||
if err != nil {
|
||||
logger.Error(ctx, err)
|
||||
} else {
|
||||
imgFyne := canvas.NewImageFromImage(img)
|
||||
imgFyne.FillMode = canvas.ImageFillContain
|
||||
|
||||
p.chatContainer.RemoveAll()
|
||||
p.chatContainer.Add(imgFyne)
|
||||
p.chatContainer.Refresh()
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"image"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -19,8 +20,10 @@ import (
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
fyneapp "fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/facebookincubator/go-belt"
|
||||
@@ -72,6 +75,11 @@ type Panel struct {
|
||||
streamTitleField *widget.Entry
|
||||
streamDescriptionField *widget.Entry
|
||||
|
||||
monitorPageUpdaterLocker sync.Mutex
|
||||
monitorPageUpdaterCancel context.CancelFunc
|
||||
screenshotContainer *fyne.Container
|
||||
chatContainer *fyne.Container
|
||||
|
||||
filterValue string
|
||||
|
||||
youtubeCheck *widget.Check
|
||||
@@ -89,6 +97,13 @@ type Panel struct {
|
||||
waitWindow fyne.Window
|
||||
}
|
||||
|
||||
type Page string
|
||||
|
||||
const (
|
||||
PageControl = Page("Control")
|
||||
PageMonitor = Page("Monitor")
|
||||
)
|
||||
|
||||
func New(
|
||||
configPath string,
|
||||
opts ...Option,
|
||||
@@ -835,12 +850,19 @@ func (p *Panel) openSettingsWindow(ctx context.Context) error {
|
||||
afterStopStreamCommandEntry := widget.NewEntry()
|
||||
afterStopStreamCommandEntry.SetText(cmdAfterStopStream)
|
||||
|
||||
oldScreenshoterEnabled := p.Config.Screenshot.Enabled != nil && *p.Config.Screenshot.Enabled
|
||||
|
||||
cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||
w.Close()
|
||||
})
|
||||
saveButton := widget.NewButtonWithIcon("Save", theme.DocumentSaveIcon(), func() {
|
||||
if err := p.SaveConfig(ctx); err != nil {
|
||||
p.DisplayError(fmt.Errorf("unable to save the local config: %w", err))
|
||||
} else {
|
||||
newScreenshotEnabled := p.Config.Screenshot.Enabled != nil && *p.Config.Screenshot.Enabled
|
||||
if oldScreenshoterEnabled != newScreenshotEnabled {
|
||||
p.reinitScreenshoter(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
obsCfg := cfg.Backends[obs.ID]
|
||||
@@ -1125,7 +1147,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) {
|
||||
|
||||
obsIsEnabled, err := p.StreamD.IsBackendEnabled(ctx, obs.ID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "unable to check if OBS is enabled: %w", err)
|
||||
logger.Error(ctx, fmt.Errorf("unable to check if OBS is enabled: %w", err))
|
||||
return
|
||||
}
|
||||
if !obsIsEnabled {
|
||||
@@ -1142,7 +1164,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) {
|
||||
|
||||
obsStreamStatus, err := p.StreamD.GetStreamStatus(ctx, obs.ID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "unable to get stream status from OBS: %w", err)
|
||||
logger.Error(ctx, fmt.Errorf("unable to get stream status from OBS: %w", err))
|
||||
return
|
||||
}
|
||||
logger.Tracef(ctx, "obsStreamStatus == %#+v", obsStreamStatus)
|
||||
@@ -1173,7 +1195,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) {
|
||||
|
||||
ytIsEnabled, err := p.StreamD.IsBackendEnabled(ctx, youtube.ID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "unable to check if YouTube is enabled: %w", err)
|
||||
logger.Error(ctx, fmt.Errorf("unable to check if YouTube is enabled: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1184,7 +1206,7 @@ func (p *Panel) getUpdatedStatus_startStopStreamButton(ctx context.Context) {
|
||||
|
||||
ytStreamStatus, err := p.StreamD.GetStreamStatus(ctx, youtube.ID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "unable to get stream status from YouTube: %w", err)
|
||||
logger.Error(ctx, fmt.Errorf("unable to get stream status from YouTube: %w", err))
|
||||
return
|
||||
}
|
||||
logger.Tracef(ctx, "ytStreamStatus == %#+v", ytStreamStatus)
|
||||
@@ -1238,8 +1260,7 @@ func (p *Panel) initMainWindow(ctx context.Context) {
|
||||
p.openMenuWindow(ctx)
|
||||
})
|
||||
|
||||
buttonPanel := container.NewHBox(
|
||||
menuButton,
|
||||
profileControl := container.NewHBox(
|
||||
widget.NewSeparator(),
|
||||
widget.NewRichTextWithText("Profile:"),
|
||||
widget.NewButtonWithIcon("", theme.ContentAddIcon(), func() {
|
||||
@@ -1247,16 +1268,16 @@ func (p *Panel) initMainWindow(ctx context.Context) {
|
||||
}),
|
||||
)
|
||||
|
||||
topPanel := container.NewHBox(
|
||||
menuButton,
|
||||
profileControl,
|
||||
)
|
||||
|
||||
for _, button := range selectedProfileButtons {
|
||||
button.Disable()
|
||||
buttonPanel.Add(button)
|
||||
profileControl.Add(button)
|
||||
}
|
||||
|
||||
topPanel := container.NewVBox(
|
||||
buttonPanel,
|
||||
profileFilter,
|
||||
)
|
||||
|
||||
p.setupStreamButton = widget.NewButtonWithIcon(setupStreamString(), theme.SettingsIcon(), func() {
|
||||
p.onSetupStreamButton(ctx)
|
||||
})
|
||||
@@ -1328,12 +1349,63 @@ func (p *Panel) initMainWindow(ctx context.Context) {
|
||||
),
|
||||
)
|
||||
|
||||
w.SetContent(container.NewBorder(
|
||||
topPanel,
|
||||
controlPage := container.NewBorder(
|
||||
profileFilter,
|
||||
bottomPanel,
|
||||
nil,
|
||||
nil,
|
||||
profilesList,
|
||||
)
|
||||
|
||||
monitorBackground := image.NewGray(image.Rect(0, 0, 1, 1))
|
||||
monitorBackgroundFyne := canvas.NewImageFromImage(monitorBackground)
|
||||
monitorBackgroundFyne.FillMode = canvas.ImageFillStretch
|
||||
|
||||
p.screenshotContainer = container.NewBorder(nil, nil, nil, nil)
|
||||
p.chatContainer = container.NewBorder(nil, nil, nil, nil)
|
||||
monitorPage := container.NewStack(
|
||||
monitorBackgroundFyne,
|
||||
p.screenshotContainer,
|
||||
p.chatContainer,
|
||||
)
|
||||
|
||||
setPage := func(page Page) {
|
||||
logger.Debugf(ctx, "setPage(%s)", page)
|
||||
defer logger.Debugf(ctx, "/setPage(%s)", page)
|
||||
|
||||
if page != PageMonitor {
|
||||
p.stopMonitorPage(ctx)
|
||||
}
|
||||
|
||||
switch page {
|
||||
case PageControl:
|
||||
monitorPage.Hide()
|
||||
profileControl.Show()
|
||||
controlPage.Show()
|
||||
case PageMonitor:
|
||||
controlPage.Hide()
|
||||
profileControl.Hide()
|
||||
monitorPage.Show()
|
||||
p.startMonitorPage(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
pageSelector := widget.NewSelect(
|
||||
[]string{"Control", "Monitor"},
|
||||
func(page string) {
|
||||
setPage(Page(page))
|
||||
},
|
||||
)
|
||||
pageSelector.SetSelected(string(PageControl))
|
||||
topPanel.Add(layout.NewSpacer())
|
||||
topPanel.Add(pageSelector)
|
||||
|
||||
w.SetContent(container.NewBorder(
|
||||
topPanel,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
container.NewStack(controlPage, monitorPage),
|
||||
))
|
||||
|
||||
w.Show()
|
||||
|
Reference in New Issue
Block a user