Linux versino and Gif output

This commit is contained in:
2026-10-17 06:21:19 +00:00
parent 396a676146
commit f6ac9c2f34
12 changed files with 728 additions and 122 deletions

210
platform_linux.go Normal file
View File

@@ -0,0 +1,210 @@
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"syscall"
"time"
)
// dialogTool is the zenity or kdialog binary used for native file dialogs, or
// "" when neither is installed (or there is no display) and the UI uses its
// built-in file browser.
var dialogTool = findDialogTool()
var nativeDialogs = dialogTool != ""
func findDialogTool() string {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return ""
}
order := []string{"zenity", "kdialog"}
if strings.Contains(strings.ToUpper(os.Getenv("XDG_CURRENT_DESKTOP")), "KDE") {
order = []string{"kdialog", "zenity"}
}
for _, name := range order {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
// hideConsole puts child programs (ffmpeg, dialogs) in their own process group
// so Ctrl+C in the terminal reaches only VibEdit, which then cancels them and
// cleans up. They are killed if VibEdit itself dies.
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
}
// listRoots offers / plus mounted drives for the built-in file browser.
func listRoots() []string {
roots := []string{"/"}
var dirs []string
if u, err := user.Current(); err == nil {
dirs = append(dirs, "/run/media/"+u.Username, "/media/"+u.Username)
}
dirs = append(dirs, "/mnt")
for _, d := range dirs {
des, _ := os.ReadDir(d)
for _, de := range des {
if de.IsDir() && !strings.HasPrefix(de.Name(), ".") {
roots = append(roots, filepath.Join(d, de.Name()))
}
}
}
return roots
}
// openBrowser prefers a chromeless Chrome/Chromium/Edge/Brave "app" window,
// which feels like a normal desktop program, and falls back to the default
// browser.
func openBrowser(url string, preferDefault bool) error {
if !preferDefault {
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
"microsoft-edge", "microsoft-edge-stable", "brave-browser"} {
p, err := exec.LookPath(name)
if err != nil {
continue
}
if err := startDetached(p, "--app="+url, "--window-size=1440,920"); err == nil {
return nil
}
}
}
return startDetached("xdg-open", url)
}
// startDetached starts a desktop program in its own session, so it neither
// gets the terminal's Ctrl+C nor is tied to VibEdit's lifetime.
func startDetached(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return err
}
go cmd.Wait()
return nil
}
// revealInFileManager asks the file manager to show the file selected (the
// FileManager1 D-Bus interface of Nautilus, Dolphin, Nemo, Caja and Thunar)
// and falls back to opening its folder.
func revealInFileManager(path string) error {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "dbus-send", "--session", "--print-reply", "--reply-timeout=4000",
"--type=method_call", "--dest=org.freedesktop.FileManager1", "/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems", "array:string:"+fileURI(path), "string:").Run()
if err == nil {
return nil
}
return startDetached("xdg-open", filepath.Dir(path))
}
// fileURI percent-encodes everything except unreserved characters and slashes
// (dbus-send would otherwise split the array argument at commas).
func fileURI(path string) string {
var b strings.Builder
b.WriteString("file://")
for i := 0; i < len(path); i++ {
c := path[i]
if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || strings.IndexByte("/-._~", c) >= 0 {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
func waitBeforeExit() {}
func isKDialog() bool { return filepath.Base(dialogTool) == "kdialog" }
// runDialog runs zenity/kdialog and returns the chosen paths, one per output
// line. Both exit with status 1 when the user cancels.
func runDialog(args ...string) ([]string, error) {
if dialogTool == "" {
return nil, errors.New("no file dialog program (zenity or kdialog) found")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
out, err := command(ctx, dialogTool, args...).Output()
var exit *exec.ExitError
if errors.As(err, &exit) && exit.ExitCode() == 1 {
return nil, nil
}
if err != nil {
return nil, err
}
var lines []string
for _, l := range strings.Split(string(out), "\n") {
if l != "" {
lines = append(lines, l)
}
}
return lines, nil
}
// videoPatterns lists every media extension in lower and upper case, since
// GTK file filters are case-sensitive.
func videoPatterns() string {
var pats []string
for _, e := range mediaExts {
pats = append(pats, "*."+e, "*."+strings.ToUpper(e))
}
return strings.Join(pats, " ")
}
func nativeOpenDialog(title, dir string) ([]string, error) {
if !isDir(dir) {
dir, _ = os.UserHomeDir()
}
if isKDialog() {
return runDialog("--title", title, "--multiple", "--separate-output",
"--getopenfilename", dir, "Video files ("+videoPatterns()+")\nAll files (*)")
}
return runDialog("--file-selection", "--multiple", "--separator=\n", "--title="+title,
"--filename="+strings.TrimSuffix(dir, "/")+"/",
"--file-filter=Video files | "+videoPatterns(), "--file-filter=All files | *")
}
func nativeSaveDialog(title, path string) (string, error) {
ext := strings.TrimPrefix(filepath.Ext(path), ".")
var lines []string
var err error
if isKDialog() {
filter := "All files (*)"
if ext != "" {
filter = strings.ToUpper(ext) + " video (*." + ext + ")\n" + filter
}
lines, err = runDialog("--title", title, "--getsavefilename", path, filter)
} else {
args := []string{"--file-selection", "--save", "--title=" + title, "--filename=" + path}
if ext != "" {
args = append(args, "--file-filter="+strings.ToUpper(ext)+" video | *."+ext)
}
lines, err = runDialog(append(args, "--file-filter=All files | *")...)
}
if err != nil || len(lines) == 0 {
return "", err
}
// Like the Windows dialog, add the default extension when none was typed.
p := lines[0]
if ext != "" && filepath.Ext(p) == "" {
p += "." + ext
}
return p, nil
}