diff --git a/Makefile b/Makefile index 2c144143..b9bbcb95 100644 --- a/Makefile +++ b/Makefile @@ -14,13 +14,16 @@ install: install -Dm00755 tyde_runner $(DESTDIR)$(PREFIX)/bin/tyde_runner install -Dm00755 tyde_ctl $(DESTDIR)$(PREFIX)/bin/tyde_ctl install -Dm00755 tyde $(DESTDIR)$(PREFIX)/bin/tyde + install -Dm00644 theme/assets/icon.png $(DESTDIR)$(PREFIX)/share/pixmaps/com.fyshos.tyde.png install -Dm00644 tyde.desktop $(DESTDIR)$(PREFIX)/share/xsessions/tyde.desktop + install -Dm00644 tyde-welcome.desktop $(DESTDIR)$(PREFIX)/share/applications/tyde-welcome.desktop uninstall: -rm $(DESTDIR)$(PREFIX)/bin/tyde_runner -rm $(DESTDIR)$(PREFIX)/bin/tyde_ctl -rm $(DESTDIR)$(PREFIX)/bin/tyde -rm $(DESTDIR)$(PREFIX)/share/xsessions/tyde.desktop + -rm $(DESTDIR)$(PREFIX)/share/applications/tyde-welcome.desktop embed: Xephyr :5 -screen 1280x720 & diff --git a/go.mod b/go.mod index db70163f..6697621e 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/fyne-io/image v0.1.1 github.com/fyne-io/oksvg v0.2.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 + github.com/godbus/dbus/v5 v5.2.2 github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect github.com/jackmordaunt/icns v1.0.1-0.20200413110149-9e181b441ab2 // indirect @@ -35,6 +35,7 @@ require github.com/creack/pty v1.1.21 // indirect require ( github.com/FyshOS/dryvers v0.0.0-20260222162433-1ffb8226c1cf github.com/FyshOS/fyqr v0.0.0-20260624213738-536424772cba + github.com/FyshOS/networks v0.0.0-20260626180915-31c6f68dcf53 github.com/FyshOS/screens v0.0.0-20260616082735-2b927ac5e820 github.com/fyne-io/terminal v0.0.0-20251011215138-c2ed69d5a2d6 golang.org/x/image v0.24.0 @@ -48,6 +49,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect + github.com/amenzhinsky/go-polkit v0.0.0-20210519083301-ee6a51849123 // indirect github.com/anthonynsimon/bild v0.13.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fredbi/uri v1.1.1 // indirect @@ -58,6 +60,7 @@ require ( github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.4 // indirect + github.com/joeflateau/go-iwd v0.0.0-20240409133838-1f3ac7d42dc5 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect github.com/pkg/errors v0.8.1 // indirect diff --git a/go.sum b/go.sum index 8cab3068..69b89b4f 100644 --- a/go.sum +++ b/go.sum @@ -27,12 +27,16 @@ github.com/FyshOS/fyles v0.1.0 h1:ezJIxIEcQtoyN8c52vadLZRr1m8ZUIusz8yBfjed/pk= github.com/FyshOS/fyles v0.1.0/go.mod h1:YgozzG7CgidZTeohsrb6ayuLP803tRfmPNZ7cUXwdHI= github.com/FyshOS/fyqr v0.0.0-20260624213738-536424772cba h1:en4DPSeMviqsBmoT2nbPmPV9l5FsXFuz9JU4S0y/SE0= github.com/FyshOS/fyqr v0.0.0-20260624213738-536424772cba/go.mod h1:hFyul2zT1VOgINBEBeRVkXeoCBWo5dWU4poPihDniVU= +github.com/FyshOS/networks v0.0.0-20260626180915-31c6f68dcf53 h1:Dh1Un0qbuz1KBqn52rkL5j1M5CbntyrGbdtqXyOPdoo= +github.com/FyshOS/networks v0.0.0-20260626180915-31c6f68dcf53/go.mod h1:2Xjq+UcVtuEJrVPfQm6DVYo1DQgilPiO8aznbmR8Veg= github.com/FyshOS/saver v0.1.1-0.20260407200543-762135717028 h1:gnZOxK+y64+zaAreh9PyispXXCSz9vV5WgM+qO5h7ok= github.com/FyshOS/saver v0.1.1-0.20260407200543-762135717028/go.mod h1:WvBivsR68hbiahFFjEf5Yzv1chBd/Slo8TRziPgzEOY= github.com/FyshOS/screens v0.0.0-20260616082735-2b927ac5e820 h1:1brj9ygNCRJUM5uMjPC2s5q/du9FnK4Y40YdLpURaGQ= github.com/FyshOS/screens v0.0.0-20260616082735-2b927ac5e820/go.mod h1:WUjD+Oi5pGOusfSqrFrk/woIrqr+bMOZUGK0lc5mbRQ= github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f6s9Tn1Tt7/WTEg= github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/amenzhinsky/go-polkit v0.0.0-20210519083301-ee6a51849123 h1:VdNhe94PF9yn6KudYnpcBb6bH7l+wsEy9yn6Ulm1/j8= +github.com/amenzhinsky/go-polkit v0.0.0-20210519083301-ee6a51849123/go.mod h1:CdMR3dsiNi5M2BbtFlMo85mRbNt6LiMw04UBzJmoVEU= github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8= github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -76,8 +80,9 @@ github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+y github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= @@ -90,6 +95,8 @@ github.com/jackmordaunt/icns v1.0.1-0.20200413110149-9e181b441ab2/go.mod h1:Hj3T github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/joeflateau/go-iwd v0.0.0-20240409133838-1f3ac7d42dc5 h1:EIdfNVFubG8t/of/KrJnQLj5GMzZ+/+1V+/SmP/dtJU= +github.com/joeflateau/go-iwd v0.0.0-20240409133838-1f3ac7d42dc5/go.mod h1:4I0mfrVvxj7labUhhbS4EYZb5c1nQV3YM092CcIfyFI= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/internal/ui/desk.go b/internal/ui/desk.go index a126240a..4405758c 100644 --- a/internal/ui/desk.go +++ b/internal/ui/desk.go @@ -108,6 +108,14 @@ type desktop struct { // overlayShapes maps each shown overlay to the screen-pixel rectangle it occupies, // so frame input shapes can be made transparent only under the overlay content. overlayShapes map[fyne.CanvasObject]image.Rectangle + + // welcomeDone guards the first-run welcome splash so it is only ever triggered + // once per session, from the first primary-window layout with a real size. + welcomeDone bool + + // activityLayer, in embedded mode only, watches for mouse movement to defer the + // screen saver. + activityLayer fyne.CanvasObject } func (l *desktop) Desktop() int { @@ -439,6 +447,14 @@ func (l *desktop) Layout(objects []fyne.CanvasObject, size fyne.Size) { l.widgets.Resize(fyne.NewSize(widgetsWidth, pH)) l.widgets.Move(fyne.NewPos(pW-widgetsWidth, 0)) l.widgets.Refresh() + + // On the very first boot, once the primary window has a real (full-screen) + // size, present the welcome splash. + if !l.welcomeDone && shouldShowWelcome() && l.primaryWin != nil && + size.Width >= welcomeWidth && size.Height >= welcomeHeight { + l.welcomeDone = true + fyne.Do(l.ShowWelcome) + } } func (l *desktop) MinSize(_ []fyne.CanvasObject) fyne.Size { @@ -668,6 +684,11 @@ func (l *desktop) createPrimaryContent(sw *screenWindow) fyne.CanvasObject { // Order: background -> compositor -> overlay modules -> bar -> widgets -> compositor overlay -> UI overlay -> mouse objects := []fyne.CanvasObject{sw.bg} + // Embedded mode's screen-saver activity monitor sits just above the background. + if l.activityLayer != nil { + objects = append(objects, l.activityLayer) + } + // Normal compositor for regular windows below desktop chrome if sw.compositor != nil { objects = append(objects, sw.compositor) @@ -1167,8 +1188,9 @@ func NewEmbeddedDesktop(app fyne.App, icons appie.Provider) tyde.Desktop { desk.accessoryLayer = container.NewWithoutLayout() AccessoryRefresher = func() { rebuildEmbeddedAccessories(desk.accessoryLayer) } - over := wm.setWindow(win) - win.SetContent(container.NewStack(desk.createPrimaryContent(sw), over)) + // The saver monitor watches mouse movement to defer the screen saver. + desk.activityLayer = wm.setWindow(win) + win.SetContent(desk.createPrimaryContent(sw)) return desk } diff --git a/internal/ui/settings_ui.go b/internal/ui/settings_ui.go index ae05ec03..f01e165e 100644 --- a/internal/ui/settings_ui.go +++ b/internal/ui/settings_ui.go @@ -33,6 +33,7 @@ import ( "fyshos.com/tyde" wmtheme "fyshos.com/tyde/theme" "fyshos.com/tyde/wm" + "github.com/godbus/dbus/v5" ) //go:embed "themes/*" @@ -44,6 +45,8 @@ type settingsUI struct { fyneSettings *settings.Settings launcherIcons []string + + netConn *dbus.Conn // system bus backing the Network tab, closed with the window } func (d *settingsUI) populateThemeIcons(box *fyne.Container, theme string) { @@ -346,6 +349,18 @@ func (d *settingsUI) loadBarScreen() fyne.CanvasObject { widget.NewCard("App Bar", "", container.NewVBox(bar, details))) } +// loadNetworkScreen builds the Wi-Fi management tab from our networks app package. +func (d *settingsUI) loadNetworkScreen() fyne.CanvasObject { + nm, conn, err := newWifiNetworks(d.win) + if err != nil { + msg := widget.NewLabel("Wi-Fi management is unavailable.\n\n" + err.Error()) + msg.Wrapping = fyne.TextWrapWord + return widget.NewCard("Network", "", container.NewCenter(msg)) + } + d.netConn = conn + return widget.NewCard("Network", "", nm) +} + func (d *settingsUI) loadModulesScreen() fyne.CanvasObject { var modules, launchers []fyne.CanvasObject @@ -564,7 +579,13 @@ func (w *widgetPanel) showSettings() { screens := screenmanager.New(win) screens.OnConfigurationChanged = w.desk.Screens().RefreshScreens screenui := widget.NewCard("Screens", "", screens) - win.SetOnClosed(screens.Close) + win.SetOnClosed(func() { + screens.Close() + if ui.netConn != nil { + _ = ui.netConn.Close() + ui.netConn = nil + } + }) tabs := container.NewAppTabs( &container.TabItem{ @@ -580,6 +601,10 @@ func (w *widgetPanel) showSettings() { Text: "Display", Icon: wmtheme.ScreensIcon, Content: container.NewBorder(scale, nil, nil, nil, screenui), }, + &container.TabItem{ + Text: "Network", Icon: wmtheme.WifiIcon, + Content: ui.loadNetworkScreen(), + }, &container.TabItem{Text: "Time/Date", Icon: wmtheme.ClockIcon, Content: ui.loadTimeScreen()}, &container.TabItem{Text: "Theme", Icon: theme.ColorPaletteIcon(), Content: ui.loadThemeScreen()}, &container.TabItem{Text: "Keyboard", Icon: wmtheme.KeyboardIcon, Content: ui.loadKeyboardScreen()}, diff --git a/internal/ui/welcome.go b/internal/ui/welcome.go new file mode 100644 index 00000000..77d50451 --- /dev/null +++ b/internal/ui/welcome.go @@ -0,0 +1,389 @@ +package ui + +import ( + "image/color" + "math" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + deskDriver "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + wmtheme "fyshos.com/tyde/theme" + "github.com/FyshOS/networks/pkg/netman" + "github.com/godbus/dbus/v5" +) + +const ( + welcomePrefKey = "welcome.done" + + welcomeWidth = 560 + welcomeHeight = 510 + welcomeMargin = 60 + welcomeCardRadius = 14 + welcomeFish = 116 +) + +// shouldShowWelcome reports whether the first-run welcome splash is yet to be shown. +func shouldShowWelcome() bool { + return !fyne.CurrentApp().Preferences().Bool(welcomePrefKey) +} + +// welcome is the first-run setup splash: a centred panel whose background is the +// animated brand water (a shader), with the FyshOS mascot swimming in and a card +// of quick setup options drawn over the top. It is shown as a desktop modal so +// it captures input and dims the desktop behind it. +type welcome struct { + desk *desktop + sui *settingsUI + + shader *canvas.Shader + waveAnim *fyne.Animation // continuous gentle motion (drives "time") + revealAnim *fyne.Animation // 0->1 wash-in (drives "reveal") + fish *canvas.Image + fishAnim *fyne.Animation // the swim-in + fishBob *fyne.Animation // gentle idle bob once at rest + + cardBg *canvas.Rectangle // card surface, faded in from transparent over the waves + cardColor color.NRGBA // its resting (opaque) colour + cardFadeAnim *fyne.Animation + + body *fyne.Container // swappable card contents (home <-> a setup screen) + hide func() // tears down the modal overlay + + conn *dbus.Conn // system bus for Wi-Fi setup, opened lazily and reused + net *netman.Networks // the Wi-Fi widget, built once on first use +} + +// ShowWelcome builds and presents the first-run welcome splash over the desktop. +func (l *desktop) ShowWelcome() { + if l.primaryWin == nil || l.primaryWin.win == nil { + return + } + + ds := l.settings.(*deskSettings) + w := &welcome{ + desk: l, + sui: &settingsUI{settings: ds, launcherIcons: ds.LauncherIcons(), win: l.primaryWin.win}, + } + + // Animated brand water filling the whole panel. + w.shader = canvas.NewShader("tydeWelcomeWaves", welcomeWaveGL, welcomeWaveES) + w.shader.Uniforms = map[string]float32{"reveal": 0} + w.waveAnim = canvas.NewShaderAnimation(w.shader) + + // The mascot. It faces right, so it rests in the bottom-right corner and + // swims in rightward (forward). Hidden until the water has washed in. + w.fish = canvas.NewImageFromResource(wmtheme.FyshOSLogo) + w.fish.FillMode = canvas.ImageFillContain + w.fish.Resize(fyne.NewSquareSize(welcomeFish)) + restY := float32(welcomeHeight - welcomeFish - 22) + restX := float32(welcomeWidth - welcomeFish - 30) + w.fish.Move(fyne.NewPos(restX, restY)) + w.fish.Hide() + fishLayer := container.NewWithoutLayout(w.fish) + + // The setup card, inset so the waves read as a frame around it. The card + // surface starts fully transparent so the shader shows through it, then fades + // in and the content surfaces once the water has washed across. + w.cardColor = color.NRGBAModel.Convert(theme.Color(theme.ColorNameBackground)).(color.NRGBA) + w.cardBg = canvas.NewRectangle(color.NRGBA{R: w.cardColor.R, G: w.cardColor.G, B: w.cardColor.B, A: 0}) + w.cardBg.CornerRadius = welcomeCardRadius + w.body = container.NewStack() + w.showHome() + w.body.Hide() + card := container.NewStack(w.cardBg, container.NewPadded(w.body)) + framed := container.New(layout.NewCustomPaddedLayout(welcomeMargin, welcomeMargin, welcomeMargin, welcomeMargin), card) + + panel := container.NewStack(w.shader, framed, fishLayer) + w.hide = l.ShowModal(panel, fyne.NewSize(welcomeWidth, welcomeHeight)) + + w.waveAnim.Start() + w.startReveal(func() { + w.fish.Show() + w.startFishSwim(restX, restY) + w.startCardFade() + }) +} + +// startReveal animates the shader's water washing up from the bottom edge, then +// invokes done so the mascot and card can follow once the water is in. +func (w *welcome) startReveal(done func()) { + fired := false + a := fyne.NewAnimation(time.Millisecond*1000, func(f float32) { + w.shader.Uniforms["reveal"] = f + w.shader.Refresh() + if f >= 1 && !fired { + fired = true + if done != nil { + done() + } + } + }) + a.Curve = fyne.AnimationEaseOut + w.revealAnim = a + a.Start() +} + +// startFishSwim darts the mascot forward (rightward, the way it faces) into its +// resting corner with a slight rise, then hands off to a gentle idle bob. +func (w *welcome) startFishSwim(restX, restY float32) { + startX := restX - 420 + fired := false + a := fyne.NewAnimation(time.Millisecond*1800, func(f float32) { + x := startX + (restX-startX)*f + dip := float32(math.Sin(float64(f)*math.Pi)) * -8 // rises slightly mid-swim + w.fish.Move(fyne.NewPos(x, restY+dip)) + if f >= 1 && !fired { + fired = true + w.startFishBob(restX, restY) + } + }) + // a.Curve = fyne.AnimationEaseOut + w.fishAnim = a + a.Start() +} + +// startFishBob keeps the resting mascot gently rising and falling so it doesn't +// look frozen on the moving water. +func (w *welcome) startFishBob(restX, restY float32) { + a := fyne.NewAnimation(time.Millisecond*2400, func(f float32) { + bob := float32(math.Sin(float64(f)*math.Pi*2)) * 4 + w.fish.Move(fyne.NewPos(restX, restY+bob)) + }) + a.Curve = fyne.AnimationLinear + a.RepeatCount = fyne.AnimationRepeatForever + w.fishBob = a + a.Start() +} + +// startCardFade fades the card surface in from transparent - so the waves show +// through and are seen washing across the card area - then surfaces the content +// once the surface is most of the way in. +func (w *welcome) startCardFade() { + shown := false + a := fyne.NewAnimation(time.Millisecond*800, func(f float32) { + c := w.cardColor + c.A = uint8(float32(w.cardColor.A) * f * .7) + w.cardBg.FillColor = c + w.cardBg.Refresh() + if f >= 0.7 && !shown { + shown = true + w.body.Show() + } + }) + a.Curve = fyne.AnimationEaseOut + w.cardFadeAnim = a + a.Start() +} + +// showHome populates the card with the welcome message and the list of setup +// options, matching the first-run mock-up. +func (w *welcome) showHome() { + fg := theme.Color(theme.ColorNameForeground) + hello := canvas.NewText("Welcome to ", fg) + hello.TextSize = 22 + brand := canvas.NewText("FyshOS", fg) + brand.TextSize = 22 + brand.TextStyle = fyne.TextStyle{Bold: true} + titleRow := container.NewHBox(layout.NewSpacer(), hello, brand, layout.NewSpacer()) + + subtitle := canvas.NewText("Let's get you set up!", theme.Color(theme.ColorNamePlaceHolder)) + subtitle.TextSize = theme.Size(theme.SizeNameSubHeadingText) + header := container.NewVBox(titleRow, container.NewCenter(subtitle)) + + rows := container.NewVBox( + newWelcomeRow(theme.ColorPaletteIcon(), "Customize Appearance", + "Choose your theme and colors", w.openAppearance), + newWelcomeRow(wmtheme.WifiIcon, "Connect to Wi-Fi", + "Setup a Wi-Fi network", w.openWifi), + newWelcomeRow(theme.ListIcon(), "Manage Modules", + "Enable or disable features", w.openModules), + newWelcomeRow(theme.SettingsIcon(), "Additional Settings", + "Change system preferences", w.openFullSettings), + ) + + getStarted := &widget.Button{Text: "Get Started", Importance: widget.HighImportance, OnTapped: func() { + w.dismiss(true) + }} + skip := &widget.Button{Text: "Skip for now", Importance: widget.LowImportance, OnTapped: func() { + w.dismiss(false) + }} + footer := container.NewCenter(container.NewPadded(container.NewHBox(skip, getStarted))) + + w.setBody(container.NewBorder( + container.NewVBox(header, widget.NewSeparator()), footer, nil, nil, rows, + )) +} + +// showScreen swaps the card to a single setup screen with a Back button. +func (w *welcome) showScreen(title string, content fyne.CanvasObject) { + back := &widget.Button{ + Text: "Back", Icon: theme.NavigateBackIcon(), + Importance: widget.LowImportance, OnTapped: w.showHome, + } + head := container.NewBorder(nil, nil, back, nil, + widget.NewLabelWithStyle(title, fyne.TextAlignCenter, fyne.TextStyle{Bold: true})) + w.setBody(container.NewBorder(container.NewVBox(head, widget.NewSeparator()), nil, nil, nil, content)) +} + +func (w *welcome) setBody(o fyne.CanvasObject) { + w.body.Objects = []fyne.CanvasObject{o} + w.body.Refresh() +} + +func (w *welcome) openAppearance() { + w.showScreen("Customize Appearance", w.sui.loadAppearanceScreen()) +} + +func (w *welcome) openModules() { + w.showScreen("Manage Modules", w.sui.loadModulesScreen()) +} + +// openWifi shows Wi-Fi setup screen allowing user to pick a network from those found. +func (w *welcome) openWifi() { + win := w.sui.win + + // Build the network browser once and reuse it (and its bus connection). + if w.net == nil { + nm, conn, err := newWifiNetworks(win) + if err != nil { + dialog.ShowError(err, win) + return + } + w.conn, w.net = conn, nm + } + + w.showScreen("Connect to Wi-Fi", w.net) +} + +// newWifiNetworks loads the network browser from our networks repo. +// The caller owns the returned connection and must Close it once the widget is no longer needed. +func newWifiNetworks(win fyne.Window) (*netman.Networks, *dbus.Conn, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, nil, err + } + + // handlePass prompts for a network passphrase, blocking until the user submits + // or cancels. It is called from netman's iwd agent callback (off the UI thread), + // so the blocking read is safe; Cancel returns "" to abort the connection. + handlePass := func(name string) string { + result := make(chan string, 1) + entry := widget.NewPasswordEntry() + d := dialog.NewForm("Connect to "+name, "Connect", "Cancel", + []*widget.FormItem{widget.NewFormItem("Password", entry)}, + func(ok bool) { + if ok { + result <- entry.Text + } else { + result <- "" + } + }, win) + d.Resize(fyne.NewSize(320, d.MinSize().Height)) + d.Show() + + return <-result + } + + nm, err := netman.New(conn, handlePass, func(err error) { + dialog.ShowError(err, win) + }) + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return nm, conn, nil +} + +// openFullSettings closes the welcome and launches the full settings window, +// which opens as its own window beneath where this overlay was. +func (w *welcome) openFullSettings() { + w.dismiss(true) + w.desk.ShowSettings() +} + +// dismiss stops the animations, records that the welcome has been seen and tears +// down the overlay. Safe to call more than once. +func (w *welcome) dismiss(done bool) { + if w.waveAnim != nil { + w.waveAnim.Stop() + } + if w.revealAnim != nil { + w.revealAnim.Stop() + } + if w.fishAnim != nil { + w.fishAnim.Stop() + } + if w.fishBob != nil { + w.fishBob.Stop() + } + if w.cardFadeAnim != nil { + w.cardFadeAnim.Stop() + } + + if w.conn != nil { + _ = w.conn.Close() + w.conn, w.net = nil, nil + } + + if done { + fyne.CurrentApp().Preferences().SetBool(welcomePrefKey, true) + } + if w.hide != nil { + w.hide() + } +} + +// welcomeRow is one tappable setup option in the welcome card: an icon, a bold +// title and a dimmer description, with a chevron and a hover highlight. +type welcomeRow struct { + widget.BaseWidget + icon fyne.Resource + title string + desc string + onTap func() + + bg *canvas.Rectangle +} + +func newWelcomeRow(icon fyne.Resource, title, desc string, onTap func()) *welcomeRow { + r := &welcomeRow{icon: icon, title: title, desc: desc, onTap: onTap} + r.ExtendBaseWidget(r) + return r +} + +func (r *welcomeRow) CreateRenderer() fyne.WidgetRenderer { + r.bg = canvas.NewRectangle(color.Transparent) + r.bg.CornerRadius = theme.Size(theme.SizeNameInputRadius) + + icon := widget.NewIcon(r.icon) + text := widget.NewRichTextFromMarkdown("## " + r.title + "\n" + r.desc) + chevron := widget.NewIcon(theme.NavigateNextIcon()) + + row := container.NewBorder(nil, nil, container.NewPadded(icon), container.NewPadded(chevron), text) + return widget.NewSimpleRenderer(container.NewStack(r.bg, row)) +} + +func (r *welcomeRow) Tapped(*fyne.PointEvent) { + if r.onTap != nil { + r.onTap() + } +} + +func (r *welcomeRow) MouseIn(*deskDriver.MouseEvent) { + r.bg.FillColor = theme.Color(theme.ColorNameHover) + r.bg.Refresh() +} + +func (r *welcomeRow) MouseMoved(*deskDriver.MouseEvent) {} + +func (r *welcomeRow) MouseOut() { + r.bg.FillColor = color.Transparent + r.bg.Refresh() +} diff --git a/internal/ui/welcomeshader.go b/internal/ui/welcomeshader.go new file mode 100644 index 00000000..d616a7f3 --- /dev/null +++ b/internal/ui/welcomeshader.go @@ -0,0 +1,113 @@ +package ui + +// welcomeWaveBody is the fragment shader shared between the desktop and ES +// variants. It fills the welcome splash panel with the FyshOS brand water as a +// stack of undulating horizontal bands, each a flatter shade of blue - pale at +// the surface, through teal, to deep blue below - to match the layered waves in +// theme/assets/logo_fade.png rather than white crests on a flat background. Each +// band's wavy top edge gently scrolls, and the "reveal" uniform fades the bands +// in from the pale surface tone (in place, no slide) as the panel animates on; +// "time" keeps them undulating afterwards. +// +// It follows the uniform contract used by the built-in vector shaders and the +// flames/cube shaders in this project, so it is driven the same way: rect_coords +// gives the object's bounds (canvas-top origin), letting the shader confine +// itself to the panel like the built-in shapes do. +// +// The version header (and, for ES, the precision preamble) is the only thing +// that differs between targets, so it is prepended below rather than duplicated. +const welcomeWaveBody = ` +uniform vec2 frame_size; // size of the output frame, in pixels +uniform vec4 rect_coords; // this object's bounds: x1 [0], x2 [1], y1 [2], y2 [3] +uniform float time; // elapsed animation time, in seconds +uniform float reveal; // 0..1 wash-in: bands fade in from the surface (no slide) + +// bandColor maps a 0..1 depth (0 surface -> 1 floor) to the FyshOS water palette +// sampled from logo_fade.png: pale at the top, through sky and teal, to deep blue. +vec3 bandColor(float s) { + vec3 pale = vec3(0.91, 0.95, 0.99); + vec3 sky = vec3(0.62, 0.84, 0.94); + vec3 teal = vec3(0.36, 0.71, 0.85); + vec3 blue = vec3(0.20, 0.53, 0.82); + vec3 deep = vec3(0.11, 0.39, 0.74); + if (s < 0.25) return mix(pale, sky, s / 0.25); + if (s < 0.50) return mix(sky, teal, (s - 0.25) / 0.25); + if (s < 0.75) return mix(teal, blue, (s - 0.50) / 0.25); + return mix(blue, deep, (s - 0.75) / 0.25); +} + +void main() { + // Discard anything outside this object's bounds, like the built in shapes. + if (gl_FragCoord.x < rect_coords[0] || gl_FragCoord.x > rect_coords[1] || + gl_FragCoord.y < frame_size.y - rect_coords[3] || + gl_FragCoord.y > frame_size.y - rect_coords[2]) { + discard; + } + + float w = rect_coords[1] - rect_coords[0]; + float h = rect_coords[3] - rect_coords[2]; + + // Local coordinates within the rect: uv.x 0..1 left->right, uv.y 0..1 + // top->bottom (rect_coords is canvas-top origin, gl_FragCoord.y grows upward). + float lx = gl_FragCoord.x - rect_coords[0]; + float ly = (frame_size.y - gl_FragCoord.y) - rect_coords[2]; + vec2 uv = vec2(lx / w, ly / h); + + // Rounded corners: fade to transparent outside a rounded-rectangle so the + // panel reads as a soft card floating over the dimmed desktop rather than a + // hard-edged box. edge feeds the final alpha for a 1px anti-aliased border. + float radius = 22.0; + vec2 corner = clamp(vec2(lx, ly), vec2(radius), vec2(w - radius, h - radius)); + float cornerDist = distance(vec2(lx, ly), corner); + float edge = 1.0 - smoothstep(radius - 1.0, radius + 1.0, cornerDist); + if (edge <= 0.0) { + discard; + } + + // Build the water by painting a stack of bands over the palest surface tone. + // Each band fills from its wavy top edge downwards; lower (front) bands + // overwrite the ones behind, so their undulating edges read as layered waves. + vec3 col = bandColor(0.0); + + const int N = 7; + for (int i = 0; i < N; i++) { + float fi = float(i); + float s = fi / float(N - 1); // 0..1 palette position + float baseY = 0.08 + 0.84 * s; // resting top edge of band + float amp = 0.015 + 0.004 * fi; // lower bands swell a little more + float freq = 6.2831 * (0.6 + 0.16 * fi); // long, gentle humps + float speed = 0.15 + 0.08 * fi; + float ph = fi * 1.7; + // Two summed sines give an organic, non-repeating undulation. + float yEdge = baseY + + amp * sin(uv.x * freq + time * speed + ph) + + amp * 0.4 * sin(uv.x * freq * 1.9 - time * speed * 0.7 + ph * 1.3); + // Soft edge so the band silhouettes are anti-aliased rather than jagged. + float cover = smoothstep(-0.005, 0.005, uv.y - yEdge); + col = mix(col, bandColor(s), cover); + } + + // No slide-up: the bands stay at their resting heights and instead fade in + // from the pale surface tone as reveal grows, so the water develops in place. + col = mix(bandColor(0.0), col, smoothstep(0.0, 1.0, reveal)); + + gl_FragColor = vec4(col, edge); +} +` + +// welcomeWaveGL is the desktop OpenGL (core profile) variant. +var welcomeWaveGL = []byte("#version 110\n" + welcomeWaveBody) + +// welcomeWaveES is the OpenGL ES / mobile / web variant - same body, with the ES +// version header and the float precision preamble the built in shaders use. +var welcomeWaveES = []byte(`#version 100 + +#ifdef GL_ES +# ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +# else +precision mediump float; +#endif +precision mediump int; +#endif +` + welcomeWaveBody) diff --git a/modules/rpc/rpc.go b/modules/rpc/rpc.go index b7bc8dce..4c82beac 100644 --- a/modules/rpc/rpc.go +++ b/modules/rpc/rpc.go @@ -128,6 +128,14 @@ func newRPC() tyde.Module { os.Remove(sock) // clean up stale socket mod := &rpcModule{commands: map[string]func() error{ + "welcome": func() error { + if welcomer, ok := tyde.Instance().(interface{ ShowWelcome() }); ok { + fyne.Do(welcomer.ShowWelcome) + } else { + return errors.New("desktop does not support welcome") + } + return nil + }, "restart": func() error { if os.Getenv("FYNE_DESK_RUNNER") != "" { os.Exit(5) diff --git a/modules/status/network.go b/modules/status/network.go index c968a4c0..900a6a1a 100644 --- a/modules/status/network.go +++ b/modules/status/network.go @@ -10,10 +10,13 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" "fyshos.com/tyde" wmtheme "fyshos.com/tyde/theme" + "github.com/FyshOS/networks/pkg/netman" + "github.com/godbus/dbus/v5" ) var networkMeta = tyde.ModuleMetadata{ @@ -28,9 +31,16 @@ type network struct { icon *widget.Button wasBlocked bool + + conn *dbus.Conn // system bus for Wi-Fi browsing, opened lazily and reused + net *netman.Networks // iwd-backed network browser, built once on first use } func (n *network) Destroy() { + if n.conn != nil { + _ = n.conn.Close() + n.conn, n.net = nil, nil + } } func (n *network) wirelessName() (string, error) { @@ -190,7 +200,7 @@ func (n *network) StatusAreaWidget() fyne.CanvasObject { } n.name = widget.NewLabel("") - n.icon = &widget.Button{Icon: wmtheme.WifiOffIcon, Importance: widget.LowImportance, OnTapped: n.toggleFlightMode} + n.icon = &widget.Button{Icon: wmtheme.WifiOffIcon, Importance: widget.LowImportance, OnTapped: n.showMenu} if blocked { n.icon.Icon = wmtheme.AirplaneIcon } @@ -254,6 +264,78 @@ func (n *network) setFlightMode(block bool) error { return nil } +// showMenu pops up the network menu beneath the status icon: the Wi-Fi networks +// iwd currently knows about (from netman), followed by an Airplane Mode toggle. +func (n *network) showMenu() { + blocked, _ := n.isBlocked() + + var items []*fyne.MenuItem + // The radio is off in airplane mode, so there are no networks to list. + if !blocked { + if nm := n.networks(); nm != nil { + // Menu(nil) returns iwd's currently-known networks without blocking; + // kick a background scan so the next open reflects any changes. + items = append(items, nm.Menu(nil).Items...) + go nm.Scan() + } + } + if len(items) > 0 { + items = append(items, fyne.NewMenuItemSeparator()) + } + + air := fyne.NewMenuItem("Airplane Mode", n.toggleFlightMode) + air.Checked = blocked + items = append(items, air) + + pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(n.icon) + tyde.Instance().ShowMenuAt(fyne.NewMenu("", items...), pos) +} + +// networks lazily gets a network manager from our networks repo package that will generate our menu. +func (n *network) networks() *netman.Networks { + if n.net != nil { + return n.net + } + + win := tyde.Instance().Root() + conn, err := dbus.ConnectSystemBus() + if err != nil { + log.Println("network menu: system bus unavailable:", err) + return nil + } + + // handlePass prompts for a network passphrase, blocking until the user submits + // or cancels; it is called from netman's iwd agent callback. Cancel returns "". + handlePass := func(name string) string { + result := make(chan string, 1) + entry := widget.NewPasswordEntry() + d := dialog.NewForm("Connect to "+name, "Connect", "Cancel", + []*widget.FormItem{widget.NewFormItem("Password", entry)}, + func(ok bool) { + if ok { + result <- entry.Text + } else { + result <- "" + } + }, win) + d.Resize(fyne.NewSize(320, d.MinSize().Height)) + d.Show() + + return <-result + } + + nm, err := netman.New(conn, handlePass, func(err error) { + dialog.ShowError(err, win) + }) + if err != nil { + _ = conn.Close() + log.Println("network menu: iwd unavailable:", err) + return nil + } + n.conn, n.net = conn, nm + return nm +} + func (n *network) toggleFlightMode() { blocked, err := n.isBlocked() if err != nil { diff --git a/tyde-welcome.desktop b/tyde-welcome.desktop new file mode 100644 index 00000000..849a5787 --- /dev/null +++ b/tyde-welcome.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Tyde Welcome +Comment=Show the Tyde welcome and quick setup screen +Exec=tyde_ctl welcome +Icon=com.fyshos.tyde +Type=Application +Categories=Settings;Utility; +Keywords=welcome;setup;getting started; diff --git a/tyde.desktop b/tyde.desktop index af6929c0..dded9795 100644 --- a/tyde.desktop +++ b/tyde.desktop @@ -2,4 +2,5 @@ Name=Tyde Comment=Use the Fyne based desktop environment Exec=tyde_runner +Icon=com.fyshos.tyde Type=Application