Polling System Colors For Dynamic Theming

One of the things that has frustrated me while hacking the CSS in Joplin is an inability to dynamically define colors based on the operating system's GUI APIs. I was digging in the Electron API, and apparently Electron does, in fact, support polling the operating system for color values in both Windows and macOS:

Note that this is entirely separate from the question of Dark Mode.

In addition to the base UI colors, the Electron API allows developers to poll for the system-wide "accent color". In Windows, this color can be user-specified or dynamically generated from the desktop background, and in macOS, it can be selected from a list or automagically chosen by the operating system.

macOS also specifies very subtle translucency under certain circumstances (as is also allowed in Windows), but as far as I can tell Chromium's support for window transparency is kind of piss-poor. Fun fact, though: macOS Big Sur subtly updates the colors of open applications every time your desktop background changes, which is particularly noticeable if you have a slideshow background. Listening for color-change System Events (in Windows or in macOS) and responding to them would presumably compensate for the lack of window translucency.

My recommendation should Joplin adopt system colors is that the existing color options should be hidden under the "Show Advanced Settings" disclosure alongside the existing CSS links. (Also, don't get me started on fonts. Roboto on macOS?!? Heresy!)

As my usual sign-off, I can take a stab at this myself, but, to mix metaphors, I would probably be in over my head. :diving_mask:

Inscrutable Theme Type Properties

I am trying to create a new theme type, and it's really unclear what a lot of the properties refer to. (Some of the existing comments are clearly incorrect, too.) Could someone who knows maybe go in and add some comments, here and/or on GitHub? It could also make sense for there to be a README.md file with this information in joplin / ReactNativeClient / lib / themes.

Specifically, I am trying to match up elements from Joplin's Theme type and the elements available from the two lists under systemPreferences.getColor(color) (and adjacent methods) in the Electron API. (I recognize there isn't a 100% correspondence, and this snippet below wouldn't actually do anything, yet, but it seems like a good starting point.)

This is what I have so far, based on dark.ts:

import { Theme, ThemeAppearance } from './type';

// This is the default dark theme in Joplin

const { systemPreferences } = require('electron');

function getColor(color: string):string {
	return systemPreferences.getColor(color);
}

const theme:Theme = {
	appearance: ThemeAppearance.Dark,

	// Color Names Taken From
	// https://www.electronjs.org/docs/api/system-preferences#systempreferencesgetcolorcolor-windows-macos

	// Color scheme "1" is the basic one, like used to display the note
	// content. It's basically dark gray text on white background
	backgroundColor:
		getColor("text-background"),
		// '#1D2024',
	backgroundColorTransparent:
		getColor("text-background"),
		// 'rgba(255,255,255,0.9)',
	oddBackgroundColor:
		getColor(""),
		// '#dddddd',
	color:
		getColor("text"),
		// '#dddddd',
	colorError:
		getColor(""),
		// 'red',
	colorWarn:
		getColor(""),
		// '#9A5B00',
	colorFaded:
		getColor("disabled-control-text"),
		// '#999999', // For less important text
	colorBright:
		getColor("control-text"),
		// '#ffffff', // For important text
	dividerColor:
		getColor("separator"),
		// '#555555',
	selectedColor:
		getColor("selected-text"),
		// '#616161',
	urlColor:
		getColor("link"),
		// 'rgb(166,166,255)',

	// Color scheme "2" is used for the sidebar. It's white text over
	// dark blue background.
	backgroundColor2:
		getColor("under-page-background"),
		// getColor("window-background"),
		// '#181A1D',
	color2:
		getColor(""),
		// '#ffffff',
	selectedColor2:
		getColor(""),
		// '#013F74',
	colorError2:
		getColor(""),
		// '#ff6c6c',

	// Color scheme "3" is used for the config screens for example/
	// It's dark text over gray background.
	backgroundColor3:
		getColor("control-background"),
		// '#2E3138',
	backgroundColorHover3:
		getColor("keyboard-focus-indicator"),
		// '#4E4E4E',
	color3:
		getColor("control"),
		// '#dddddd',

	// Color scheme "4" is used for secondary-style buttons. It makes a white
	// button with blue text.
	backgroundColor4:
		getColor("control"),
		// '#1D2024',
	color4:
		getColor("control-text"),
		// '#789FE9',

	raisedBackgroundColor:
		getColor(""),
		// '#474747',
	raisedColor:
		getColor(""),
		// '#ffffff',
	searchMarkerBackgroundColor:
		getColor(""),
		// '#F7D26E',
	searchMarkerColor:
		getColor(""),
		// 'black',

	warningBackgroundColor:
		getColor(""),
		// '#CC6600',

	tableBackgroundColor:
		getColor(""),
		// 'rgb(40, 41, 42)',
	codeBackgroundColor:
		getColor(""),
		// 'rgb(47, 48, 49)',
	codeBorderColor:
		getColor(""),
		// 'rgb(70, 70, 70)',
	codeColor:
		getColor(""),
		// '#ffffff',

	codeMirrorTheme: 'macos-dynamic',
	codeThemeCss: 'macos-dynamic.css',
};

export default theme;

Do you have a screenshot to show how it looks? Also please note that the themes are shared between the mobile and desktop app, so your theme would break the mobile app since it doesn't have the Electron package. So there would be some preliminary work to do to allow specific themes per platform.

Do you have a screenshot to show how it looks?

No, I don't. I haven't gotten to the point of building the application, yet. I know there are other things I would need to do for the rest of the application to even know this file exists.

I mean, I can try and get a build up and running even if I don't know what all of the interface properties refer to. And I could use dummy colors (like the classic fuschia) to try and suss things out myself.

Also please note that the themes are shared between the mobile and desktop app, so your theme would break the mobile app since it doesn't have the Electron package. So there would be some preliminary work to do to allow specific themes per platform.

That makes perfect sense. And indeed the string values passed to systemPreferences.getColor() are completely different between macOS and Windows, so there would also need to be some logic to make sure it works on both of those. The Electron systemPreferences API doesn't even mention Linux, so I'll get there when I get there, too. For the time being, I would just like to focus on getting this to work on macOS on my own git branch as a proof of concept.

Well, I haven't drowned, yet!

Thoughts, in no particular order:

  • No, I still haven't tried building this, yet, but VS Code isn't showing any errors, other than a warning that the input variable of getColor() is unused (which it isn't).
  • This could probably be comfortably split into smaller files without breaking any other themes, but I wanted to put everything in one place to begin with.
  • The best way of annotating the type properties would probably be a mix of using a color picker in Joplin and searching for callbacks elsewhere in the code.
  • I mostly know C#, so TypeScript looks like gobbledygook to me.

I can continue work on this myself, but I would nonetheless appreciate general feedback. I have littered a number of comments throughout my code snippet as potential conversation prompts.

import { Theme, ThemeAppearance } from './type';
import { Platform, PlatformColor } from 'react-native';

// The fallback values in this theme
// come from the default dark theme in Joplin
// because that's what I plan on using for debugging.

const colorNames = {

    // Android and iOS use React Native PlatformColor:
    // https://reactnative.dev/docs/platformcolor

    // Android React Native color names taken from:
    // https://developer.android.com/reference/android/R.attr
    // https://developer.android.com/reference/android/R.color

    // iOS React Native color names taken from:
    // https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors

	// Linux uses GTK:
	// https://stackoverflow.com/a/56415144
	// 

    // macOS and Windows use Electron systemPrefences:
    // https://www.electronjs.org/docs/api/system-preferences#systempreferencesgetaccentcolor-windows-macos
	// https://www.electronjs.org/docs/api/system-preferences#systempreferencesgetcolorcolor-windows-macos
    // https://www.electronjs.org/docs/api/system-preferences#systempreferencesgetsystemcolorcolor-macos

	
	// backgroundColor is used for ???.
	// It is by default midnight blue.
	backgroundColor: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
		windows:"",
		fallbackLiteral:"#1D2024",
    },

	// backgroundColorTransparent is used for ???.
	// It is by default translucent white.
	backgroundColorTransparent: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"rgba(255,255,255,0.9)",
    },

	// oddBackgroundColor is used for ???.
	// It is by default light gray.
	oddBackgroundColor: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#dddddd",
    },

	// color is used for most text.
	// It is by default light gray.
	color: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#dddddd",
    },

	// colorError is used for ???.
	// It is by default red.
	colorError: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"red",
    },

	// colorWarn is used for ???.
	// It is by default dark orange.
	colorWarn: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#9A5B00",
    },

	// colorFaded is used for less important text.
	// It is by default medium gray.
	colorFaded: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#999999",
    },

	// colorBright is used for important text.
	// It is by default white.
	colorBright: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#ffffff",
    },

	// divider color is used for ???.
	// It is by default dark gray.
	dividerColor: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#555555",
    },

	// selectedColor is used for ???.
	// It is by default medium gray.
	selectedColor: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#616161",
    },

	// urlColor is used for URLs.
	// It is by default lavender.
	urlColor: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"rgb(166,166,255)",
    },
	
	// backgroundColor2 is used for the sidebar.
	// It is by default midnight blue.
	backgroundColor2: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"",
    },

	// color2 is used for sidebar text.
	// It is by default white.
	color2: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"",
    },

	// selectedColor2 is used for sidebar selections.
	// It is by default navy blue.
	selectedColor2: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#013F74",
    },

	// colorError2 is used for ???.
	// It is by default salmon or coral.
	colorError2: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#ff6c6c",
    },

	// backgroundColor3 is used for config screens
	// It is by default dark blue
	backgroundColor3: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#2E3138",
    },

	// backgroundColorHover3 is used for config screens.
	// It is by default dark gray.
	backgroundColorHover3: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#4E4E4E",
    },

	// color3 is used for config screens.
	// It is by default light gray.
	color3: {
        android:"",
        ios:"",
        linux:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#dddddd",
    },

	// backgroundColor4 is used for secondary-style buttons.
	// It is by default midnight blue.
	backgroundColor4: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#1D2024",
    },

	// color4 is the foreground for secondary-style buttons.
	// It is by default light blue.
	color4: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#789FE9",
    },

	// raisedBackgroundColor is used for ???.
	// It is by default medium gray.
	raisedBackgroundColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#474747",
    },

	// raisedColor is used for ???.
	// It is by default white.
	raisedColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#ffffff",
    },

	// searchMarkerBackgroundColor is used for ???.
	// It is by default yellow.
	searchMarkerBackgroundColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#F7D26E",
    },

	// searchMarkerColor is used for ???.
	// It is by default black.
	searchMarkerColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"black",
    },

	// warningBackgroundColor is used for ???.
	// It is by default orange.
	warningBackgroundColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#CC6600",
    },

	// tableBackgroundColor is used for ???.
	// It is by default midnight blue.
	tableBackgroundColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"rgb(40, 41, 42)",
    },

	// codeBackgroundColor is used for ???.
	// It is by default midnight blue.
	codeBackgroundColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"rgb(47, 48, 49)",
    },

	// codeBorderColor is used for ???.
	// It is by default dark gray.
	codeBorderColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"rgb(70, 70, 70)",
    },

	// codeColor is used for ???.
	// It is by default white.
	codeColor: {
        android:"",
        ios:"",
        linuxDark:"",
        linuxLight:"",
        macos:"",
        windows:"",
		fallbackLiteral:"#ffffff",
    },
        
}

