1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-24 12:05:21 -04:00

feat(pluginBrowser): Add inline image previews while browsing

This commit is contained in:
purian23
2026-06-23 20:00:55 -04:00
parent bed11feaa4
commit b2e728315b
7 changed files with 245 additions and 56 deletions
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/net" "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/net"
coreplugins "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -165,6 +166,7 @@ func TestPluginInfoJSON(t *testing.T) {
info := PluginInfo{ info := PluginInfo{
Name: "test", Name: "test",
Description: "test description", Description: "test description",
Screenshot: "https://raw.githubusercontent.com/test/repo/main/screenshot.png",
Installed: true, Installed: true,
FirstParty: true, FirstParty: true,
} }
@@ -177,6 +179,59 @@ func TestPluginInfoJSON(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, info.Name, unmarshaled.Name) assert.Equal(t, info.Name, unmarshaled.Name)
assert.Equal(t, info.Installed, unmarshaled.Installed) assert.Equal(t, info.Installed, unmarshaled.Installed)
assert.Equal(t, info.Screenshot, unmarshaled.Screenshot)
}
func TestNormalizeScreenshotURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "raw github url is unchanged",
raw: "https://raw.githubusercontent.com/alcxyz/DankVault/main/docs/screenshot.png",
want: "https://raw.githubusercontent.com/alcxyz/DankVault/main/docs/screenshot.png",
},
{
name: "github blob url becomes raw content url",
raw: "https://github.com/acmagn/DMS-UPS-Monitor/blob/main/assets/screenshot.png",
want: "https://raw.githubusercontent.com/acmagn/DMS-UPS-Monitor/main/assets/screenshot.png",
},
{
name: "github raw url becomes raw content url",
raw: "https://github.com/antonjah/nix-monitor/raw/master/assets/scrot.png",
want: "https://raw.githubusercontent.com/antonjah/nix-monitor/master/assets/scrot.png",
},
{
name: "non github url is unchanged",
raw: "https://example.com/screenshot.png",
want: "https://example.com/screenshot.png",
},
{
name: "empty url is empty",
raw: " ",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, normalizeScreenshotURL(tt.raw))
})
}
}
func TestPluginInfoFromPluginIncludesScreenshot(t *testing.T) {
info := pluginInfoFromPlugin(coreplugins.Plugin{
ID: "dankVault",
Name: "Vault",
Repo: "https://github.com/AvengeMedia/dms-plugins",
Screenshot: "https://github.com/AvengeMedia/dms-plugins/blob/master/DankNotepadModule/screenshot.png",
})
assert.Equal(t, "https://raw.githubusercontent.com/AvengeMedia/dms-plugins/master/DankNotepadModule/screenshot.png", info.Screenshot)
assert.True(t, info.FirstParty)
} }
func TestSuccessResult(t *testing.T) { func TestSuccessResult(t *testing.T) {
+6 -20
View File
@@ -3,7 +3,6 @@ package plugins
import ( import (
"fmt" "fmt"
"net" "net"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -34,25 +33,12 @@ func HandleList(conn net.Conn, req models.Request) {
for i, p := range pluginList { for i, p := range pluginList {
installed, _ := manager.IsInstalled(p) installed, _ := manager.IsInstalled(p)
fb := feedback[p.ID] fb := feedback[p.ID]
result[i] = PluginInfo{ info := pluginInfoFromPlugin(p)
ID: p.ID, info.Installed = installed
Name: p.Name, info.Upvotes = fb.Upvotes
Category: p.Category, info.Status = fb.Status
Author: p.Author, info.IssueURL = fb.IssueURL
Description: p.Description, result[i] = info
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
Featured: p.Featured,
RequiresDMS: p.RequiresDMS,
Upvotes: fb.Upvotes,
Status: fb.Status,
IssueURL: fb.IssueURL,
}
} }
models.Respond(conn, req.ID, result) models.Respond(conn, req.ID, result)
+3 -16
View File
@@ -3,7 +3,6 @@ package plugins
import ( import (
"fmt" "fmt"
"net" "net"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -47,21 +46,9 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
hasUpdate = hasUpdates hasUpdate = hasUpdates
} }
result = append(result, PluginInfo{ info := pluginInfoFromPlugin(plugin)
ID: plugin.ID, info.HasUpdate = hasUpdate
Name: plugin.Name, result = append(result, info)
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"),
HasUpdate: hasUpdate,
RequiresDMS: plugin.RequiresDMS,
})
} else { } else {
result = append(result, PluginInfo{ result = append(result, PluginInfo{
ID: id, ID: id,
+3 -16
View File
@@ -3,7 +3,6 @@ package plugins
import ( import (
"fmt" "fmt"
"net" "net"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -53,21 +52,9 @@ func HandleSearch(conn net.Conn, req models.Request) {
result := make([]PluginInfo, len(searchResults)) result := make([]PluginInfo, len(searchResults))
for i, p := range searchResults { for i, p := range searchResults {
installed, _ := manager.IsInstalled(p) installed, _ := manager.IsInstalled(p)
result[i] = PluginInfo{ info := pluginInfoFromPlugin(p)
ID: p.ID, info.Installed = installed
Name: p.Name, result[i] = info
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
RequiresDMS: p.RequiresDMS,
}
} }
models.Respond(conn, req.ID, result) models.Respond(conn, req.ID, result)
+1
View File
@@ -8,6 +8,7 @@ type PluginInfo struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Repo string `json:"repo,omitempty"` Repo string `json:"repo,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
Screenshot string `json:"screenshot,omitempty"`
Capabilities []string `json:"capabilities,omitempty"` Capabilities []string `json:"capabilities,omitempty"`
Compositors []string `json:"compositors,omitempty"` Compositors []string `json:"compositors,omitempty"`
Dependencies []string `json:"dependencies,omitempty"` Dependencies []string `json:"dependencies,omitempty"`
+53 -2
View File
@@ -1,14 +1,65 @@
package plugins package plugins
import ( import (
"net/url"
"sort" "sort"
"strings" "strings"
coreplugins "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
) )
func pluginInfoFromPlugin(plugin coreplugins.Plugin) PluginInfo {
return PluginInfo{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Screenshot: normalizeScreenshotURL(plugin.Screenshot),
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
FirstParty: isFirstPartyRepo(plugin.Repo),
Featured: plugin.Featured,
RequiresDMS: plugin.RequiresDMS,
}
}
func isFirstPartyRepo(repo string) bool {
return strings.HasPrefix(repo, "https://github.com/AvengeMedia")
}
func normalizeScreenshotURL(raw string) string {
screenshotURL := strings.TrimSpace(raw)
if screenshotURL == "" {
return ""
}
parsed, err := url.Parse(screenshotURL)
if err != nil {
return screenshotURL
}
host := strings.ToLower(parsed.Host)
if host != "github.com" && host != "www.github.com" {
return screenshotURL
}
parts := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/")
if len(parts) < 5 || (parts[2] != "blob" && parts[2] != "raw") {
return screenshotURL
}
rawParts := append([]string{parts[0], parts[1], parts[3]}, parts[4:]...)
return "https://raw.githubusercontent.com/" + strings.Join(rawParts, "/")
}
func SortPluginInfoByFirstParty(pluginInfos []PluginInfo) { func SortPluginInfoByFirstParty(pluginInfos []PluginInfo) {
sort.SliceStable(pluginInfos, func(i, j int) bool { sort.SliceStable(pluginInfos, func(i, j int) bool {
isFirstPartyI := strings.HasPrefix(pluginInfos[i].Repo, "https://github.com/AvengeMedia") isFirstPartyI := isFirstPartyRepo(pluginInfos[i].Repo)
isFirstPartyJ := strings.HasPrefix(pluginInfos[j].Repo, "https://github.com/AvengeMedia") isFirstPartyJ := isFirstPartyRepo(pluginInfos[j].Repo)
if isFirstPartyI != isFirstPartyJ { if isFirstPartyI != isFirstPartyJ {
return isFirstPartyI return isFirstPartyI
} }
+124 -2
View File
@@ -18,12 +18,14 @@ FloatingWindow {
property bool keyboardNavigationActive: false property bool keyboardNavigationActive: false
property bool isLoading: false property bool isLoading: false
property var parentModal: null property var parentModal: null
parentWindow: parentModal parentWindow: null
property bool pendingInstallHandled: false property bool pendingInstallHandled: false
property string typeFilter: "" property string typeFilter: ""
property string categoryFilter: "all" property string categoryFilter: "all"
property var categoryFilterOptions: [] property var categoryFilterOptions: []
property var availableLetters: [] property var availableLetters: []
property string expandedPluginId: ""
property string enlargedPreviewPluginId: ""
readonly property bool activeCategorySort: normalizedSortMode(SessionData.pluginBrowserSortMode) === "category" readonly property bool activeCategorySort: normalizedSortMode(SessionData.pluginBrowserSortMode) === "category"
readonly property bool showCategoryFilters: activeCategorySort && categoryFilterOptions.length > 1 readonly property bool showCategoryFilters: activeCategorySort && categoryFilterOptions.length > 1
@@ -255,6 +257,9 @@ FloatingWindow {
} }
function updateFilteredPlugins() { function updateFilteredPlugins() {
expandedPluginId = "";
enlargedPreviewPluginId = "";
var baseFiltered = []; var baseFiltered = [];
var query = searchQuery ? searchQuery.toLowerCase() : ""; var query = searchQuery ? searchQuery.toLowerCase() : "";
@@ -340,6 +345,35 @@ FloatingWindow {
refreshListLayout(); refreshListLayout();
} }
function pluginKey(plugin, fallbackIndex) {
if (!plugin)
return "plugin-" + fallbackIndex;
return plugin.id || plugin.name || ("plugin-" + fallbackIndex);
}
function toggleExpandedPlugin(pluginId) {
if (expandedPluginId === pluginId) {
expandedPluginId = "";
enlargedPreviewPluginId = "";
} else {
expandedPluginId = pluginId;
enlargedPreviewPluginId = "";
}
keyboardNavigationActive = false;
Qt.callLater(() => {
if (pluginBrowserList)
pluginBrowserList.forceLayout();
});
}
function toggleEnlargedPreview(pluginId) {
enlargedPreviewPluginId = enlargedPreviewPluginId === pluginId ? "" : pluginId;
Qt.callLater(() => {
if (pluginBrowserList)
pluginBrowserList.forceLayout();
});
}
function selectNext() { function selectNext() {
if (filteredPlugins.length === 0) if (filteredPlugins.length === 0)
return; return;
@@ -445,6 +479,8 @@ FloatingWindow {
selectedIndex = -1; selectedIndex = -1;
keyboardNavigationActive = false; keyboardNavigationActive = false;
isLoading = false; isLoading = false;
expandedPluginId = "";
enlargedPreviewPluginId = "";
} }
Connections { Connections {
@@ -828,6 +864,8 @@ FloatingWindow {
} }
delegate: Rectangle { delegate: Rectangle {
id: pluginDelegate
width: pluginBrowserList.width width: pluginBrowserList.width
height: pluginDelegateColumn.implicitHeight + Theme.spacingM * 2 height: pluginDelegateColumn.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
@@ -836,12 +874,26 @@ FloatingWindow {
property bool isFirstParty: modelData.firstParty || false property bool isFirstParty: modelData.firstParty || false
property bool isFeatured: modelData.featured || false property bool isFeatured: modelData.featured || false
property bool isCompatible: PluginService.checkPluginCompatibility(modelData.requires_dms) property bool isCompatible: PluginService.checkPluginCompatibility(modelData.requires_dms)
color: isSelected ? Theme.primarySelected : Theme.withAlpha(Theme.surfaceVariant, 0.3) property string pluginId: root.pluginKey(modelData, index)
property bool isExpanded: root.expandedPluginId === pluginId
property bool isPreviewEnlarged: root.enlargedPreviewPluginId === pluginId
property string screenshotUrl: modelData.screenshot || ""
color: isSelected ? Theme.primarySelected : rowMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceVariant, 0.45) : Theme.withAlpha(Theme.surfaceVariant, 0.3)
border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.outline, 0.2) border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.outline, 0.2)
border.width: isSelected ? 2 : 1 border.width: isSelected ? 2 : 1
MouseArea {
id: rowMouseArea
z: 0
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleExpandedPlugin(pluginDelegate.pluginId)
}
Column { Column {
id: pluginDelegateColumn id: pluginDelegateColumn
z: 1
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
spacing: Theme.spacingXS spacing: Theme.spacingXS
@@ -1171,6 +1223,76 @@ FloatingWindow {
} }
} }
} }
Rectangle {
id: screenshotPreview
width: parent.width
height: pluginDelegate.isExpanded ? (pluginDelegate.isPreviewEnlarged ? Math.min(620, Math.max(320, width * 0.78)) : Math.min(260, Math.max(150, width * 0.42))) : 0
visible: height > 0
clip: true
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.color: Theme.withAlpha(Theme.outline, 0.2)
border.width: 1
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Loader {
id: screenshotImageLoader
anchors.fill: parent
anchors.margins: 1
active: pluginDelegate.isExpanded && pluginDelegate.screenshotUrl.length > 0
sourceComponent: CachingImage {
imagePath: pluginDelegate.screenshotUrl
maxCacheSize: pluginDelegate.isPreviewEnlarged ? 1600 : 960
fillMode: Image.PreserveAspectFit
visible: status !== Image.Error
}
}
MouseArea {
anchors.fill: parent
enabled: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Ready
hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: mouse => {
mouse.accepted = true;
root.toggleEnlargedPreview(pluginDelegate.pluginId);
}
}
DankSpinner {
anchors.centerIn: parent
running: screenshotImageLoader.active && screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Loading
visible: running
}
Column {
anchors.centerIn: parent
spacing: Theme.spacingXS
visible: pluginDelegate.isExpanded && (pluginDelegate.screenshotUrl.length === 0 || (screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error))
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error ? "broken_image" : "image_not_supported"
size: Theme.iconSize
color: Theme.outline
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error ? I18n.tr("Screenshot unavailable", "plugin browser screenshot error") : I18n.tr("No screenshot provided", "plugin browser no screenshot")
font.pixelSize: Theme.fontSizeSmall
color: Theme.outline
}
}
}
} }
} }
} }