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:
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -165,6 +166,7 @@ func TestPluginInfoJSON(t *testing.T) {
|
||||
info := PluginInfo{
|
||||
Name: "test",
|
||||
Description: "test description",
|
||||
Screenshot: "https://raw.githubusercontent.com/test/repo/main/screenshot.png",
|
||||
Installed: true,
|
||||
FirstParty: true,
|
||||
}
|
||||
@@ -177,6 +179,59 @@ func TestPluginInfoJSON(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, info.Name, unmarshaled.Name)
|
||||
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) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package plugins
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||
"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 {
|
||||
installed, _ := manager.IsInstalled(p)
|
||||
fb := feedback[p.ID]
|
||||
result[i] = PluginInfo{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
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"),
|
||||
Featured: p.Featured,
|
||||
RequiresDMS: p.RequiresDMS,
|
||||
Upvotes: fb.Upvotes,
|
||||
Status: fb.Status,
|
||||
IssueURL: fb.IssueURL,
|
||||
}
|
||||
info := pluginInfoFromPlugin(p)
|
||||
info.Installed = installed
|
||||
info.Upvotes = fb.Upvotes
|
||||
info.Status = fb.Status
|
||||
info.IssueURL = fb.IssueURL
|
||||
result[i] = info
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
|
||||
@@ -3,7 +3,6 @@ package plugins
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
@@ -47,21 +46,9 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
|
||||
hasUpdate = hasUpdates
|
||||
}
|
||||
|
||||
result = append(result, PluginInfo{
|
||||
ID: plugin.ID,
|
||||
Name: plugin.Name,
|
||||
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,
|
||||
})
|
||||
info := pluginInfoFromPlugin(plugin)
|
||||
info.HasUpdate = hasUpdate
|
||||
result = append(result, info)
|
||||
} else {
|
||||
result = append(result, PluginInfo{
|
||||
ID: id,
|
||||
|
||||
@@ -3,7 +3,6 @@ package plugins
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||
"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))
|
||||
for i, p := range searchResults {
|
||||
installed, _ := manager.IsInstalled(p)
|
||||
result[i] = PluginInfo{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
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,
|
||||
}
|
||||
info := pluginInfoFromPlugin(p)
|
||||
info.Installed = installed
|
||||
result[i] = info
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
|
||||
@@ -8,6 +8,7 @@ type PluginInfo struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Compositors []string `json:"compositors,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"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) {
|
||||
sort.SliceStable(pluginInfos, func(i, j int) bool {
|
||||
isFirstPartyI := strings.HasPrefix(pluginInfos[i].Repo, "https://github.com/AvengeMedia")
|
||||
isFirstPartyJ := strings.HasPrefix(pluginInfos[j].Repo, "https://github.com/AvengeMedia")
|
||||
isFirstPartyI := isFirstPartyRepo(pluginInfos[i].Repo)
|
||||
isFirstPartyJ := isFirstPartyRepo(pluginInfos[j].Repo)
|
||||
if isFirstPartyI != isFirstPartyJ {
|
||||
return isFirstPartyI
|
||||
}
|
||||
|
||||
@@ -18,12 +18,14 @@ FloatingWindow {
|
||||
property bool keyboardNavigationActive: false
|
||||
property bool isLoading: false
|
||||
property var parentModal: null
|
||||
parentWindow: parentModal
|
||||
parentWindow: null
|
||||
property bool pendingInstallHandled: false
|
||||
property string typeFilter: ""
|
||||
property string categoryFilter: "all"
|
||||
property var categoryFilterOptions: []
|
||||
property var availableLetters: []
|
||||
property string expandedPluginId: ""
|
||||
property string enlargedPreviewPluginId: ""
|
||||
|
||||
readonly property bool activeCategorySort: normalizedSortMode(SessionData.pluginBrowserSortMode) === "category"
|
||||
readonly property bool showCategoryFilters: activeCategorySort && categoryFilterOptions.length > 1
|
||||
@@ -255,6 +257,9 @@ FloatingWindow {
|
||||
}
|
||||
|
||||
function updateFilteredPlugins() {
|
||||
expandedPluginId = "";
|
||||
enlargedPreviewPluginId = "";
|
||||
|
||||
var baseFiltered = [];
|
||||
var query = searchQuery ? searchQuery.toLowerCase() : "";
|
||||
|
||||
@@ -340,6 +345,35 @@ FloatingWindow {
|
||||
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() {
|
||||
if (filteredPlugins.length === 0)
|
||||
return;
|
||||
@@ -445,6 +479,8 @@ FloatingWindow {
|
||||
selectedIndex = -1;
|
||||
keyboardNavigationActive = false;
|
||||
isLoading = false;
|
||||
expandedPluginId = "";
|
||||
enlargedPreviewPluginId = "";
|
||||
}
|
||||
|
||||
Connections {
|
||||
@@ -828,6 +864,8 @@ FloatingWindow {
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: pluginDelegate
|
||||
|
||||
width: pluginBrowserList.width
|
||||
height: pluginDelegateColumn.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
@@ -836,12 +874,26 @@ FloatingWindow {
|
||||
property bool isFirstParty: modelData.firstParty || false
|
||||
property bool isFeatured: modelData.featured || false
|
||||
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.width: isSelected ? 2 : 1
|
||||
|
||||
MouseArea {
|
||||
id: rowMouseArea
|
||||
z: 0
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleExpandedPlugin(pluginDelegate.pluginId)
|
||||
}
|
||||
|
||||
Column {
|
||||
id: pluginDelegateColumn
|
||||
z: 1
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user