Переглянути джерело

Merge remote-tracking branch 'upstream/release/v0.7.0' into alt-status-codes
Update to current release

Cody Meadows 8 місяців тому
батько
коміт
04811a0658

+ 1 - 1
Dockerfile

@@ -1,4 +1,4 @@
-FROM golang:1.22.5-alpine3.20 AS builder
+FROM golang:1.23.1-alpine3.20 AS builder
 
 WORKDIR /app
 COPY . /app

+ 66 - 4
docs/configuration.md

@@ -15,6 +15,7 @@
   - [Reddit](#reddit)
   - [Search](#search-widget)
   - [Group](#group)
+  - [Split Column](#split-column)
   - [Extension](#extension)
   - [Weather](#weather)
   - [Monitor](#monitor)
@@ -890,7 +891,7 @@ url: https://www.amazon.com/s?k={QUERY}
 ```
 
 ### Group
-Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
+Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget or a split column widget within a group widget.
 
 Example:
 
@@ -933,6 +934,63 @@ Example:
       <<: *shared-properties
 ```
 
+### Split Column
+Splits a full sized column in half, allowing you to place widgets side by side. This is converted to a single column on mobile devices or if not enough width is available. Widgets are defined using a `widgets` property exactly as you would on a page column.
+
+Example of a full page with an effective 4 column layout using two split column widgets inside of two full sized columns:
+
+<details>
+<summary>View config</summary>
+
+```yaml
+shared:
+  - &reddit-props
+    type: reddit
+    collapse-after: 4
+    show-thumbnails: true
+
+pages:
+  - name: Split Column Demo
+    width: wide
+    columns:
+      - size: full
+        widgets:
+          - type: split-column
+            widgets:
+              - subreddit: gaming
+                <<: *reddit-props
+              - subreddit: worldnews
+                <<: *reddit-props
+              - subreddit: lifeprotips
+                <<: *reddit-props
+                show-thumbnails: false
+              - subreddit: askreddit
+                <<: *reddit-props
+                show-thumbnails: false
+
+      - size: full
+        widgets:
+          - type: split-column
+            widgets:
+              - subreddit: todayilearned
+                <<: *reddit-props
+                collapse-after: 2
+              - subreddit: aww
+                <<: *reddit-props
+              - subreddit: science
+                <<: *reddit-props
+              - subreddit: showerthoughts
+                <<: *reddit-props
+                show-thumbnails: false
+```
+</details>
+
+<br>
+
+Preview:
+
+![](images/split-column-widget-preview.png)
+
 ### Extension
 Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
 
@@ -948,12 +1006,16 @@ Display a widget provided by an external source (3rd party). If you want to lear
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | url | string | yes | |
+| fallback-content-type | string | no | |
 | allow-potentially-dangerous-html | boolean | no | false |
 | parameters | key & value | no | |
 
 ##### `url`
 The URL of the extension.
 
+##### `fallback-content-type`
+Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`.
+
 ##### `allow-potentially-dangerous-html`
 Whether to allow the extension to display HTML.
 
@@ -1108,7 +1170,7 @@ icon: si:adguard
 
 > [!WARNING]
 >
-> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
+> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
 
 `allow-insecure`
 
@@ -1395,7 +1457,7 @@ icon: si:reddit
 
 > [!WARNING]
 >
-> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
+> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
 
 `same-tab`
 
@@ -1549,7 +1611,7 @@ Preview:
 An array of markets for which to display information about.
 
 ##### `sort-by`
-By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
+By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `change` for descending order based on the stock's percentage change (e.g. 1% would be sorted higher than -1%) or `absolute-change` for descending order based on the stock's absolute price change (e.g. -1% would be sorted higher than +0.5%).
 
 ###### Properties for each stock
 | Name | Type | Required |

BIN
docs/images/split-column-widget-preview.png


+ 4 - 4
go.mod

@@ -1,19 +1,19 @@
 module github.com/glanceapp/glance
 
-go 1.22.5
+go 1.23.1
 
 require (
 	github.com/mmcdole/gofeed v1.3.0
-	golang.org/x/text v0.16.0
+	golang.org/x/text v0.18.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
-	github.com/PuerkitoBio/goquery v1.9.2 // indirect
+	github.com/PuerkitoBio/goquery v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	golang.org/x/net v0.27.0 // indirect
+	golang.org/x/net v0.29.0 // indirect
 )

+ 6 - 0
go.sum

@@ -1,5 +1,7 @@
 github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
+github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -35,6 +37,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
 golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
+golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -56,6 +60,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
 golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
+golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

+ 2 - 0
internal/assets/static/js/main.js

@@ -1,4 +1,5 @@
 import { setupPopovers } from './popover.js';
+import { setupMasonries } from './masonry.js';
 import { throttledDebounce, isElementVisible } from './utils.js';
 
 async function fetchPageContent(pageData) {
@@ -581,6 +582,7 @@ async function setupPage() {
         setupCollapsibleLists();
         setupCollapsibleGrids();
         setupGroups();
+        setupMasonries();
         setupDynamicRelativeTime();
         setupLazyImages();
     } finally {

+ 53 - 0
internal/assets/static/js/masonry.js

@@ -0,0 +1,53 @@
+
+import { clamp } from "./utils.js";
+
+export function setupMasonries() {
+    const masonryContainers = document.getElementsByClassName("masonry");
+
+    for (let i = 0; i < masonryContainers.length; i++) {
+        const container = masonryContainers[i];
+
+        const options = {
+            minColumnWidth: container.dataset.minColumnWidth || 330,
+            maxColumns: container.dataset.maxColumns || 6,
+        };
+
+        const items = Array.from(container.children);
+        let previousColumnsCount = 0;
+
+        const render = function() {
+            const columnsCount = clamp(
+                Math.floor(container.offsetWidth / options.minColumnWidth),
+                1,
+                Math.min(options.maxColumns, items.length)
+            );
+
+            if (columnsCount === previousColumnsCount) {
+                return;
+            } else {
+                container.textContent = "";
+                previousColumnsCount = columnsCount;
+            }
+
+            const columnsFragment = document.createDocumentFragment();
+
+            for (let i = 0; i < columnsCount; i++) {
+                const column = document.createElement("div");
+                column.className = "masonry-column";
+                columnsFragment.append(column);
+            }
+
+            // poor man's masonry
+            // TODO: add an option that allows placing items in the
+            // shortest column instead of iterating the columns in order
+            for (let i = 0; i < items.length; i++) {
+                columnsFragment.children[i % columnsCount].appendChild(items[i]);
+            }
+
+            container.append(columnsFragment);
+        };
+
+        const observer = new ResizeObserver(() => requestAnimationFrame(render));
+        observer.observe(container);
+    }
+}

+ 2 - 0
internal/assets/static/js/popover.js

@@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
 }
 
 function showPopover() {
+    if (pendingTarget === null) return;
+
     activeTarget = pendingTarget;
     pendingTarget = null;
 

+ 4 - 0
internal/assets/static/js/utils.js

@@ -23,3 +23,7 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
 export function isElementVisible(element) {
     return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
 }
+
+export function clamp(value, min, max) {
+    return Math.min(Math.max(value, min), max);
+}

+ 18 - 0
internal/assets/static/main.css

@@ -440,6 +440,17 @@ kbd:active {
     box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
 }
 
+.masonry {
+    display: flex;
+    gap: var(--widget-gap);
+}
+
+.masonry-column {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+}
+
 .popover-container, [data-popover-html] {
     display: none;
 }
@@ -1493,6 +1504,13 @@ details[open] .summary::after {
     border: 2px solid var(--color-widget-background);
 }
 
+.twitch-stream-preview {
+    width: 100%;
+    aspect-ratio: 16 / 9;
+    border-radius: var(--border-radius);
+    object-fit: cover;
+}
+
 .reddit-card-thumbnail {
     width: 100%;
     height: 100%;

+ 1 - 0
internal/assets/templates.go

@@ -39,6 +39,7 @@ var (
 	ExtensionTemplate             = compileTemplate("extension.html", "widget-base.html")
 	GroupTemplate                 = compileTemplate("group.html", "widget-base.html")
 	DNSStatsTemplate              = compileTemplate("dns-stats.html", "widget-base.html")
+	SplitColumnTemplate           = compileTemplate("split-column.html", "widget-base.html")
 )
 
 var globalTemplateFunctions = template.FuncMap{

+ 1 - 1
internal/assets/templates/page.html

@@ -44,7 +44,7 @@
         <div class="mobile-navigation-icons">
             <a class="mobile-navigation-label" href="#top">↑</a>
             {{ range $i, $column := .Page.Columns }}
-            <label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
+            <label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
             {{ end }}
             <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
         </div>

+ 1 - 1
internal/assets/templates/releases.html

@@ -1,7 +1,7 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
+<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
     {{ range .Releases }}
     <li>
         <div class="flex items-center gap-10">

+ 11 - 0
internal/assets/templates/split-column.html

@@ -0,0 +1,11 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
+
+{{ define "widget-content" }}
+<div class="masonry" data-max-columns="2">
+{{ range .Widgets }}
+    {{ .Render }}
+{{ end }}
+</div>
+{{ end }}

+ 7 - 1
internal/assets/templates/twitch-channels.html

@@ -5,7 +5,13 @@
     {{ range .Channels }}
     <li>
         <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
-            <div class="twitch-channel-avatar-container">
+            <div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem"{{ end }}>
+                {{ if .IsLive }}
+                <div data-popover-html>
+                    <img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
+                    <p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
+                </div>
+                {{ end }}
                 {{ if .Exists }}
                 <a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
                     <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">

+ 9 - 4
internal/feed/extension.go

@@ -27,9 +27,10 @@ const (
 )
 
 type ExtensionRequestOptions struct {
-	URL        string            `yaml:"url"`
-	Parameters map[string]string `yaml:"parameters"`
-	AllowHtml  bool              `yaml:"allow-potentially-dangerous-html"`
+	URL                 string            `yaml:"url"`
+	FallbackContentType string            `yaml:"fallback-content-type"`
+	Parameters          map[string]string `yaml:"parameters"`
+	AllowHtml           bool              `yaml:"allow-potentially-dangerous-html"`
 }
 
 type Extension struct {
@@ -88,7 +89,11 @@ func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
 	contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
 
 	if !ok {
-		contentType = ExtensionContentUnknown
+		contentType, ok = ExtensionStringToType[options.FallbackContentType]
+
+		if !ok {
+			contentType = ExtensionContentUnknown
+		}
 	}
 
 	extension.Content = convertExtensionContent(options, body, contentType)

+ 8 - 1
internal/feed/openmeteo.go

@@ -189,12 +189,19 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
 		minT := slices.Min(temperatures)
 		maxT := slices.Max(temperatures)
 
+		temperaturesRange := float64(maxT - minT)
+
 		for i := 0; i < 12; i++ {
 			bars = append(bars, weatherColumn{
 				Temperature:      temperatures[i],
-				Scale:            float64(temperatures[i]-minT) / float64(maxT-minT),
 				HasPrecipitation: precipitations[i],
 			})
+
+			if temperaturesRange > 0 {
+				bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
+			} else {
+				bars[i].Scale = 1
+			}
 		}
 	}
 

+ 6 - 0
internal/feed/primitives.go

@@ -133,6 +133,12 @@ func (t Markets) SortByAbsChange() {
 	})
 }
 
+func (t Markets) SortByChange() {
+	sort.Slice(t, func(i, j int) bool {
+		return t[i].PercentChange > t[j].PercentChange
+	})
+}
+
 var weatherCodeTable = map[int]string{
 	0:  "Clear Sky",
 	1:  "Mainly Clear",

+ 12 - 1
internal/feed/twitch.go

@@ -28,6 +28,7 @@ type TwitchChannel struct {
 	Login        string
 	Exists       bool
 	Name         string
+	StreamTitle  string
 	AvatarUrl    string
 	IsLive       bool
 	LiveSince    time.Time
@@ -77,6 +78,9 @@ type twitchStreamMetadataOperationResponse struct {
 				Name string `json:"name"`
 			} `json:"game"`
 		} `json:"stream"`
+		LastBroadcast *struct {
+			Title string `json:"title"`
+		}
 	} `json:"user"`
 }
 
@@ -142,7 +146,10 @@ func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, err
 	return categories, nil
 }
 
-const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
+const twitchChannelStatusOperationRequestBody = `[
+{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
+{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
+]`
 
 // TODO: rework
 // The operations for multiple channels can all be sent in a single request
@@ -205,6 +212,10 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
 		result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
 
 		if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
+			if streamMetadata.UserOrNull.LastBroadcast != nil {
+				result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
+			}
+
 			if streamMetadata.UserOrNull.Stream.Game != nil {
 				result.Category = streamMetadata.UserOrNull.Stream.Game.Name
 				result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug

+ 16 - 6
internal/glance/glance.go

@@ -75,6 +75,7 @@ type Page struct {
 	HideDesktopNavigation bool     `yaml:"hide-desktop-navigation"`
 	CenterVertically      bool     `yaml:"center-vertically"`
 	Columns               []Column `yaml:"columns"`
+	PrimaryColumnIndex    int8     `yaml:"-"`
 	mu                    sync.Mutex
 }
 
@@ -140,15 +141,24 @@ func NewApplication(config *Config) (*Application, error) {
 	}
 
 	for p := range config.Pages {
-		if config.Pages[p].Slug == "" {
-			config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
+		page := &config.Pages[p]
+		page.PrimaryColumnIndex = -1
+
+		if page.Slug == "" {
+			page.Slug = titleToSlug(page.Title)
 		}
 
-		app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
+		app.slugToPage[page.Slug] = page
+
+		for c := range page.Columns {
+			column := &page.Columns[c]
+
+			if page.PrimaryColumnIndex == -1 && column.Size == "full" {
+				page.PrimaryColumnIndex = int8(c)
+			}
 
-		for c := range config.Pages[p].Columns {
-			for w := range config.Pages[p].Columns[c].Widgets {
-				widget := config.Pages[p].Columns[c].Widgets[w]
+			for w := range column.Widgets {
+				widget := column.Widgets[w]
 				app.widgetByID[widget.GetID()] = widget
 
 				widget.SetProviders(providers)

+ 48 - 0
internal/widget/container.go

@@ -0,0 +1,48 @@
+package widget
+
+import (
+	"context"
+	"sync"
+	"time"
+)
+
+type containerWidgetBase struct {
+	Widgets Widgets `yaml:"widgets"`
+}
+
+func (widget *containerWidgetBase) Update(ctx context.Context) {
+	var wg sync.WaitGroup
+	now := time.Now()
+
+	for w := range widget.Widgets {
+		widget := widget.Widgets[w]
+
+		if !widget.RequiresUpdate(&now) {
+			continue
+		}
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			widget.Update(ctx)
+		}()
+	}
+
+	wg.Wait()
+}
+
+func (widget *containerWidgetBase) SetProviders(providers *Providers) {
+	for i := range widget.Widgets {
+		widget.Widgets[i].SetProviders(providers)
+	}
+}
+
+func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool {
+	for i := range widget.Widgets {
+		if widget.Widgets[i].RequiresUpdate(now) {
+			return true
+		}
+	}
+
+	return false
+}

+ 11 - 9
internal/widget/extension.go

@@ -12,12 +12,13 @@ import (
 )
 
 type Extension struct {
-	widgetBase `yaml:",inline"`
-	URL        string            `yaml:"url"`
-	Parameters map[string]string `yaml:"parameters"`
-	AllowHtml  bool              `yaml:"allow-potentially-dangerous-html"`
-	Extension  feed.Extension    `yaml:"-"`
-	cachedHTML template.HTML     `yaml:"-"`
+	widgetBase          `yaml:",inline"`
+	URL                 string            `yaml:"url"`
+	FallbackContentType string            `yaml:"fallback-content-type"`
+	Parameters          map[string]string `yaml:"parameters"`
+	AllowHtml           bool              `yaml:"allow-potentially-dangerous-html"`
+	Extension           feed.Extension    `yaml:"-"`
+	cachedHTML          template.HTML     `yaml:"-"`
 }
 
 func (widget *Extension) Initialize() error {
@@ -38,9 +39,10 @@ func (widget *Extension) Initialize() error {
 
 func (widget *Extension) Update(ctx context.Context) {
 	extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
-		URL:        widget.URL,
-		Parameters: widget.Parameters,
-		AllowHtml:  widget.AllowHtml,
+		URL:                 widget.URL,
+		FallbackContentType: widget.FallbackContentType,
+		Parameters:          widget.Parameters,
+		AllowHtml:           widget.AllowHtml,
 	})
 
 	widget.canContinueUpdateAfterHandlingErr(err)

+ 32 - 11
internal/widget/fields.go

@@ -13,7 +13,7 @@ import (
 )
 
 var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
-var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`)
+var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
 
 const (
 	HSLHueMax        = 360
@@ -133,21 +133,42 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
 		return err
 	}
 
-	matches := EnvFieldPattern.FindStringSubmatch(value)
+	replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
+		if err != nil {
+			return ""
+		}
 
-	if len(matches) != 2 {
-		*f = OptionalEnvString(value)
+		groups := EnvFieldPattern.FindStringSubmatch(whole)
 
-		return nil
-	}
+		if len(groups) != 3 {
+			return whole
+		}
+
+		prefix, key := groups[1], groups[2]
+
+		if prefix == `\` {
+			if len(whole) >= 2 {
+				return whole[1:]
+			} else {
+				return ""
+			}
+		}
+
+		value, found := os.LookupEnv(key)
 
-	value, found := os.LookupEnv(matches[1])
+		if !found {
+			err = fmt.Errorf("environment variable %s not found", key)
+			return ""
+		}
 
-	if !found {
-		return fmt.Errorf("environment variable %s not found", matches[1])
+		return prefix + value
+	})
+
+	if err != nil {
+		return err
 	}
 
-	*f = OptionalEnvString(value)
+	*f = OptionalEnvString(replaced)
 
 	return nil
 }
@@ -162,7 +183,7 @@ func toSimpleIconIfPrefixed(icon string) (string, bool) {
 	}
 
 	icon = strings.TrimPrefix(icon, "si:")
-	icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
+	icon = "https://cdn.jsdelivr.net/npm/simple-icons@latest/" + icon + ".svg"
 
 	return icon, true
 }

+ 8 - 32
internal/widget/group.go

@@ -4,15 +4,14 @@ import (
 	"context"
 	"errors"
 	"html/template"
-	"sync"
 	"time"
 
 	"github.com/glanceapp/glance/internal/assets"
 )
 
 type Group struct {
-	widgetBase `yaml:",inline"`
-	Widgets    Widgets `yaml:"widgets"`
+	widgetBase          `yaml:",inline"`
+	containerWidgetBase `yaml:",inline"`
 }
 
 func (widget *Group) Initialize() error {
@@ -23,7 +22,9 @@ func (widget *Group) Initialize() error {
 		widget.Widgets[i].SetHideHeader(true)
 
 		if widget.Widgets[i].GetType() == "group" {
-			return errors.New("nested groups are not allowed")
+			return errors.New("nested groups are not supported")
+		} else if widget.Widgets[i].GetType() == "split-column" {
+			return errors.New("split columns inside of groups are not supported")
 		}
 
 		if err := widget.Widgets[i].Initialize(); err != nil {
@@ -35,40 +36,15 @@ func (widget *Group) Initialize() error {
 }
 
 func (widget *Group) Update(ctx context.Context) {
-	var wg sync.WaitGroup
-	now := time.Now()
-
-	for w := range widget.Widgets {
-		widget := widget.Widgets[w]
-
-		if !widget.RequiresUpdate(&now) {
-			continue
-		}
-
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			widget.Update(ctx)
-		}()
-	}
-
-	wg.Wait()
+	widget.containerWidgetBase.Update(ctx)
 }
 
 func (widget *Group) SetProviders(providers *Providers) {
-	for i := range widget.Widgets {
-		widget.Widgets[i].SetProviders(providers)
-	}
+	widget.containerWidgetBase.SetProviders(providers)
 }
 
 func (widget *Group) RequiresUpdate(now *time.Time) bool {
-	for i := range widget.Widgets {
-		if widget.Widgets[i].RequiresUpdate(now) {
-			return true
-		}
-	}
-
-	return false
+	return widget.containerWidgetBase.RequiresUpdate(now)
 }
 
 func (widget *Group) Render() template.HTML {

+ 4 - 0
internal/widget/markets.go

@@ -38,6 +38,10 @@ func (widget *Markets) Update(ctx context.Context) {
 		markets.SortByAbsChange()
 	}
 
+	if widget.Sort == "change" {
+		markets.SortByChange()
+	}
+
 	widget.Markets = markets
 }
 

+ 42 - 0
internal/widget/split-column.go

@@ -0,0 +1,42 @@
+package widget
+
+import (
+	"context"
+	"html/template"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+)
+
+type SplitColumn struct {
+	widgetBase          `yaml:",inline"`
+	containerWidgetBase `yaml:",inline"`
+}
+
+func (widget *SplitColumn) Initialize() error {
+	widget.withError(nil).withTitle("Split Column").SetHideHeader(true)
+
+	for i := range widget.Widgets {
+		if err := widget.Widgets[i].Initialize(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (widget *SplitColumn) Update(ctx context.Context) {
+	widget.containerWidgetBase.Update(ctx)
+}
+
+func (widget *SplitColumn) SetProviders(providers *Providers) {
+	widget.containerWidgetBase.SetProviders(providers)
+}
+
+func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool {
+	return widget.containerWidgetBase.RequiresUpdate(now)
+}
+
+func (widget *SplitColumn) Render() template.HTML {
+	return widget.render(widget, assets.SplitColumnTemplate)
+}

+ 2 - 0
internal/widget/widget.go

@@ -67,6 +67,8 @@ func New(widgetType string) (Widget, error) {
 		widget = &Group{}
 	case "dns-stats":
 		widget = &DNSStats{}
+	case "split-column":
+		widget = &SplitColumn{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}