Repository: oliverschwendener/ueli (ts)

Issue:
[Feature Request] Color Converter - show color swatch
Would be nice if `rgb(78, 154, 6)` also showed a sample color swatch of that color.

Pull request description:
feat(ColorConverter): show color preview in search result list
This PR adds a color preview for color conversion results:

![image](https://github.com/user-attachments/assets/351ea838-ad12-414d-99c1-4e552a871112)

Fixes #1302

Patch:
diff --git a/src/main/Extensions/ColorConverter/ColorConverter.ts b/src/main/Extensions/ColorConverter/ColorConverter.ts
index fd9f359b..12f91166 100644
--- a/src/main/Extensions/ColorConverter/ColorConverter.ts
+++ b/src/main/Extensions/ColorConverter/ColorConverter.ts
@@ -2,4 +2,5 @@ import type { ColorConversionResult } from "./ColorConversionResult";
 
 export interface ColorConverter {
     convertFromString(value: string): ColorConversionResult[];
+    getRgbColor(value: string): string | undefined;
 }
diff --git a/src/main/Extensions/ColorConverter/ColorConverterExtension.ts b/src/main/Extensions/ColorConverter/ColorConverterExtension.ts
index 14b83544..3e20d387 100644
--- a/src/main/Extensions/ColorConverter/ColorConverterExtension.ts
+++ b/src/main/Extensions/ColorConverter/ColorConverterExtension.ts
@@ -6,6 +6,7 @@ import type { Extension } from "@Core/Extension";
 import type { SettingsManager } from "@Core/SettingsManager";
 import type { Translator } from "@Core/Translator";
 import type { ColorConverter } from "./ColorConverter";
+import type { ColorPreviewGenerator } from "./ColorPreviewGenerator";
 import type { Settings } from "./Settings";
 
 export class ColorConverterExtension implements Extension {
@@ -32,6 +33,7 @@ export class ColorConverterExtension implements Extension {
         private readonly settingsManager: SettingsManager,
         private readonly translator: Translator,
         private readonly colorConverter: ColorConverter,
+        private readonly colorPreviewGenerator: ColorPreviewGenerator,
     ) {}
 
     public async getSearchResultItems(): Promise<SearchResultItem[]> {
@@ -46,12 +48,6 @@ export class ColorConverterExtension implements Extension {
         return this.defaultSettings[key];
     }
 
-    public getImage(): Image {
-        return {
-            url: `file://${this.assetPathResolver.getExtensionAssetPath(this.id, "color-converter.png")}`,
-        };
-    }
-
     public getI18nResources(): Resources<Translations> {
         return {
             "en-US": {
@@ -78,24 +74,40 @@ export class ColorConverterExtension implements Extension {
             after: this.colorConverter
                 .convertFromString(searchTerm)
                 .filter(({ format }) => this.getEnabledColorFormats().includes(format))
-                .map(({ format, value }) => ({
-                    defaultAction: createCopyToClipboardAction({
-                        textToCopy: value,
-                        description: "Copy color to clipboard",
-                        descriptionTranslation: {
-                            key: "copyColorToClipboard",
-                            namespace: "extension[ColorConverter]",
-                        },
-                    }),
-                    description: t("color", { format }),
-                    id: `color-${value}-${format}`,
-                    image: this.getImage(),
-                    name: value,
-                })),
+                .map(({ format, value }) => {
+                    const hexColor = this.colorConverter.getRgbColor(value);
+
+                    return {
+                        defaultAction: createCopyToClipboardAction({
+                            textToCopy: value,
+                            description: "Copy color to clipboard",
+                            descriptionTranslation: {
+                                key: "copyColorToClipboard",
+                                namespace: "extension[ColorConverter]",
+                            },
+                        }),
+                        description: t("color", { format }),
+                        id: `color-${value}-${format}`,
+                        image: hexColor ? this.getColorImage(hexColor) : this.getImage(),
+                        name: value,
+                    };
+                }),
             before: [],
         };
     }
 
+    public getImage(): Image {
+        return {
+            url: `file://${this.assetPathResolver.getExtensionAssetPath(this.id, "color-converter.png")}`,
+        };
+    }
+
+    private getColorImage(hexColor: string): Image {
+        return {
+            url: this.colorPreviewGenerator.generateImageUrl(hexColor),
+        };
+    }
+
     private getEnabledColorFormats(): string[] {
         return this.settingsManager.getValue(`extension[${this.id}].formats`, this.defaultSettings.formats);
     }
diff --git a/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.ts b/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.ts
index d855fd08..f9798d0f 100644
--- a/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.ts
+++ b/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.ts
@@ -3,6 +3,7 @@ import type { ExtensionBootstrapResult } from "../ExtensionBootstrapResult";
 import type { ExtensionModule } from "../ExtensionModule";
 import { ColorConverterExtension } from "./ColorConverterExtension";
 import { QixColorConverter } from "./QixColorConverter";
+import { SvgColorPreviewGenerator } from "./SvgColorPreviewGenerator";
 
 export class ColorConverterExtensionModule implements ExtensionModule {
     public bootstrap(moduleRegistry: UeliModuleRegistry): ExtensionBootstrapResult {
@@ -12,6 +13,7 @@ export class ColorConverterExtensionModule implements ExtensionModule {
                 moduleRegistry.get("SettingsManager"),
                 moduleRegistry.get("Translator"),
                 new QixColorConverter(),
+                new SvgColorPreviewGenerator(),
             ),
         };
     }
diff --git a/src/main/Extensions/ColorConverter/ColorPreviewGenerator.ts b/src/main/Extensions/ColorConverter/ColorPreviewGenerator.ts
new file mode 100644
index 00000000..18110beb
--- /dev/null
+++ b/src/main/Extensions/ColorConverter/ColorPreviewGenerator.ts
@@ -0,0 +1,3 @@
+export interface ColorPreviewGenerator {
+    generateImageUrl(hexColor: string): string;
+}
diff --git a/src/main/Extensions/ColorConverter/QixColorConverter.ts b/src/main/Extensions/ColorConverter/QixColorConverter.ts
index d9fb7893..87ea0bd0 100644
--- a/src/main/Extensions/ColorConverter/QixColorConverter.ts
+++ b/src/main/Extensions/ColorConverter/QixColorConverter.ts
@@ -15,6 +15,10 @@ export class QixColorConverter implements ColorConverter {
             : [];
     }
 
+    public getRgbColor(value: string): string | undefined {
+        return this.extractColorFromString(value)?.rgb().string();
+    }
+
     private extractColorFromString(value: string): Color | undefined {
         try {
             return Color(value);
diff --git a/src/main/Extensions/ColorConverter/SvgColorPreviewGenerator.ts b/src/main/Extensions/ColorConverter/SvgColorPreviewGenerator.ts
new file mode 100644
index 00000000..989db527
--- /dev/null
+++ b/src/main/Extensions/ColorConverter/SvgColorPreviewGenerator.ts
@@ -0,0 +1,10 @@
+import type { ColorPreviewGenerator } from "./ColorPreviewGenerator";
+
+export class SvgColorPreviewGenerator implements ColorPreviewGenerator {
+    public generateImageUrl(hexColor: string): string {
+        const size = 20;
+        const borderRadius = 4;
+
+        return `data:image/svg+xml;utf8,<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="${size}" height="${size}" rx="${borderRadius}" ry="${borderRadius}" style="fill:${encodeURIComponent(hexColor)}" /></svg>`;
+    }
+}


Test patch:
diff --git a/src/main/Extensions/ColorConverter/ColorConverterExtension.test.ts b/src/main/Extensions/ColorConverter/ColorConverterExtension.test.ts
index 20eb0f22..63468ae9 100644
--- a/src/main/Extensions/ColorConverter/ColorConverterExtension.test.ts
+++ b/src/main/Extensions/ColorConverter/ColorConverterExtension.test.ts
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
 import type { ColorConversionResult } from "./ColorConversionResult";
 import type { ColorConverter } from "./ColorConverter";
 import { ColorConverterExtension } from "./ColorConverterExtension";
+import type { ColorPreviewGenerator } from "./ColorPreviewGenerator";
 
 describe(ColorConverterExtension, () => {
     describe(ColorConverterExtension.prototype.getSearchResultItems, () => {
@@ -14,6 +15,7 @@ describe(ColorConverterExtension, () => {
                 <SettingsManager>{},
                 <Translator>{},
                 <ColorConverter>{},
+                <ColorPreviewGenerator>{},
             ).getSearchResultItems();
             expect(actual).toEqual([]);
         });
@@ -31,6 +33,7 @@ describe(ColorConverterExtension, () => {
                 <SettingsManager>{},
                 <Translator>{},
                 <ColorConverter>{},
+                <ColorPreviewGenerator>{},
             );
 
             expect(colorConverterExtension.getImage()).toEqual({ url: "file://color-converter.png" });
@@ -48,6 +51,7 @@ describe(ColorConverterExtension, () => {
             <SettingsManager>{},
             <Translator>{},
             <ColorConverter>{},
+            <ColorPreviewGenerator>{},
         );
 
         it("should return the default formats when passing 'formats' as key", () =>
@@ -62,6 +66,7 @@ describe(ColorConverterExtension, () => {
                     <SettingsManager>{},
                     <Translator>{},
                     <ColorConverter>{},
+                    <ColorPreviewGenerator>{},
                 ).isSupported(),
             ).toBe(true)),
     );
@@ -90,6 +95,13 @@ describe(ColorConverterExtension, () => {
                     { format: "HSL", value: "hsl(0, 0%, 100%)" },
                     { format: "RGB", value: "rgb(255, 255, 255)" },
                 ]),
+                getRgbColor: vi.fn().mockReturnValue("#FFFFFF"),
+            };
+
+            const generateImageUrlMock = vi.fn().mockReturnValue("file://preview-image.png");
+
+            const colorPreviewGenerator: ColorPreviewGenerator = {
+                generateImageUrl: generateImageUrlMock,
             };
 
             const colorConverterExtension = new ColorConverterExtension(
@@ -97,6 +109,7 @@ describe(ColorConverterExtension, () => {
                 settingsManager,
                 translator,
                 colorConverter,
+                colorPreviewGenerator,
             );
 
             const { before, after } = colorConverterExtension.getInstantSearchResultItems("#fff");
@@ -110,8 +123,8 @@ describe(ColorConverterExtension, () => {
             expect(after.map((a) => a.name)).toEqual(["#FFFFFF", "rgb(255, 255, 255)"]);
 
             expect(after.map(({ image }) => image)).toEqual([
-                { url: "file://color-converter.png" },
-                { url: "file://color-converter.png" },
+                { url: "file://preview-image.png" },
+                { url: "file://preview-image.png" },
             ]);
         });
     });
@@ -125,6 +138,7 @@ describe(ColorConverterExtension, () => {
                         <SettingsManager>{},
                         <Translator>{},
                         <ColorConverter>{},
+                        <ColorPreviewGenerator>{},
                     ).getI18nResources(),
                 ),
             ).toEqual(["en-US", "de-CH"])),
diff --git a/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.test.ts b/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.test.ts
deleted file mode 100644
index b90bbfed..00000000
--- a/src/main/Extensions/ColorConverter/ColorConverterExtensionModule.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { AssetPathResolver } from "@Core/AssetPathResolver";
-import type { UeliModuleRegistry } from "@Core/ModuleRegistry";
-import type { SettingsManager } from "@Core/SettingsManager";
-import type { Translator } from "@Core/Translator";
-import { describe, expect, it, vi } from "vitest";
-import { ColorConverterExtension } from "./ColorConverterExtension";
-import { ColorConverterExtensionModule } from "./ColorConverterExtensionModule";
-import { QixColorConverter } from "./QixColorConverter";
-
-describe(ColorConverterExtensionModule, () => {
-    describe(ColorConverterExtensionModule.prototype.bootstrap, () => {
-        it("should bootstrap the extension", () => {
-            const assetPathResolver = <AssetPathResolver>{};
-            const settingsManager = <SettingsManager>{};
-            const translator = <Translator>{};
-
-            const moduleRegistry = <UeliModuleRegistry>{
-                get: vi.fn().mockImplementation((n: string) => {
-                    if (n === "AssetPathResolver") {
-                        return assetPathResolver;
-                    } else if (n === "SettingsManager") {
-                        return settingsManager;
-                    } else if (n === "Translator") {
-                        return translator;
-                    }
-
-                    return null;
-                }),
-                register: () => null,
-            };
-
-            expect(new ColorConverterExtensionModule().bootstrap(moduleRegistry)).toEqual({
-                extension: new ColorConverterExtension(
-                    assetPathResolver,
-                    settingsManager,
-                    translator,
-                    new QixColorConverter(),
-                ),
-            });
-
-            expect(moduleRegistry.get).toHaveBeenCalledWith("AssetPathResolver");
-            expect(moduleRegistry.get).toHaveBeenCalledWith("SettingsManager");
-            expect(moduleRegistry.get).toHaveBeenCalledWith("Translator");
-        });
-    });
-});