Skip to content

Commit 90a3076

Browse files
authored
Custom Dark Theme - main & settings menu updates (streamlit#12761)
**Part 4 of Custom Dark Theme Feature** This PR does the following: 1. **Adds auto custom theme** - like we do for streamlit default themes * Theme selector shows `"Use System Setting"`, `"Light"`, & `"Dark"` for streamlit default themes & custom themes (where a light & dark version are configured) and allows navigation between them as expected. * First step in decoupling system settings from default vs custom themes - setting stage for app menu & theme caching updates 2. **Update theme selection in the Settings Menu** If `light`/`dark` section configuration (2 custom themes, 1 auto theme) * We select the `activeTheme` between `Custom Theme Light` and `Custom Theme Dark` based on user system preference and create an `Auto` theme with that preferred theme. * We allow the user to choose between these custom themes (`"Use System Setting"`, `"Light"`, & `"Dark"`) in the settings menu (remove streamlit default themes as options) If NO `light`/`dark` section configurations (1 custom theme, no auto theme necessary) * We set the `activeTheme` to `"Custom Theme"` * We don't allow the user a choice of theme - theme selector is disabled and just displays `"Custom Theme"` (remove streamlit default themes as options) 3. **Fixes the Main Menu** (which did not apply custom theme font)
1 parent 7319923 commit 90a3076

File tree

14 files changed

+356
-59
lines changed

14 files changed

+356
-59
lines changed
8.33 KB
Loading
11.8 KB
Loading
7.95 KB
Loading

e2e_playwright/theming/custom_theme_fonts_test.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ def test_custom_theme(app: Page, assert_snapshot: ImageCompareFunction):
6767
assert_snapshot(app, name="custom_fonts_app", image_threshold=0.0003)
6868

6969

70+
@pytest.mark.usefixtures("configure_custom_fonts")
71+
def test_custom_theme_main_menu(app: Page, assert_snapshot: ImageCompareFunction):
72+
"""Test that the main menu is rendered correctly with a custom theme (uses configured font)."""
73+
# Make sure that all elements are rendered and no skeletons are shown:
74+
expect_no_skeletons(app, timeout=25000)
75+
76+
# Open the main menu
77+
app.get_by_test_id("stMainMenu").click()
78+
79+
element = app.get_by_test_id("stMainMenuPopover")
80+
assert_snapshot(element, name="custom_fonts_main_menu")
81+
82+
7083
@pytest.mark.usefixtures("configure_custom_fonts")
7184
def test_custom_theme_settings_dialog(app: Page, assert_snapshot: ImageCompareFunction):
7285
"""Test that the settings dialog is rendered correctly with a custom theme."""

frontend/app/src/App.test.tsx

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ import {
3737
mockEndpoints,
3838
} from "@streamlit/connection"
3939
import {
40+
CUSTOM_THEME_AUTO_NAME,
4041
CUSTOM_THEME_DARK_NAME,
4142
CUSTOM_THEME_LIGHT_NAME,
4243
CUSTOM_THEME_NAME,
4344
FileUploadClient,
4445
getDefaultTheme,
4546
getHostSpecifiedTheme,
46-
getSystemThemePreference,
4747
HOST_COMM_VERSION,
4848
HostCommunicationManager,
4949
isEmbed,
@@ -98,7 +98,6 @@ vi.mock("@streamlit/lib", async () => {
9898
...actualLib,
9999
isEmbed: vi.fn(),
100100
isToolbarDisplayed: vi.fn(),
101-
getSystemThemePreference: vi.fn(),
102101
}
103102
})
104103

@@ -1924,6 +1923,74 @@ describe("App", () => {
19241923
})
19251924

19261925
describe("App.processThemeInput", () => {
1926+
it("passing a custom theme adds the custom theme and removes preset themes", () => {
1927+
// Simplest custom theme (no light/dark versions)
1928+
const themeInput = new CustomThemeConfig({
1929+
primaryColor: "blue",
1930+
})
1931+
1932+
const props = getProps()
1933+
renderApp(props)
1934+
1935+
sendForwardMessage("newSession", {
1936+
...NEW_SESSION_JSON,
1937+
customTheme: themeInput,
1938+
})
1939+
1940+
// Custom theme should be added
1941+
expect(props.theme.addThemes).toHaveBeenCalledTimes(1)
1942+
// Should have exactly one theme with name CUSTOM_THEME_NAME, and keepPresetThemes should be false
1943+
expect(props.theme.addThemes).toHaveBeenCalledWith(
1944+
[expect.objectContaining({ name: CUSTOM_THEME_NAME })],
1945+
expect.objectContaining({ keepPresetThemes: false })
1946+
)
1947+
// Active theme should be set to the custom theme
1948+
expect(props.theme.setTheme).toHaveBeenCalledWith(
1949+
expect.objectContaining({ name: CUSTOM_THEME_NAME })
1950+
)
1951+
})
1952+
1953+
it("passing a custom theme with light/dark versions adds both and removes preset themes", () => {
1954+
const themeInput = new CustomThemeConfig({
1955+
primaryColor: "blue",
1956+
light: {
1957+
primaryColor: "red",
1958+
},
1959+
dark: {
1960+
primaryColor: "green",
1961+
},
1962+
})
1963+
1964+
const props = getProps()
1965+
renderApp(props)
1966+
1967+
sendForwardMessage("newSession", {
1968+
...NEW_SESSION_JSON,
1969+
customTheme: themeInput,
1970+
})
1971+
1972+
// Check that 3 themes were added (light, dark, auto)
1973+
expect(props.theme.addThemes).toHaveBeenCalledTimes(1)
1974+
expect(props.theme.addThemes).toHaveBeenCalledWith(
1975+
[
1976+
expect.objectContaining({ name: CUSTOM_THEME_LIGHT_NAME }),
1977+
expect.objectContaining({ name: CUSTOM_THEME_DARK_NAME }),
1978+
expect.objectContaining({ name: CUSTOM_THEME_AUTO_NAME }),
1979+
],
1980+
expect.objectContaining({ keepPresetThemes: false })
1981+
)
1982+
1983+
// Active theme should be set to the auto theme
1984+
expect(props.theme.setTheme).toHaveBeenCalledWith(
1985+
expect.objectContaining({
1986+
name: CUSTOM_THEME_AUTO_NAME,
1987+
themeInput: expect.objectContaining({
1988+
primaryColor: "red",
1989+
}),
1990+
})
1991+
)
1992+
})
1993+
19271994
it("calls setFonts when fontFaces are provided", () => {
19281995
const fontFaces = [{ url: "test-url" }]
19291996
const themeInput = new CustomThemeConfig({
@@ -2027,8 +2094,13 @@ describe("App", () => {
20272094
})
20282095

20292096
it("sets active theme based on system preference when theme input has light/dark configs - Custom Theme Light", () => {
2030-
// Mock the system preference return value
2031-
vi.mocked(getSystemThemePreference).mockReturnValue("light")
2097+
// Mock the system preference return value (light)
2098+
Object.defineProperty(window, "matchMedia", {
2099+
writable: true,
2100+
value: vi.fn().mockImplementation(query => ({
2101+
matches: query === "(prefers-color-scheme: light)", // Returns true for light
2102+
})),
2103+
})
20322104
const themeInput = new CustomThemeConfig({
20332105
primaryColor: "blue",
20342106
light: {
@@ -2048,16 +2120,25 @@ describe("App", () => {
20482120
})
20492121

20502122
expect(props.theme.addThemes).toHaveBeenCalledTimes(1)
2123+
// Check that the auto theme is set, and that it is the custom light theme
20512124
expect(props.theme.setTheme).toHaveBeenCalledWith(
20522125
expect.objectContaining({
2053-
name: CUSTOM_THEME_LIGHT_NAME,
2126+
name: CUSTOM_THEME_AUTO_NAME,
2127+
themeInput: expect.objectContaining({
2128+
primaryColor: "lightblue",
2129+
}),
20542130
})
20552131
)
20562132
})
20572133

20582134
it("sets active theme based on system preference when theme input has light/dark configs - Custom Theme Dark", () => {
2059-
// Mock the system preference return value
2060-
vi.mocked(getSystemThemePreference).mockReturnValue("dark")
2135+
// Mock the system preference return value (dark)
2136+
Object.defineProperty(window, "matchMedia", {
2137+
writable: true,
2138+
value: vi.fn().mockImplementation(query => ({
2139+
matches: query === "(prefers-color-scheme: dark)", // Returns true for dark
2140+
})),
2141+
})
20612142
const themeInput = new CustomThemeConfig({
20622143
primaryColor: "blue",
20632144
light: {
@@ -2077,9 +2158,13 @@ describe("App", () => {
20772158
})
20782159

20792160
expect(props.theme.addThemes).toHaveBeenCalledTimes(1)
2161+
// Check that the auto theme is set, and that it is the custom dark theme
20802162
expect(props.theme.setTheme).toHaveBeenCalledWith(
20812163
expect.objectContaining({
2082-
name: CUSTOM_THEME_DARK_NAME,
2164+
name: CUSTOM_THEME_AUTO_NAME,
2165+
themeInput: expect.objectContaining({
2166+
primaryColor: "darkblue",
2167+
}),
20832168
})
20842169
)
20852170
})

frontend/app/src/App.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
createCustomThemes,
6565
createFormsData,
6666
createPresetThemes,
67+
CUSTOM_THEME_AUTO_NAME,
6768
DeployedAppMetadata,
6869
ensureError,
6970
extractPageNameFromPathName,
@@ -76,7 +77,6 @@ import {
7677
getHostSpecifiedTheme,
7778
getIFrameEnclosingApp,
7879
getLocaleLanguage,
79-
getSystemThemePreference,
8080
getTimezone,
8181
getTimezoneOffset,
8282
getUrl,
@@ -1324,24 +1324,23 @@ export class App extends PureComponent<Props, State> {
13241324
const usingCustomTheme = !isPresetTheme(this.props.theme.activeTheme)
13251325
if (themeInput) {
13261326
// createCustomThemes can return either 1 theme ("Custom Theme")
1327-
// or 2 themes ("Custom Theme Light" and "Custom Theme Dark")
1327+
// or 3 themes ("Custom Theme Light", "Custom Theme Dark", and "Custom Theme Auto")
13281328
const customThemes = createCustomThemes(themeInput)
13291329

1330-
// Add the themes to the theme manager
1331-
this.props.theme.addThemes(customThemes)
1330+
// Add the new custom themes to the theme manager and remove the preset themes
1331+
this.props.theme.addThemes(customThemes, { keepPresetThemes: false })
13321332

13331333
const userPreference = getCachedTheme()
13341334
if (userPreference === null || usingCustomTheme) {
1335-
// Update the theme to be customTheme either if the user hasn't set a
1336-
// preference (developer-provided custom themes should be the default
1337-
// for an app) or if a custom theme is currently active (to ensure that
1338-
// we pick up any new changes to it).
1335+
// If the user hasn't set a preference, or if a custom theme is currently active,
1336+
// update the theme to be a custom theme.
13391337
if (customThemes.length > 1) {
1340-
// Decide between Custom Theme Light or Custom Theme Dark
1341-
// based on the user's system preference
1342-
const systemPreference = getSystemThemePreference()
1343-
const themeIndex = systemPreference === "dark" ? 1 : 0
1344-
this.setAndSendTheme(customThemes[themeIndex])
1338+
// When Custom Theme Light & Custom Theme Dark present, we create an auto theme based
1339+
// on the system preference and set this as the active theme
1340+
const autoThemeIndex = customThemes.findIndex(
1341+
theme => theme.name === CUSTOM_THEME_AUTO_NAME
1342+
)
1343+
this.setAndSendTheme(customThemes[autoThemeIndex])
13451344
} else {
13461345
// Set to singular Custom Theme
13471346
this.setAndSendTheme(customThemes[0])

frontend/app/src/components/MainMenu/styled-components.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,6 @@ export const StyledDevItem = styled.li<ItemStyleProps>(
162162
export const StyledMenuItemLabel = styled.span(({ theme }) => ({
163163
marginRight: theme.spacing.md,
164164
flexGrow: 1,
165-
// We do not want to change the font for this based on theme.
166-
fontFamily: theme.fonts.sansSerif,
167165
}))
168166

169167
export const StyledMenuContainer = styled.div(({ theme }) => ({

frontend/app/src/components/StreamlitDialog/SettingsDialog.test.tsx

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import { userEvent } from "@testing-library/user-event"
2121

2222
import { MetricsManager } from "@streamlit/app/src/MetricsManager"
2323
import {
24+
AUTO_THEME_NAME,
2425
createPresetThemes,
26+
CUSTOM_THEME_DARK_NAME,
27+
CUSTOM_THEME_LIGHT_NAME,
2528
CUSTOM_THEME_NAME,
2629
customTheme,
2730
darkTheme,
@@ -30,12 +33,34 @@ import {
3033
mockSessionInfo,
3134
renderWithContexts,
3235
SessionInfo,
36+
ThemeConfig,
3337
} from "@streamlit/lib"
3438

3539
import { Props, SettingsDialog } from "./SettingsDialog"
3640

3741
const mockSetTheme = vi.fn()
3842

43+
export const autoCustomTheme: ThemeConfig = {
44+
name: "Use system setting",
45+
emotion: lightTheme.emotion,
46+
basewebTheme: lightTheme.basewebTheme,
47+
primitives: lightTheme.primitives,
48+
}
49+
50+
const customThemeLight: ThemeConfig = {
51+
name: "Custom Theme Light",
52+
emotion: lightTheme.emotion,
53+
basewebTheme: lightTheme.basewebTheme,
54+
primitives: lightTheme.primitives,
55+
}
56+
57+
const customThemeDark: ThemeConfig = {
58+
name: "Custom Theme Dark",
59+
emotion: darkTheme.emotion,
60+
basewebTheme: darkTheme.basewebTheme,
61+
primitives: darkTheme.primitives,
62+
}
63+
3964
const getContext = (
4065
extend?: Partial<LibContextProps>
4166
): Partial<LibContextProps> => ({
@@ -111,9 +136,10 @@ describe("SettingsDialog", () => {
111136
expect(screen.getByRole("combobox")).toBeVisible()
112137
})
113138

114-
it("if custom theme exists, only show custom theme as option & disable selectbox", () => {
115-
const presetThemes = createPresetThemes()
116-
const availableThemes = [...presetThemes, customTheme]
139+
it("if single custom theme exists, only show Custom Theme as option & disable selectbox", () => {
140+
// When single custom theme exists (no light/dark versions), this is the only option
141+
// and the preset themes are removed from available themes
142+
const availableThemes = [customTheme]
117143
const props = getProps()
118144
const context = getContext({ availableThemes, activeTheme: customTheme })
119145

@@ -126,7 +152,67 @@ describe("SettingsDialog", () => {
126152
expect(screen.getByText(CUSTOM_THEME_NAME)).toBeVisible()
127153
})
128154

129-
it("should show custom theme does not exists", async () => {
155+
it("if Custom Theme Light active, show correct active theme & light/dark/auto custom themes as options", async () => {
156+
const user = userEvent.setup()
157+
// When custom theme light & dark exist, also have auto theme
158+
// and the preset themes are removed from available themes
159+
const availableThemes = [
160+
autoCustomTheme,
161+
customThemeLight,
162+
customThemeDark,
163+
]
164+
const props = getProps()
165+
const context = getContext({
166+
availableThemes,
167+
activeTheme: customThemeLight,
168+
})
169+
170+
renderWithContexts(<SettingsDialog {...props} />, context)
171+
172+
// Correct selected theme is shown
173+
expect(screen.getByText(CUSTOM_THEME_LIGHT_NAME)).toBeVisible()
174+
175+
const selectbox = screen.getByRole("combobox")
176+
await user.click(selectbox)
177+
178+
// Should only show Auto (Use System Setting), Custom Theme Light and Custom Theme Dark as options
179+
const options = screen.getAllByRole("option")
180+
expect(options).toHaveLength(3)
181+
expect(options[0]).toHaveTextContent(AUTO_THEME_NAME)
182+
expect(options[1]).toHaveTextContent(CUSTOM_THEME_LIGHT_NAME)
183+
expect(options[2]).toHaveTextContent(CUSTOM_THEME_DARK_NAME)
184+
})
185+
186+
it("if Custom Theme Dark active, show correct active theme & light/dark/auto custom themes as options", async () => {
187+
const user = userEvent.setup()
188+
const availableThemes = [
189+
autoCustomTheme,
190+
customThemeLight,
191+
customThemeDark,
192+
]
193+
const props = getProps()
194+
const context = getContext({
195+
availableThemes,
196+
activeTheme: customThemeDark,
197+
})
198+
199+
renderWithContexts(<SettingsDialog {...props} />, context)
200+
201+
// Correct selected theme is shown
202+
expect(screen.getByText(CUSTOM_THEME_DARK_NAME)).toBeVisible()
203+
204+
const selectbox = screen.getByRole("combobox")
205+
await user.click(selectbox)
206+
207+
// Should only show Auto (Use System Setting), Custom Theme Light and Custom Theme Dark as options
208+
const options = screen.getAllByRole("option")
209+
expect(options).toHaveLength(3)
210+
expect(options[0]).toHaveTextContent(AUTO_THEME_NAME)
211+
expect(options[1]).toHaveTextContent(CUSTOM_THEME_LIGHT_NAME)
212+
expect(options[2]).toHaveTextContent(CUSTOM_THEME_DARK_NAME)
213+
})
214+
215+
it("should not show custom theme as option if it does not exist", async () => {
130216
const user = userEvent.setup()
131217
const presetThemes = createPresetThemes()
132218
const availableThemes = [...presetThemes]

0 commit comments

Comments
 (0)