function getColor(color: string):string {
	var colorValue:string;
	var platform:string;
	platform = process.platform;

	switch(platform) {
		case 'darwin':
			var systemPreferences = require('electron');
			colorValue: systemPreferences.getColor(colorNames.color.macos);
			break;
		case 'win32':
			var systemPreferences = require('electron');
			colorValue: systemPreferences.getColor(colorNames.color.windows);
			break;
		case 'linux':
			// Something something
			// https://stackoverflow.com/a/56415144
			break;
    	default:
			Platform.select({
				ios: {
					colorValue: PlatformColor(colorNames.color.ios)
				},
				android: {
					colorValue: PlatformColor(colorNames.color.android)
				},
				default: {
					colorValue: colorNames.color.fallbackLiteral
				}
			})
			break;
	}
	return colorValue;
}



const theme:Theme = {
	appearance: ThemeAppearance.Dark,

	// It would be nice to be able to iterate through
	// the properties of type.ts,
	// but apparently this isn't immediately possible:
	// https://stackoverflow.com/questions/45670705/typescript-iterate-interface-properties

	backgroundColor:
		getColor("backgroundColor"),
	backgroundColorTransparent:
		getColor("backgroundColorTransparent"),
	oddBackgroundColor:
		getColor("oddBackgroundColor"),
	color:
		getColor("color"),
	colorError:
		getColor("colorError"),
	colorWarn:
		getColor("colorWarn"),
	colorFaded:
		getColor("colorFaded"),
	colorBright:
		getColor("colorBright"),
	dividerColor:
		getColor("dividerColor"),
	selectedColor:
		getColor("selectedColor"),
	urlColor:
		getColor("urlColor"),

	backgroundColor2:
		getColor("backgroundColor2"),
	color2:
		getColor("color2"),
	selectedColor2:
		getColor("selectedColor2"),
	colorError2:
		getColor("colorError2"),

	backgroundColor3:
		getColor("backgroundColor3"),
	backgroundColorHover3:
		getColor("backgroundColorHover3"),
	color3:
		getColor("color3"),

	backgroundColor4:
		getColor("backgroundColor4"),
	color4:
		getColor("color4"),

	raisedBackgroundColor:
		getColor("raisedBackgroundColor"),
	raisedColor:
		getColor("raisedColor"),
	searchMarkerBackgroundColor:
		getColor("searchMarkerBackgroundColor"),
	searchMarkerColor:
		getColor("searchMarkerColor"),

	warningBackgroundColor:
		getColor("warningBackgroundColor"),

	tableBackgroundColor:
		getColor("tableBackgroundColor"),
	codeBackgroundColor:
		getColor("codeBackgroundColor"),
	codeBorderColor:
		getColor("codeBorderColor"),
	codeColor:
		getColor("codeColor"),

	codeMirrorTheme: 'dynamic',
	codeThemeCss: 'dynamic.css',
};

export default theme;