diff --git a/cmd/desktop_demo/main.go b/cmd/desktop_demo/main.go new file mode 100644 index 00000000..7f61aa53 --- /dev/null +++ b/cmd/desktop_demo/main.go @@ -0,0 +1,100 @@ +//go:build linux +// +build linux + +package main + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + xtheme "fyne.io/x/fyne/theme" + xdesktop "fyne.io/x/fyne/theme/desktop" +) + +func main() { + app := app.New() + app.Settings().SetTheme(xtheme.FromDesktopEnvironment()) + win := app.NewWindow("Desktop integration demo") + win.Resize(fyne.NewSize(550, 390)) + win.CenterOnScreen() + + // Gnome/GTK theme invertion + invertButton := widget.NewButton("Invert Gnome theme", func() { + if t, ok := app.Settings().Theme().(*xdesktop.GnomeTheme); ok { + t.Invert() + win.Content().Refresh() + } + }) + + // the invertButton can only work on Gnome / GTK theme. + if _, ok := app.Settings().Theme().(*xdesktop.GnomeTheme); !ok { + invertButton.Disable() + invertButton.SetText("Invert only works on Gnome/GTK") + } + + var switched bool + switchThemeButton := widget.NewButton("Switch theme", func() { + if switched { + app.Settings().SetTheme(xtheme.FromDesktopEnvironment()) + } else { + app.Settings().SetTheme(theme.DefaultTheme()) + } + switched = !switched + win.Content().Refresh() + }) + + entry := widget.NewEntry() + entry.SetPlaceHolder("Example of text entry...") + win.SetContent(container.NewBorder( + nil, + container.NewHBox( + widget.NewButtonWithIcon("Home icon button", theme.HomeIcon(), nil), + widget.NewButtonWithIcon("Info icon button", theme.InfoIcon(), nil), + widget.NewButtonWithIcon("Example file dialog", theme.FolderIcon(), func() { + dialog.ShowFileSave(func(fyne.URIWriteCloser, error) {}, win) + }), + invertButton, + ), + nil, + nil, + container.NewVBox( + createExplanationLabel(app), + entry, + widget.NewLabel("Try to switch theme"), + switchThemeButton, + ), + )) + + win.ShowAndRun() +} + +func createExplanationLabel(app fyne.App) fyne.CanvasObject { + + var current string + + switch app.Settings().Theme().(type) { + case *xdesktop.GnomeTheme: + current = "Gnome / GTK" + case *xdesktop.KDETheme: + current = "KDE / Plasma" + default: + current = "This window manager is not supported for now" + } + + text := "Current Desktop: " + current + "\n" + text += ` + +This window should be styled to look like a desktop application. It works with GTK/Gnome based desktops and KDE/Plasma at this time +For the others desktops, the application will look like a normal window with default theme. + +You may try to change icon theme or GTK/KDE theme in your desktop settings, as font, font scaling... + +Note that you need to have fontforge package to make Fyne able to convert non-ttf fonts to ttf. +` + label := widget.NewLabel(text) + label.Wrapping = fyne.TextWrapWord + return label +} diff --git a/go.mod b/go.mod index e8834a43..b2efd0f8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/eclipse/paho.mqtt.golang v1.3.5 github.com/gorilla/websocket v1.4.2 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/srwiley/oksvg v0.0.0-20200311192757-870daf9aa564 github.com/stretchr/testify v1.7.2 github.com/wagslane/go-password-validator v0.3.0 golang.org/x/image v0.0.0-20220601225756-64ec528b34cd diff --git a/theme/desktop/doc.go b/theme/desktop/doc.go new file mode 100644 index 00000000..c9110cd2 --- /dev/null +++ b/theme/desktop/doc.go @@ -0,0 +1,25 @@ +// Package desktop provides theme for Linux (for now) desktop environment like Gnome, KDE, Plasma... +// +// To be fully used, the system need to have gsettings and gjs for all GTK/Gnome based desktop. +// KDE/Plasma theme only works when the user has already initialize a session to create ~/.config/kdeglobals +// +// The package will try to use fontconfig ("fc-match" and "fontconfig" commands). +// This is not required but recommended to be able to generate TTF font if the user as configure a non TTF font. +// +// The package also tries to use "inkscape" or "convert" command (from ImageMagick) to generate SVG icons when they +// cannot be parsed by Fyne (it happens when oksvg package fails to load icons). +// +// Some recent desktop environment now use Adwaita as default theme. If this theme is applied, the desktop package +// loads the default Fyne theme colors. It only try to change the scaling factor, font and icons of the applications. +// +// The easiest way to use this package is to call the FromDesktopEnvironment function from "theme" package. +// +// Example: +// +// app := app.New() +// theme := FromDesktopEnvironment() +// app.Settings().SetTheme(theme) +// +// This loads the theme from the current detected desktop environment. For Windows and MacOS, and mobile devices +// it will return the default Fyne theme. +package desktop diff --git a/theme/desktop/gnome.go b/theme/desktop/gnome.go new file mode 100644 index 00000000..c0afbafa --- /dev/null +++ b/theme/desktop/gnome.go @@ -0,0 +1,588 @@ +//go:build linux +// +build linux + +package desktop + +import ( + "bytes" + "encoding/json" + "fmt" + "image/color" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" + "github.com/srwiley/oksvg" +) + +// mapping to gnome/gtk icon names. +var gnomeIconMap = map[fyne.ThemeIconName]string{ + theme.IconNameInfo: "dialog-information", + theme.IconNameError: "dialog-error", + theme.IconNameQuestion: "dialog-question", + + theme.IconNameFolder: "folder", + theme.IconNameFolderNew: "folder-new", + theme.IconNameFolderOpen: "folder-open", + theme.IconNameHome: "go-home", + theme.IconNameDownload: "download", + + theme.IconNameDocument: "document", + theme.IconNameFileImage: "image", + theme.IconNameFileApplication: "binary", + theme.IconNameFileText: "text", + theme.IconNameFileVideo: "video", + theme.IconNameFileAudio: "audio", + theme.IconNameComputer: "computer", + theme.IconNameMediaPhoto: "photo", + theme.IconNameMediaVideo: "video", + theme.IconNameMediaMusic: "music", + + theme.IconNameConfirm: "dialog-apply", + theme.IconNameCancel: "cancel", + + theme.IconNameCheckButton: "checkbox-symbolic", + theme.IconNameCheckButtonChecked: "checkbox-checked-symbolic", + theme.IconNameRadioButton: "radio-symbolic", + theme.IconNameRadioButtonChecked: "radio-checked-symbolic", + + theme.IconNameArrowDropDown: "arrow-down", + theme.IconNameArrowDropUp: "arrow-up", + theme.IconNameNavigateNext: "go-right", + theme.IconNameNavigateBack: "go-left", + theme.IconNameMoveDown: "go-down", + theme.IconNameMoveUp: "go-up", + theme.IconNameSettings: "document-properties", + theme.IconNameHistory: "history-view", + theme.IconNameList: "view-list", + theme.IconNameGrid: "view-grid", + theme.IconNameColorPalette: "color-select", + theme.IconNameColorChromatic: "color-select", + theme.IconNameColorAchromatic: "color-picker-grey", +} + +// Map Fyne colorname to Adwaita/GTK color names +// See https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/named-colors.html +var gnomeColorMap = map[fyne.ThemeColorName]string{ + theme.ColorNameBackground: "theme_bg_color,window_bg_color", + theme.ColorNameForeground: "theme_text_color,view_fg_color", + theme.ColorNameButton: "theme_base_color,view_bg_color", + theme.ColorNameInputBackground: "theme_base_color,view_bg_color", + theme.ColorNamePrimary: "accent_color,success_color", + theme.ColorNameError: "error_color", +} + +// Script to get the colors from the Gnome GTK/Adwaita theme. +const gjsColorScript = ` +let gtkVersion = Number(ARGV[0] || 4); +imports.gi.versions.Gtk = gtkVersion + ".0"; + +const { Gtk, Gdk } = imports.gi; +if (gtkVersion === 3) { + Gtk.init(null); +} else { + Gtk.init(); +} + +const colors = {}; +const win = new Gtk.Window(); +const ctx = win.get_style_context(); +const colorMap = %s; + +for (let col in colorMap) { + let [ok, bg] = [false, null]; + let found = false; + colorMap[col].split(",").forEach((fetch) => { + [ok, bg] = ctx.lookup_color(fetch); + if (ok && !found) { + found = true; + colors[col] = [bg.red, bg.green, bg.blue, bg.alpha]; + } + }); +} + +print(JSON.stringify(colors)); +` + +// Script to get icons from theme. +const gjsIconsScript = ` +let gtkVersion = Number(ARGV[0] || 4); +imports.gi.versions.Gtk = gtkVersion + ".0"; +const iconSize = 96; // can be 8, 16, 24, 32, 48, 64, 96 + +const { Gtk, Gdk } = imports.gi; +if (gtkVersion === 3) { + Gtk.init(null); +} else { + Gtk.init(); +} + +let iconTheme = null; +const icons = %s; // the icon list to get +const iconset = {}; + +if (gtkVersion === 3) { + iconTheme = Gtk.IconTheme.get_default(); +} else { + iconTheme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()); +} + +icons.forEach((name) => { + try { + if (gtkVersion === 3) { + const icon = iconTheme.lookup_icon(name, iconSize, 0); + iconset[name] = icon.get_filename(); + } else { + const icon = iconTheme.lookup_icon(name, null, null, iconSize, null, 0); + iconset[name] = icon.file.get_path(); + } + } catch (e) { + iconset[name] = null; + } +}); + +print(JSON.stringify(iconset)); +` + +// GnomeTheme theme, based on the Gnome desktop manager. This theme uses GJS and gsettings to get +// the colors and font from the Gnome desktop. +type GnomeTheme struct { + colors map[fyne.ThemeColorName]color.Color + icons map[string]string + + scaleFactor float32 + font fyne.Resource + fontSize float32 + iconCache map[string]fyne.Resource + + themeName string +} + +// Color returns the color for the given color name +// +// Implements: fyne.Theme +func (gnome *GnomeTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { + + if col, ok := gnome.colors[name]; ok { + return col + } + + return theme.DefaultTheme().Color(name, variant) +} + +// Font returns the font for the given name. +// +// Implements: fyne.Theme +func (gnome *GnomeTheme) Font(s fyne.TextStyle) fyne.Resource { + if gnome.font == nil { + return theme.DefaultTheme().Font(s) + } + return gnome.font +} + +// Icon returns the icon for the given name. +// +// Implements: fyne.Theme +func (gnome *GnomeTheme) Icon(i fyne.ThemeIconName) fyne.Resource { + if icon, found := gnomeIconMap[i]; found { + if resource := gnome.loadIcon(icon); resource != nil { + return resource + } + } + return theme.DefaultTheme().Icon(i) +} + +// Invert is a specific Gnome/GTK option to invert the theme color for background of window and some input +// widget. This to help to imitate some GTK application with "views" inside the window. +func (gnome *GnomeTheme) Invert() { + + gnome.colors[theme.ColorNameBackground], + gnome.colors[theme.ColorNameInputBackground], + gnome.colors[theme.ColorNameButton] = + gnome.colors[theme.ColorNameButton], + gnome.colors[theme.ColorNameBackground], + gnome.colors[theme.ColorNameBackground] +} + +// Size returns the size for the given name. It will scale the detected Gnome font size +// by the Gnome font factor. +// +// Implements: fyne.Theme +func (gnome *GnomeTheme) Size(s fyne.ThemeSizeName) float32 { + switch s { + case theme.SizeNameText: + return gnome.scaleFactor * gnome.fontSize + } + return theme.DefaultTheme().Size(s) * gnome.scaleFactor +} + +// applyColors sets the colors for the Gnome theme. Colors are defined by a GJS script. +func (gnome *GnomeTheme) applyColors(gtkVersion int, wg *sync.WaitGroup) { + + if wg != nil { + defer wg.Done() + } + // we will call gjs to get the colors + gjs, err := exec.LookPath("gjs") + if err != nil { + log.Println("To activate the theme, please install gjs", err) + return + } + + // create a temp file to store the colors + f, err := ioutil.TempFile("", "fyne-theme-gnome-*.js") + if err != nil { + log.Println(err) + return + } + defer os.Remove(f.Name()) + + // generate the js object from gnomeColorMap + colormap := "{\n" + for col, fetch := range gnomeColorMap { + colormap += fmt.Sprintf(` "%s": "%s",`+"\n", col, fetch) + } + colormap += "}" + + // write the script to the temp file + script := fmt.Sprintf(gjsColorScript, colormap) + _, err = f.WriteString(script) + if err != nil { + log.Println(err) + return + } + + // run the script + cmd := exec.Command(gjs, + f.Name(), strconv.Itoa(gtkVersion), + fmt.Sprintf("%0.2f", 1.0), + ) + out, err := cmd.CombinedOutput() + if err != nil { + log.Println("gjs error:", err, string(out)) + return + } + + // decode json + var colors map[fyne.ThemeColorName][]float32 + err = json.Unmarshal(out, &colors) + if err != nil { + log.Println("gjs error:", err, string(out)) + return + } + for name, rgba := range colors { + // convert string arry to colors + gnome.colors[name] = gnome.parseColor(rgba) + } + +} + +// applyFont gets the font name from gsettings and set the font size. This also calls +// setFont() to set the font. +func (gnome *GnomeTheme) applyFont(wg *sync.WaitGroup) { + + if wg != nil { + defer wg.Done() + } + + gnome.font = theme.TextFont() + // call gsettings get org.gnome.desktop.interface font-name + cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "font-name") + out, err := cmd.CombinedOutput() + if err != nil { + log.Println(err) + log.Println(string(out)) + return + } + // try to get the font as a TTF file + fontFile := strings.TrimSpace(string(out)) + fontFile = strings.Trim(fontFile, "'") + // the fontFile string is in the format: Name size, eg: "Sans Bold 12", so get the size + parts := strings.Split(fontFile, " ") + fontSize := parts[len(parts)-1] + // convert the size to a float + size, err := strconv.ParseFloat(fontSize, 32) + if err != nil { + log.Println(err) + return + } + // apply this to the fontScaleFactor + gnome.fontSize = float32(size) + + // try to get the font as a TTF file + gnome.setFont(strings.Join(parts[:len(parts)-1], " ")) +} + +// applyFontScale find the font scaling factor in settings. +func (gnome *GnomeTheme) applyFontScale(wg *sync.WaitGroup) { + if wg != nil { + defer wg.Done() + } + // for any error below, we will use the default + gnome.scaleFactor = 1 + + // call gsettings get org.gnome.desktop.interface text-scaling-factor + cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "text-scaling-factor") + out, err := cmd.CombinedOutput() + if err != nil { + return + } + + // get the text scaling factor + ts := strings.TrimSpace(string(out)) + scaleValue, err := strconv.ParseFloat(ts, 32) + if err != nil { + return + } + + // return the text scaling factor + gnome.scaleFactor = float32(scaleValue) +} + +// applyIcons gets the icon theme from gsettings and call GJS script to get the icon set. +func (gnome *GnomeTheme) applyIcons(gtkVersion int, wg *sync.WaitGroup) { + + if wg != nil { + defer wg.Done() + } + + gjs, err := exec.LookPath("gjs") + if err != nil { + log.Println("To activate the theme, please install gjs", err) + return + } + // create the list of icon to get + var icons []string + for _, icon := range gnomeIconMap { + icons = append(icons, icon) + } + iconSet := "[\n" + for _, icon := range icons { + iconSet += fmt.Sprintf(` "%s",`+"\n", icon) + } + iconSet += "]" + + gjsIconList := fmt.Sprintf(gjsIconsScript, iconSet) + + // write the script to a temp file + f, err := ioutil.TempFile("", "fyne-theme-gnome-*.js") + if err != nil { + log.Println(err) + return + } + defer os.Remove(f.Name()) + + // write the script to the temp file + _, err = f.WriteString(gjsIconList) + if err != nil { + log.Println(err) + return + } + + // Call gjs with 2 version, 3 and 4 to complete the icon, this because + // gtk version is sometimes not available or icon is not fully completed... + // It's a bit tricky but it works. + for _, gtkVersion := range []string{"3", "4"} { + // run the script + cmd := exec.Command(gjs, + f.Name(), gtkVersion, + ) + out, err := cmd.CombinedOutput() + if err != nil { + log.Println("gjs error:", err, string(out)) + return + } + + tmpicons := map[string]*string{} + // decode json to apply to the gnome theme + err = json.Unmarshal(out, &tmpicons) + if err != nil { + log.Println(err) + return + } + for k, v := range tmpicons { + if _, ok := gnome.icons[k]; !ok { + if v != nil && *v != "" { + gnome.icons[k] = *v + } + } + } + } +} + +// findThemeInformation decodes the theme from the gsettings and Gtk API. +func (gnome *GnomeTheme) findThemeInformation(gtkVersion int, variant fyne.ThemeVariant) { + // make things faster in concurrent mode + + themename := gnome.getThemeName() + if themename == "" { + return + } + gnome.themeName = themename + wg := sync.WaitGroup{} + wg.Add(4) + go gnome.applyColors(gtkVersion, &wg) + go gnome.applyIcons(gtkVersion, &wg) + go gnome.applyFont(&wg) + go gnome.applyFontScale(&wg) + wg.Wait() +} + +// getGTKVersion gets the available GTK version for the given theme. If the version cannot be +// determine, it will return 3 wich is the most common used version. +func (gnome *GnomeTheme) getGTKVersion() (version int) { + + version = 3 + + // ok so now, find if the theme is gtk4, either fallback to gtk3 + home, err := os.UserHomeDir() + if err != nil { + log.Println(err) + return + } + + possiblePaths := []string{ + home + "/.local/share/themes/", + home + "/.themes/", + `/usr/local/share/themes/`, + `/usr/share/themes/`, + } + + for _, path := range possiblePaths { + path = filepath.Join(path, gnome.themeName) + if _, err := os.Stat(path); err == nil { + // now check if it is gtk4 compatible + if _, err := os.Stat(path + "gtk-4.0/gtk.css"); err == nil { + // it is gtk4 + version = 3 + return + } + if _, err := os.Stat(path + "gtk-3.0/gtk.css"); err == nil { + version = 3 + return + } + } + } + return // default, but that may be a false positive now +} + +// getThemeName gets the current theme name. +func (gnome *GnomeTheme) getThemeName() string { + // call gsettings get org.gnome.desktop.interface gtk-theme + cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme") + out, err := cmd.CombinedOutput() + if err != nil { + log.Println(err) + log.Println(string(out)) + return "" + } + themename := strings.TrimSpace(string(out)) + themename = strings.Trim(themename, "'") + return themename +} + +// loadIcon loads the icon from gnome theme, if the icon was already loaded, so the cached version is returned. +func (gnome *GnomeTheme) loadIcon(name string) (resource fyne.Resource) { + var ok bool + + if resource, ok = gnome.iconCache[name]; ok { + return + } + + defer func() { + // whatever the result is, cache it + // even if it is nil + gnome.iconCache[name] = resource + }() + + if filename, ok := gnome.icons[name]; ok { + content, err := ioutil.ReadFile(filename) + if err != nil { + log.Println("Error while loading icon", err) + return + } + if strings.HasSuffix(filename, ".svg") { + // we need to ensure that the svg can be opened by Fyne + buff := bytes.NewBuffer(content) + _, err := oksvg.ReadIconStream(buff) + if err != nil { + // try to convert it to png with a converter + log.Println("Cannot load file", filename, err, ", try to convert with tools") + resource, err = convertSVGtoPNG(filename) + if err != nil { + log.Println("Cannot convert file", filename, ":", err) + } + return + } + } + resource = fyne.NewStaticResource(filename, content) + return + } + return +} + +// parseColor converts a float32 array to color.Color. +func (*GnomeTheme) parseColor(col []float32) color.Color { + return color.RGBA{ + R: uint8(col[0] * 255), + G: uint8(col[1] * 255), + B: uint8(col[2] * 255), + A: uint8(col[3] * 255), + } +} + +// setFont sets the font for the theme - this method calls getFontPath() and converToTTF +// if needed. +func (gnome *GnomeTheme) setFont(fontname string) { + + fontpath, err := getFontPath(fontname) + if err != nil { + log.Println(err) + return + } + + ext := filepath.Ext(fontpath) + if ext != ".ttf" { + font, err := converToTTF(fontpath) + if err != nil { + log.Println(err) + return + } + gnome.font = fyne.NewStaticResource(fontpath, font) + } else { + font, err := ioutil.ReadFile(fontpath) + if err != nil { + log.Println(err) + return + } + gnome.font = fyne.NewStaticResource(fontpath, font) + } +} + +// NewGnomeTheme returns a new Gnome theme based on the given gtk version. If gtkVersion is <= 0, +// the theme will try to determine the higher Gtk version available for the current GtkTheme. +func NewGnomeTheme(gtkVersion int) fyne.Theme { + gnome := &GnomeTheme{ + fontSize: theme.DefaultTheme().Size(theme.SizeNameText), + iconCache: map[string]fyne.Resource{}, + icons: map[string]string{}, + colors: map[fyne.ThemeColorName]color.Color{}, + font: theme.DefaultTextFont(), + scaleFactor: 1.0, + } + + if gtkVersion <= 0 { + // detect gtkVersion + gtkVersion = gnome.getGTKVersion() + } + + gnome.findThemeInformation(gtkVersion, theme.VariantDark) + return gnome +} diff --git a/theme/desktop/gnomeNotLinux.go b/theme/desktop/gnomeNotLinux.go new file mode 100644 index 00000000..c8324254 --- /dev/null +++ b/theme/desktop/gnomeNotLinux.go @@ -0,0 +1,14 @@ +//go:build !linux +// +build !linux + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// NewGnomeTheme returns the GNOME theme. If the current OS is not Linux, it returns the default theme. +func NewGnomeTheme() fyne.Theme { + return theme.DefaultTheme() +} diff --git a/theme/desktop/gnome_test.go b/theme/desktop/gnome_test.go new file mode 100644 index 00000000..3647eb6c --- /dev/null +++ b/theme/desktop/gnome_test.go @@ -0,0 +1,41 @@ +//go:build linux +// +build linux + +package desktop + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/widget" +) + +func ExampleNewGnomeTheme() { + app := app.New() + app.Settings().SetTheme(NewGnomeTheme(0)) +} + +// Force GTK version to 3 +func ExampleNewGnomeTheme_forceGtkVersion() { + app := app.New() + app.Settings().SetTheme(NewGnomeTheme(3)) +} + +// Will reload theme when it changes in Gnome (or other GTK environment) +// connecting to DBus signal. +func ExampleNewGnomeTheme_autoReload() { + app := app.New() + app.Settings().SetTheme(NewGnomeTheme(0)) +} + +// Check if the GnomeTheme can be loaded. +func TestGnomeTheme(t *testing.T) { + app := test.NewApp() + app.Settings().SetTheme(NewGnomeTheme(0)) + win := app.NewWindow("Test") + defer win.Close() + win.Resize(fyne.NewSize(200, 200)) + win.SetContent(widget.NewLabel("Hello")) +} diff --git a/theme/desktop/kde.go b/theme/desktop/kde.go new file mode 100644 index 00000000..d1da143c --- /dev/null +++ b/theme/desktop/kde.go @@ -0,0 +1,206 @@ +//go:build linux +// +build linux + +package desktop + +import ( + "image/color" + "io/ioutil" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "fyne.io/fyne/v2" + ft "fyne.io/fyne/v2/theme" +) + +// KDETheme theme is based on the KDETheme or Plasma theme. +type KDETheme struct { + variant fyne.ThemeVariant + bgColor color.Color + fgColor color.Color + viewColor color.Color + buttonColor color.Color + buttonAlternate color.Color + fontConfig string + fontSize float32 + font fyne.Resource +} + +// Color returns the color for the specified name. +// +// Implements: fyne.Theme +func (k *KDETheme) Color(name fyne.ThemeColorName, _ fyne.ThemeVariant) color.Color { + switch name { + case ft.ColorNameBackground: + return k.bgColor + case ft.ColorNameForeground: + return k.fgColor + case ft.ColorNameButton: + return k.buttonColor + case ft.ColorNameDisabledButton: + return k.buttonAlternate + case ft.ColorNameInputBackground: + return k.viewColor + + } + return ft.DefaultTheme().Color(name, k.variant) +} + +// Font returns the font for the specified name. +// +// Implements: fyne.Theme +func (k *KDETheme) Font(s fyne.TextStyle) fyne.Resource { + if k.font != nil { + return k.font + } + return ft.DefaultTheme().Font(s) +} + +// Icon returns the icon for the specified name. +// +// Implements: fyne.Theme +func (k *KDETheme) Icon(i fyne.ThemeIconName) fyne.Resource { + return ft.DefaultTheme().Icon(i) +} + +// Size returns the size of the font for the specified text style. +// +// Implements: fyne.Theme +func (k *KDETheme) Size(s fyne.ThemeSizeName) float32 { + if s == ft.SizeNameText { + return k.fontSize + } + return ft.DefaultTheme().Size(s) +} + +// decodeTheme initialize the theme. +func (k *KDETheme) decodeTheme() error { + if err := k.loadScheme(); err != nil { + return err + } + k.setFont() + return nil +} + +// loadScheme loads the KDE theme from kdeglobals if it is found. +func (k *KDETheme) loadScheme() error { + // the theme name is declared in ~/.config/kdedefaults/kdeglobals + // in the ini section [General] as "ColorScheme" entry + homedir, err := os.UserHomeDir() + if err != nil { + return err + } + content, err := ioutil.ReadFile(filepath.Join(homedir, ".config/kdeglobals")) + if err != nil { + return err + } + + section := "" + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "[") { + section = strings.ReplaceAll(line, "[", "") + section = strings.ReplaceAll(section, "]", "") + } + if section == "Colors:Window" { + if strings.HasPrefix(line, "BackgroundNormal=") { + k.bgColor = k.parseColor(strings.ReplaceAll(line, "BackgroundNormal=", "")) + } + if strings.HasPrefix(line, "ForegroundNormal=") { + k.fgColor = k.parseColor(strings.ReplaceAll(line, "ForegroundNormal=", "")) + } + } + if section == "Colors:Button" { + if strings.HasPrefix(line, "BackgroundNormal=") { + k.buttonColor = k.parseColor(strings.ReplaceAll(line, "BackgroundNormal=", "")) + } + if strings.HasPrefix(line, "BackgroundAlternate=") { + k.buttonAlternate = k.parseColor(strings.ReplaceAll(line, "BackgroundAlternate=", "")) + } + } + if section == "Colors:View" { + if strings.HasPrefix(line, "BackgroundNormal=") { + k.viewColor = k.parseColor(strings.ReplaceAll(line, "BackgroundNormal=", "")) + } + } + if section == "General" { + if strings.HasPrefix(line, "font=") { + k.fontConfig = strings.ReplaceAll(line, "font=", "") + } + } + } + + return nil +} + +// parseColor parses a color from a string in form r,g,b or r,g,b,a. +func (k *KDETheme) parseColor(col string) color.Color { + // the color is in the form r,g,b, + // we need to convert it to a color.Color + + // split the string + cols := strings.Split(col, ",") + // convert the string to int + r, _ := strconv.Atoi(cols[0]) + g, _ := strconv.Atoi(cols[1]) + b, _ := strconv.Atoi(cols[2]) + a := 0xff + if len(cols) > 3 { + a, _ = strconv.Atoi(cols[3]) + } + + // convert the int to a color.Color + return color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} +} + +// setFont sets the font for the theme. +func (k *KDETheme) setFont() { + + if k.fontConfig == "" { + return + } + // the fontline is in the form "fontline,size,...", so we can split it + fontline := strings.SplitN(k.fontConfig, ",", 2) + name := fontline[0] + size, _ := strconv.ParseFloat(fontline[1], 32) + k.fontSize = float32(size) + + // we need to load the font, Gnome struct has got some nice methods + fontpath, err := getFontPath(name) + if err != nil { + log.Println(err) + return + } + + var font []byte + if filepath.Ext(fontpath) == ".ttf" { + font, err = ioutil.ReadFile(fontpath) + if err != nil { + log.Println(err) + return + } + } else { + font, err = converToTTF(fontpath) + if err != nil { + log.Println(err) + return + } + } + k.font = fyne.NewStaticResource(fontpath, font) +} + +// NewKDETheme returns a new KDE theme. +func NewKDETheme() fyne.Theme { + kde := &KDETheme{ + variant: ft.VariantDark, + } + if err := kde.decodeTheme(); err != nil { + log.Println(err) + return ft.DefaultTheme() + } + + return kde +} diff --git a/theme/desktop/kdeNotLinux.go b/theme/desktop/kdeNotLinux.go new file mode 100644 index 00000000..3ecb4d9e --- /dev/null +++ b/theme/desktop/kdeNotLinux.go @@ -0,0 +1,14 @@ +//go:build !linux +// +build !linux + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// NewKdeTheme returns the KDE theme. If the current OS is not Linux, it returns the default theme. +func NewKDETheme() fyne.Theme { + return theme.DefaultTheme() +} diff --git a/theme/desktop/kde_test.go b/theme/desktop/kde_test.go new file mode 100644 index 00000000..f248ec1b --- /dev/null +++ b/theme/desktop/kde_test.go @@ -0,0 +1,49 @@ +//go:build linux +// +build linux + +package desktop + +import ( + "io/ioutil" + "os" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/widget" +) + +func setup() (tmp, home string) { + // create a false home directory + var err error + tmp, err = ioutil.TempDir("", "fyne-test-") + if err != nil { + panic(err) + } + home = os.Getenv("HOME") + os.Setenv("HOME", tmp) + + // creat a false KDE configuration + if err = os.MkdirAll(tmp+"/.config", 0755); err != nil { + panic(err) + } + content := []byte("[General]\nwidgetStyle=GTK") + ioutil.WriteFile(tmp+"/.config/kdeglobals", content, 0644) + return +} + +func teardown(tmp, home string) { + os.RemoveAll(tmp) + os.Setenv("HOME", home) +} + +func TestKDETheme(t *testing.T) { + tmp, home := setup() + defer teardown(tmp, home) + app := test.NewApp() + app.Settings().SetTheme(NewKDETheme()) + win := app.NewWindow("Test") + defer win.Close() + win.Resize(fyne.NewSize(200, 200)) + win.SetContent(widget.NewLabel("Hello")) +} diff --git a/theme/desktop/utils.go b/theme/desktop/utils.go new file mode 100644 index 00000000..5e7f7e51 --- /dev/null +++ b/theme/desktop/utils.go @@ -0,0 +1,161 @@ +//go:build linux +// +build linux + +package desktop + +import ( + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "fyne.io/fyne/v2" +) + +// convertSVGtoPNG will convert a SVG file to a PNG file. It will try to detect if some common tools to convert SVG exists, +// like inkscape or ImageMagik "convert". If not, the resource is not converted. +func convertSVGtoPNG(filename string) (fyne.Resource, error) { + tmpfile, err := ioutil.TempFile("", "fyne-theme-gnome-*.png") + if err != nil { + return nil, err + } + defer os.Remove(tmpfile.Name()) + + pngConverterOptions := map[string][]string{ + "inkscape": {"--without-gui", "--export-type=png", "--export-background-opacity=0", filename, "-o", tmpfile.Name()}, + "convert": {"-background", "transparent", "-flatten", filename, tmpfile.Name()}, + } + + var commandName string + var opts []string + for binary, options := range pngConverterOptions { + if path, err := exec.LookPath(binary); err == nil { + commandName = path + opts = options + break + } + } + + if commandName == "" { + return nil, errors.New("you must install inkscape or imageMagik (convert command) to be able to convert SVG icons to PNG") + } + + // convert the svg to png, no background + log.Println("Converting", filename, "to", tmpfile.Name()) + cmd := exec.Command(commandName, opts...) + + err = cmd.Run() + if err != nil { + return nil, err + } + + content, err := ioutil.ReadFile(tmpfile.Name()) + if err != nil { + return nil, err + } + + return fyne.NewStaticResource(tmpfile.Name(), content), nil +} + +// converToTTF will convert a font to a ttf file. This requires the fontforge package. +func converToTTF(fontpath string) ([]byte, error) { + + // check if fontforge is installed + fontforge, err := exec.LookPath("fontforge") + if err != nil { + return nil, err + } + + // convert the font to a ttf file + basename := filepath.Base(fontpath) + tempTTF := filepath.Join(os.TempDir(), "fyne-"+basename+".ttf") + + // Convert to TTF, this is the FF script to call + ffScript := `Open("%s");Generate("%s")` + script := fmt.Sprintf(ffScript, fontpath, tempTTF) + + // call fontforge + cmd := exec.Command(fontforge, "-c", script) + cmd.Env = append(cmd.Env, "FONTFORGE_LANGUAGE=ff") + + out, err := cmd.CombinedOutput() + if err != nil { + log.Println(err) + log.Println(string(out)) + return nil, err + } + defer os.Remove(tempTTF) + + // read the temporary ttf file + return ioutil.ReadFile(tempTTF) +} + +// getFontPath will detect the font path from the font name taken from gsettings. +// As the font is not exactly the one that fc-match can find, we need to do some +// extra work to rebuild the name with style. +func getFontPath(fontname string) (string, error) { + + // check if fc-list and fc-match are installed + fcList, err := exec.LookPath("fc-list") + if err != nil { + return "", err + } + + fcMatch, err := exec.LookPath("fc-match") + if err != nil { + return "", err + } + + // This to transoform CamelCase to Camel-Case + camelRegExp := regexp.MustCompile(`([a-z\-])([A-Z])`) + + // get all possible styles in fc-list + allstyles := []string{} + cmd := exec.Command(fcList, "--format", "%{style}\n") + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + styles := strings.Split(string(out), "\n") + for _, style := range styles { + if style != "" { + split := strings.Split(style, ",") + for _, s := range split { + allstyles = append(allstyles, s) + // we also need to add a "-" for camel cases + s = camelRegExp.ReplaceAllString(s, "$1-$2") + allstyles = append(allstyles, s) + } + } + } + + // Find the styles, remove it from the nmae, this make a correct fc-match query + fontstyle := []string{} + for _, style := range allstyles { + if strings.Contains(fontname, " "+style) { + fontstyle = append(fontstyle, style) + fontname = strings.ReplaceAll(fontname, style, "") + } + } + + // we can now search + // fc-match ... "Font Name:Font Style + var fontpath string + cmd = exec.Command(fcMatch, "-f", "%{file}", fontname+":"+strings.Join(fontstyle, " ")) + out, err = cmd.CombinedOutput() + if err != nil { + log.Println(err) + log.Println(string(out)) + return "", err + } + + // get the font path with fc-list command + fontpath = string(out) + fontpath = strings.TrimSpace(fontpath) + return fontpath, nil +} diff --git a/theme/desktopEnvironmentLinux.go b/theme/desktopEnvironmentLinux.go new file mode 100644 index 00000000..d6c72790 --- /dev/null +++ b/theme/desktopEnvironmentLinux.go @@ -0,0 +1,35 @@ +//go:build linux +// +build linux + +package theme + +import ( + "log" + "os" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" + "fyne.io/x/fyne/theme/desktop" +) + +// FromDesktopEnvironment returns a new WindowManagerTheme instance for the current desktop session. +// If the desktop manager is not supported or if it is not found, return the default theme +func FromDesktopEnvironment() fyne.Theme { + wm := os.Getenv("XDG_CURRENT_DESKTOP") + if wm == "" { + wm = os.Getenv("DESKTOP_SESSION") + } + wm = strings.ToLower(wm) + + switch wm { + case "gnome", "xfce", "unity", "gnome-shell", "gnome-classic", "mate", "gnome-mate": + return desktop.NewGnomeTheme(-1) + case "kde", "kde-plasma", "plasma", "lxqt": + return desktop.NewKDETheme() + + } + + log.Println("Window manager not supported:", wm, "using default theme") + return theme.DefaultTheme() +} diff --git a/theme/desktopEnvironmentNotLinux.go b/theme/desktopEnvironmentNotLinux.go new file mode 100644 index 00000000..120b4ea8 --- /dev/null +++ b/theme/desktopEnvironmentNotLinux.go @@ -0,0 +1,13 @@ +//go:build !linux +// +build !linux + +package theme + +import ( + "fyne.io/fyne/v2" + fynetheme "fyne.io/fyne/v2/theme" +) + +func FromDesktopEnvironment() fyne.Theme { + return fynetheme.DefaultTheme() +} diff --git a/theme/desktopEnvironment_test.go b/theme/desktopEnvironment_test.go new file mode 100644 index 00000000..454c4be8 --- /dev/null +++ b/theme/desktopEnvironment_test.go @@ -0,0 +1,99 @@ +//go:build linux +// +build linux + +package theme + +import ( + "io/ioutil" + "os" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "fyne.io/x/fyne/theme/desktop" +) + +func setup() (tmp, home string) { + // create a false home directory + var err error + tmp, err = ioutil.TempDir("", "fyne-test-") + if err != nil { + panic(err) + } + home = os.Getenv("HOME") + os.Setenv("HOME", tmp) + + // creat a false KDE configuration + if err = os.MkdirAll(tmp+"/.config", 0755); err != nil { + panic(err) + } + content := []byte("[General]\nwidgetStyle=GTK") + ioutil.WriteFile(tmp+"/.config/kdeglobals", content, 0644) + + return +} + +func teardown(tmp, home string) { + os.Unsetenv("XDG_CURRENT_DESKTOP") + os.RemoveAll(tmp) + os.Setenv("HOME", home) +} + +// ExampleFromDesktopEnvironment_simple demonstrates how to use the FromDesktopEnvironment function. +func ExampleFromDesktopEnvironment_simple() { + app := app.New() + theme := FromDesktopEnvironment() + app.Settings().SetTheme(theme) +} + +// Test to load from desktop environment. +func TestLoadFromEnvironment(t *testing.T) { + tmp, home := setup() + defer teardown(tmp, home) + + // Set XDG_CURRENT_DESKTOP to "GNOME" + envs := []string{"GNOME", "KDE", "FAKE"} + for _, env := range envs { + // chante desktop environment + os.Setenv("XDG_CURRENT_DESKTOP", env) + app := test.NewApp() + app.Settings().SetTheme(FromDesktopEnvironment()) + win := app.NewWindow("Test") + defer win.Close() + win.Resize(fyne.NewSize(200, 200)) + win.SetContent(widget.NewLabel("Hello")) + + // check if the theme is loaded + current := app.Settings().Theme() + // Check if the type of the theme is correct + if current == nil { + t.Error("Theme is nil") + } + switch env { + case "GNOME": + switch v := current.(type) { + case *desktop.GnomeTheme: + // OK + default: + t.Error("Theme is not GnomeTheme") + t.Logf("Theme is %T\n", v) + } + case "KDE": + switch v := current.(type) { + case *desktop.KDETheme: + // OK + default: + t.Error("Theme is not KDETheme") + t.Logf("Theme is %T\n", v) + } + case "FAKE": + if current != theme.DefaultTheme() { + t.Error("Theme is not DefaultTheme") + } + } + + } +}