feat(theming): allow to define supplementary themes

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-10-09 00:50:37 +02:00
parent a1ba0bb01f
commit cb20e4e19d
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
9 changed files with 261 additions and 190 deletions

View file

@ -16,7 +16,7 @@ SPDX-FileCopyrightText = "2012-2019 Abbie Gonzalez <https://abbiecod.es|support@
SPDX-License-Identifier = "OFL-1.1-RFN"
[[annotations]]
path = ["img/dark-highcontrast.jpg", "img/dark.jpg", "img/default-source.svg", "img/default.jpg", "img/light-highcontrast.jpg", "img/light.jpg", "img/opendyslexic.jpg"]
path = ["img/dark-highcontrast.jpg", "img/dark.jpg", "img/default-source.svg", "img/default.jpg", "img/light-highcontrast.jpg", "img/light.jpg", "img/opendyslexic.jpg", "img/reduced-motion.jpg"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2022 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -17,6 +17,11 @@ interface ITheme {
public const TYPE_THEME = 1;
public const TYPE_FONT = 2;
/**
* A supplementary theme where multiple can be active at the same time.
* @since 33.0.0
*/
public const TYPE_SUPPLEMENTARY = 3;
/**
* Unique theme id

View file

@ -87,33 +87,22 @@ class ThemesService {
* @return string[] the enabled themes
*/
public function enableTheme(ITheme $theme): array {
$themesIds = $this->getEnabledThemes();
$enabledThemeIds = $this->getEnabledThemes();
// If already enabled, ignore
if (in_array($theme->getId(), $themesIds)) {
return $themesIds;
if (in_array($theme->getId(), $enabledThemeIds)) {
return $enabledThemeIds;
}
/** @var ITheme[] */
$themes = array_filter(array_map(function ($themeId) {
return $this->getThemes()[$themeId];
}, $themesIds));
// for other types then supplementary themes we need to filter out themes with the same type
if ($theme->getType() !== ITheme::TYPE_SUPPLEMENTARY) {
$allThemes = $this->getThemes();
$enabledThemeIds = array_filter($enabledThemeIds, fn (string $themeId) => $allThemes[$themeId]->gettype() !== $theme->gettype());
}
// Filtering all themes with the same type
$filteredThemes = array_filter($themes, function (ITheme $t) use ($theme) {
return $theme->getType() === $t->getType();
});
// Retrieve IDs only
/** @var string[] */
$filteredThemesIds = array_map(function (ITheme $t) {
return $t->getId();
}, array_values($filteredThemes));
$enabledThemes = array_merge(array_diff($themesIds, $filteredThemesIds), [$theme->getId()]);
$this->setEnabledThemes($enabledThemes);
return $enabledThemes;
$enabledThemeIds[] = $theme->getId();
$this->setEnabledThemes($enabledThemeIds);
return array_values($enabledThemeIds);
}
/**
@ -127,7 +116,7 @@ class ThemesService {
// If enabled, removing it
if (in_array($theme->getId(), $themesIds)) {
$enabledThemes = array_diff($themesIds, [$theme->getId()]);
$enabledThemes = array_values(array_diff($themesIds, [$theme->getId()]));
$this->setEnabledThemes($enabledThemes);
return $enabledThemes;
}

View file

@ -17,13 +17,11 @@ class ReducedMotion implements ITheme {
) {
}
public function getCustomCss(): string
{
public function getCustomCss(): string {
return '';
}
public function getMeta(): array
{
public function getMeta(): array {
return [];
}
@ -32,7 +30,7 @@ class ReducedMotion implements ITheme {
}
public function getType(): int {
return ITheme::TYPE_FONT;
return ITheme::TYPE_SUPPLEMENTARY;
}
public function getTitle(): string {

View file

@ -13,28 +13,21 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="descriptionDetail" />
<div class="theming__preview-list">
<ItemPreview
v-for="theme in themes"
:key="theme.id"
:enforced="theme.id === enforceTheme"
:selected="selectedTheme.id === theme.id"
:theme="theme"
:unique="themes.length === 1"
type="theme"
@change="changeTheme" />
</div>
<ThemeList
v-model="selectedMainThemes"
:label="t('theming', 'Themes')"
:themes="mainThemes" />
<div class="theming__preview-list">
<ItemPreview
v-for="theme in fonts"
:key="theme.id"
:selected="theme.enabled"
:theme="theme"
:unique="fonts.length === 1"
type="font"
@change="changeFont" />
</div>
<ThemeList
v-model="selectedSupplementaryThemes"
:label="t('theming', 'Supplementary themes')"
:themes="supplementaryThemes"
multiple />
<ThemeList
v-model="selectedFontThemes"
:label="t('theming', 'Fonts')"
:themes="fontThemes" />
<h3>{{ t('theming', 'Misc accessibility options') }}</h3>
<NcCheckboxRadioSwitch
@ -86,21 +79,19 @@
</template>
<script>
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import BackgroundSettings from './components/BackgroundSettings.vue'
import ItemPreview from './components/ItemPreview.vue'
import ThemeList from './components/ThemeList.vue'
import UserAppMenuSection from './components/UserAppMenuSection.vue'
import UserPrimaryColor from './components/UserPrimaryColor.vue'
import { refreshStyles } from './helpers/refreshStyles.js'
import { logger } from './logger.js'
const availableThemes = loadState('theming', 'themes', [])
const enforceTheme = loadState('theming', 'enforceTheme', '')
const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)
const enableBlurFilter = loadState('theming', 'enableBlurFilter', '')
@ -110,7 +101,7 @@ export default {
name: 'UserTheming',
components: {
ItemPreview,
ThemeList,
NcCheckboxRadioSwitch,
NcSettingsSection,
BackgroundSettings,
@ -119,30 +110,62 @@ export default {
},
data() {
if (availableThemes.every(({ type, enabled }) => type !== 1 || !enabled)) {
availableThemes.find(({ id, type }) => id === 'default' && type === 1).enabled = true
}
return {
availableThemes,
// Admin defined configs
enforceTheme,
shortcutsDisabled,
isUserThemingDisabled,
enableBlurFilter,
}
},
computed: {
themes() {
mainThemes() {
return this.availableThemes.filter((theme) => theme.type === 1)
},
fonts() {
fontThemes() {
return this.availableThemes.filter((theme) => theme.type === 2)
},
// Selected theme, fallback on first (default) if none
selectedTheme() {
return this.themes.find((theme) => theme.enabled === true) || this.themes[0]
supplementaryThemes() {
return this.availableThemes.filter((theme) => theme.type === 3)
},
selectedMainThemes: {
get() {
return this.mainThemes.filter(({ enabled }) => enabled)
},
set(themes) {
logger.debug('SETTING main', { themes })
this.updateThemes(this.mainThemes, themes)
},
},
selectedFontThemes: {
get() {
return this.fontThemes.filter(({ enabled }) => enabled)
},
set(themes) {
this.updateThemes(this.fontThemes, themes)
},
},
selectedSupplementaryThemes: {
get() {
return this.supplementaryThemes.filter(({ enabled }) => enabled)
},
set(themes) {
this.updateThemes(this.supplementaryThemes, themes)
},
},
description() {
@ -182,38 +205,19 @@ export default {
},
methods: {
t,
// Refresh server-side generated theming CSS
async refreshGlobalStyles() {
await refreshStyles()
this.$nextTick(() => this.$refs.primaryColor.reload())
},
changeTheme({ enabled, id }) {
// Reset selected and select new one
this.themes.forEach((theme) => {
if (theme.id === id && enabled) {
theme.enabled = true
return
}
theme.enabled = false
})
updateThemes(allThemes, selectedThemes) {
for (const theme of allThemes) {
theme.enabled = selectedThemes.includes(theme)
}
this.updateBodyAttributes()
this.selectItem(enabled, id)
},
changeFont({ enabled, id }) {
// Reset selected and select new one
this.fonts.forEach((font) => {
if (font.id === id && enabled) {
font.enabled = true
return
}
font.enabled = false
})
this.updateBodyAttributes()
this.selectItem(enabled, id)
},
async changeShortcutsDisabled(newState) {
@ -256,47 +260,23 @@ export default {
},
updateBodyAttributes() {
const enabledThemesIDs = this.themes.filter((theme) => theme.enabled === true).map((theme) => theme.id)
const enabledFontsIDs = this.fonts.filter((font) => font.enabled === true).map((font) => font.id)
const enabledThemesIDs = [
...this.selectedMainThemes.map((theme) => theme.id),
...this.selectedSupplementaryThemes.map((theme) => theme.id),
...this.selectedFontThemes.map((theme) => theme.id),
]
this.themes.forEach((theme) => {
this.mainThemes.forEach((theme) => {
document.body.toggleAttribute(`data-theme-${theme.id}`, theme.enabled)
})
this.fonts.forEach((font) => {
this.supplementaryThemes.forEach((theme) => {
document.body.toggleAttribute(`data-theme-${theme.id}`, theme.enabled)
})
this.fontThemes.forEach((font) => {
document.body.toggleAttribute(`data-theme-${font.id}`, font.enabled)
})
document.body.setAttribute('data-themes', [...enabledThemesIDs, ...enabledFontsIDs].join(','))
},
/**
* Commit a change and force reload css
* Fetching the file again will trigger the server update
*
* @param {boolean} enabled the theme state
* @param {string} themeId the theme ID to change
*/
async selectItem(enabled, themeId) {
try {
if (enabled) {
await axios({
url: generateOcsUrl('apps/theming/api/v1/theme/{themeId}/enable', { themeId }),
method: 'PUT',
})
} else {
await axios({
url: generateOcsUrl('apps/theming/api/v1/theme/{themeId}', { themeId }),
method: 'DELETE',
})
}
} catch (error) {
logger.error('theming: Unable to apply setting.', { error })
let message = t('theming', 'Unable to apply the setting.')
if (isAxiosError(error) && error.response.data.ocs?.meta?.message) {
message = `${error.response.data.ocs.meta.message}. ${message}`
}
showError(message)
}
document.body.setAttribute('data-themes', enabledThemesIDs.join(','))
},
},
}
@ -318,14 +298,6 @@ export default {
text-decoration: underline;
}
}
&__preview-list {
--gap: 30px;
display: grid;
margin-top: var(--gap);
column-gap: var(--gap);
row-gap: var(--gap);
}
}
.background {
@ -333,11 +305,4 @@ export default {
margin-top: 30px;
}
}
@media (max-width: 1440px) {
.theming__preview-list {
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,122 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import ThemeListItem from './ThemeListItem.vue'
import { logger } from '../logger.ts'
interface ITheme {
id: string
enabled: boolean
}
const props = defineProps<{
/**
* Label of this list
*/
label: string
/**
* Allow to select multiple themes
*/
multiple?: boolean
/**
* The list of available themes
*/
themes: ITheme[]
/**
* The selected themes
*/
modelValue: ITheme[]
}>()
const emit = defineEmits<{
(e: 'update:model-value', v: ITheme[])
}>()
const name = 'themes-' + Math.random().toString(16).slice(6)
const enforcedTheme = loadState('theming', 'enforceTheme', '')
/**
* @param theme - The theme to check if selected
*/
function isSelected(theme: ITheme) {
return props.modelValue.includes(theme)
}
/**
* @param theme - The theme to toggle
*/
async function toggleSelected(theme: ITheme) {
logger.debug('Toggle theme ' + theme.id, { theme })
try {
if (isSelected(theme)) {
await axios.delete(generateOcsUrl('apps/theming/api/v1/theme/{themeId}', { themeId: theme.id }))
emit('update:model-value', props.modelValue.filter(({ id }) => id !== theme.id))
} else {
await axios.put(generateOcsUrl('apps/theming/api/v1/theme/{themeId}/enable', { themeId: theme.id }))
if (props.multiple) {
emit('update:model-value', [...props.modelValue, theme])
} else {
emit('update:model-value', [theme])
}
}
} catch (error) {
logger.error('theming: Unable to apply setting.', { error })
let message = t('theming', 'Unable to apply the setting.')
if (isAxiosError(error) && error.response?.data.ocs?.meta?.message) {
message = `${error.response.data.ocs.meta.message}. ${message}`
}
showError(message)
}
}
</script>
<script lang="ts">
// todo: remove with Vue 3
export default {
model: {
event: 'update:model-value',
prop: 'model-value',
},
}
</script>
<template>
<ul :aria-label="label" class="theme-list">
<ThemeListItem
v-for="theme in themes"
:key="theme.id"
:enforced="theme.id === enforcedTheme"
:name="name"
:theme="theme"
:is-switch="themes.length === 1 || multiple"
@selected="toggleSelected(theme)" />
</ul>
</template>
<style scoped>
.theme-list {
--gap: 30px;
display: grid;
margin-top: var(--gap);
column-gap: var(--gap);
row-gap: var(--gap);
}
@media (max-width: 1440px) {
.theme-list {
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -3,8 +3,12 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div :class="'theming__preview--' + theme.id" class="theming__preview">
<div class="theming__preview-image" :style="{ backgroundImage: 'url(' + img + ')' }" @click="onToggle" />
<li :class="'theming__preview--' + theme.id" class="theming__preview">
<img
alt=""
class="theming__preview-image"
:src="imageUrl"
@click="onToggle">
<div class="theming__preview-description">
<h3>{{ theme.title }}</h3>
<p class="theming__preview-explanation">
@ -18,104 +22,88 @@
<NcCheckboxRadioSwitch
v-show="!enforced"
class="theming__preview-toggle"
:checked.sync="checked"
:model-value="checkboxModelValue"
:disabled="enforced"
:name="name"
:type="switchType">
:name="isSwitch ? undefined : name"
:type="isSwitch ? 'switch' : 'radio'"
:value="isSwitch ? undefined : theme.id"
@update:model-value="onToggle">
{{ theme.enableLabel }}
</NcCheckboxRadioSwitch>
</div>
</div>
</li>
</template>
<script>
import { generateFilePath } from '@nextcloud/router'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { logger } from '../logger.ts'
export default {
name: 'ItemPreview',
name: 'ThemeListItem',
components: {
NcCheckboxRadioSwitch,
},
props: {
/**
* If the theme is enforced by the admin
*/
enforced: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
/**
* The theme object
*/
theme: {
type: Object,
required: true,
},
type: {
/**
* The name for the radio input to group them.
*/
name: {
type: String,
default: '',
},
unique: {
/**
* Whether to use a switch instead of a radio
*/
isSwitch: {
type: Boolean,
default: false,
},
},
emits: ['selected'],
computed: {
switchType() {
return this.unique ? 'switch' : 'radio'
checkboxModelValue() {
if (this.isSwitch) {
return this.enforced || this.theme.enabled
}
if (this.enforced || this.theme.enabled) {
return this.theme.id
}
return ''
},
name() {
return !this.unique ? this.type : null
},
img() {
imageUrl() {
return generateFilePath('theming', 'img', this.theme.id + '.jpg')
},
checked: {
get() {
return this.selected
},
set(checked) {
if (this.enforced) {
return
}
logger.debug('Changed theme', this.theme.id, checked)
// If this is a radio, we can only enable
if (!this.unique) {
this.$emit('change', { enabled: true, id: this.theme.id })
return
}
// If this is a switch, we can disable the theme
this.$emit('change', { enabled: checked === true, id: this.theme.id })
},
},
},
methods: {
onToggle() {
onToggle(value) {
if (this.enforced) {
return
}
if (this.switchType === 'radio') {
this.checked = true
return
if (this.isSwitch || (value === this.theme.id) !== this.theme.enabled) {
this.$emit('selected')
}
// Invert state
this.checked = !this.checked
},
},
}

View file

@ -17,6 +17,7 @@ use OCA\Theming\Themes\DefaultTheme;
use OCA\Theming\Themes\DyslexiaFont;
use OCA\Theming\Themes\HighContrastTheme;
use OCA\Theming\Themes\LightTheme;
use OCA\Theming\Themes\ReducedMotion;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
use OCP\App\IAppManager;
@ -74,6 +75,7 @@ class ThemesServiceTest extends TestCase {
'light-highcontrast',
'dark-highcontrast',
'opendyslexic',
'reduced-motion',
];
$this->assertEquals($expected, array_keys($this->themesService->getThemes()));
}
@ -110,6 +112,7 @@ class ThemesServiceTest extends TestCase {
'light-highcontrast',
'dark-highcontrast',
'opendyslexic',
'reduced-motion',
];
$this->assertEquals($expected, array_keys($this->themesService->getThemes()));
@ -364,6 +367,7 @@ class ThemesServiceTest extends TestCase {
$appManager,
null,
),
'reduced-motion' => new ReducedMotion($l10n),
];
}
}