Native-like macOS buttons and Windows appearance

This commit is contained in:
Samuel Elliott
2022-06-12 15:15:35 +01:00
parent 49ee4f87b5
commit 241ce19ec8
17 changed files with 262 additions and 49 deletions

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useContext, useState } from 'react';
import { Button as NativeButton, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc from '../ipc.js';
import { useAccentColour, useColourScheme, WindowFocusedContext } from '../util.js';
function ButtonMac(props: {
title: string;
primary?: boolean;
color?: string;
autoFocus?: boolean;
onPress?: () => void;
}) {
const styles = styles_mac;
const window_focused = useContext(WindowFocusedContext);
const accent_colour = useAccentColour();
const [pressed, setPressIn] = useState(false);
const onPressIn = useCallback(() => setPressIn(true), []);
const onPressOut = useCallback(() => setPressIn(false), []);
const active = window_focused && (props.primary || pressed);
return <Pressable
style={[
styles.button,
active ? {backgroundColor: props.color ?? accent_colour} : null,
]}
onPress={props.onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
>
<View style={[
styles.inner,
active ? styles.innerActive : null,
active && pressed ? styles.innerPressed : null,
]}>
<Text style={styles.text}>{props.title}</Text>
</View>
</Pressable>;
}
const styles_mac = StyleSheet.create({
button: {
cursor: 'default',
lineHeight: 19,
borderRadius: 3,
boxShadow: '#00000030 0px 0px 1px',
},
inner: {
paddingHorizontal: 14,
paddingVertical: 2,
borderRadius: 3,
backgroundColor: '#ffffff40',
borderTopWidth: 0.5,
borderTopColor: '#ffffff3a',
},
innerActive: {
backgroundColor: 'transparent',
backgroundImage: 'linear-gradient(0deg, #00000050, #00000040)',
},
innerPressed: {
backgroundColor: 'transparent',
backgroundImage: 'linear-gradient(0deg, #00000040, #00000020)',
},
text: {
fontSize: 13,
color: '#eaeaea',
},
});
function ButtonWindows(props: {
title: string;
primary?: boolean;
color?: string;
autoFocus?: boolean;
onPress?: () => void;
}) {
const styles = styles_windows;
const window_focused = useContext(WindowFocusedContext);
const colour_scheme = useColourScheme();
const accent_colour = useAccentColour();
const [hovered, setMouseOver] = useState(false);
const onMouseOver = useCallback(() => setMouseOver(true), []);
const onMouseOut = useCallback(() => setMouseOver(false), []);
const [pressed, setPressIn] = useState(false);
const onPressIn = useCallback(() => setPressIn(true), []);
const onPressOut = useCallback(() => setPressIn(false), []);
const active = window_focused && (props.primary || pressed);
return <Pressable
style={[
styles.button,
colour_scheme === 'light' ? styles.buttonLight : null,
window_focused && hovered ? styles.buttonHover : null,
colour_scheme === 'light' && window_focused && hovered ? styles.buttonLightHover : null,
active ? {backgroundColor: props.color ?? accent_colour} : null,
]}
// @ts-expect-error react-native-web
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onPress={props.onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
>
<Text style={[
styles.text,
colour_scheme === 'light' && !active ? styles.textLight : null,
]}>{props.title}</Text>
</Pressable>;
}
const styles_windows = StyleSheet.create({
button: {
backgroundColor: '#ffffff20',
minWidth: 180,
paddingVertical: 8,
paddingHorizontal: 12,
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
},
buttonLight: {
backgroundColor: '#00000020',
},
buttonHover: {
backgroundColor: '#ffffff30',
},
buttonLightHover: {
backgroundColor: '#00000030',
},
text: {
color: '#eaeaea',
},
textLight: {
color: TEXT_COLOUR_LIGHT,
},
});
function ButtonNative(props: {
title: string;
color?: string;
onPress?: () => void;
}) {
const accent_colour = useAccentColour();
return <NativeButton
title={props.title}
color={props.color ?? accent_colour}
onPress={props.onPress}
/>;
}
export default
Platform.OS === 'web' && ipc.platform === 'darwin' ? React.memo(ButtonMac) :
Platform.OS === 'web' && ipc.platform === 'win32' ? React.memo(ButtonWindows) :
React.memo(ButtonNative);

View File

@@ -0,0 +1 @@
export { default as Button } from './button.js';

View File

@@ -1,9 +1,11 @@
import ipc from './ipc.js';
export const NSO_COLOUR = '#e60012';
export const NSO_COLOUR_DARK: `${typeof NSO_COLOUR}e0` = `${NSO_COLOUR}e0`;
export const DISCORD_COLOUR = '#5865f2';
export const BACKGROUND_COLOUR_MAIN_LIGHT = '#ececec';
export const BACKGROUND_COLOUR_MAIN_DARK = '#252424';
export const BACKGROUND_COLOUR_MAIN_LIGHT = ipc.platform === 'win32' ? '#ffffff' : '#ececec';
export const BACKGROUND_COLOUR_MAIN_DARK = ipc.platform === 'win32' ? '#000000' : '#252424';
export const BACKGROUND_COLOUR_SECONDARY_LIGHT = '#ffffff';
export const BACKGROUND_COLOUR_SECONDARY_DARK = '#353535';
@@ -15,6 +17,7 @@ export const HIGHLIGHT_COLOUR_DARK = '#ffffff20';
export const BORDER_COLOUR_LIGHT = '#00000020';
export const BORDER_COLOUR_DARK = '#00000080';
export const BORDER_COLOUR_SECONDARY_DARK = '#ffffff20';
export const TEXT_COLOUR_LIGHT = '#212121';
export const TEXT_COLOUR_DARK = '#f5f5f5';

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
import { StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
import { Picker } from 'react-native-web';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, Root, useAsync, useDiscordPresenceSource, useEventListener, User } from '../util.js';
import { getAccounts, RequestState, Root, useAsync, useDiscordPresenceSource, useEventListener } from '../util.js';
export interface DiscordSetupProps {
//
@@ -149,6 +150,7 @@ export default function DiscordSetup(props: DiscordSetupProps) {
<View style={styles.button}>
<Button title="Save"
onPress={save}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</View>

View File

@@ -1,10 +1,11 @@
/// <reference path="../react-native-web.d.ts" />
import React, { useCallback, useEffect } from 'react';
import { ActivityIndicator, Button, Image, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
import { CheckBox } from 'react-native-web';
import { Game, Presence, PresencePermissions, PresenceState } from '../../../api/znc-types.js';
import { getTitleIdFromEcUrl, hrduration } from '../../../util/misc.js';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { RequestState, Root, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener } from '../util.js';
@@ -105,7 +106,8 @@ export default function Friend(props: FriendProps) {
<Button title="Close"
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
primary autoFocus />
</View>
</View>
</View>

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useMemo } from 'react';
import { Button, Image, StyleSheet, Text, View } from 'react-native';
import { Image, StyleSheet, Text, View } from 'react-native';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, useAccentColour, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener, User } from '../util.js';
import { Friend, PresencePermissions } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
import { Button } from '../components/index.js';
export default function SetupDiscordPresence(props: {
user: User;
@@ -81,7 +82,7 @@ const styles = StyleSheet.create({
content: {
marginTop: -4,
paddingBottom: 16,
paddingHorizontal: 20,
paddingHorizontal: ipc.platform === 'win32' ? 24 : 20,
},
text: {

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { Button, Image, StyleSheet, Text, View } from 'react-native';
import { Image, StyleSheet, Text, View } from 'react-native';
import ipc from '../ipc.js';
import { useAccentColour, useColourScheme, User } from '../util.js';
import { ActiveEvent } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
import { Button } from '../components/index.js';
export default function Event(props: {
user: User;
@@ -44,7 +45,7 @@ export default function Event(props: {
const styles = StyleSheet.create({
content: {
paddingBottom: 16,
paddingHorizontal: 20,
paddingHorizontal: ipc.platform === 'win32' ? 24 : 20,
flexDirection: 'row',
},
image: {

View File

@@ -88,7 +88,7 @@ function FriendPresence(props: {
const styles = StyleSheet.create({
footer: {
paddingBottom: 16,
paddingHorizontal: 20,
paddingHorizontal: ipc.platform === 'win32' ? 24 : 20,
},
friendCode: {
fontSize: 12,
@@ -100,7 +100,8 @@ const styles = StyleSheet.create({
},
content: {
paddingBottom: 16,
paddingLeft: 20,
paddingLeft: ipc.platform === 'win32' ? 24 : 20,
paddingRight: ipc.platform === 'win32' ? 4 : 0,
flexDirection: 'row',
},

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, ScrollView, StyleSheet, useColorScheme, View } from 'react-native';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
import { CheckBox } from 'react-native-web';
import ipc, { events } from '../ipc.js';
import { AccentColourContext, getAccounts, Root, useAsync, useEventListener } from '../util.js';
import Sidebar from './sidebar.js';
@@ -38,12 +39,18 @@ export default function App(props: AppProps) {
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId}
insetTitleBarControls={props.insetTitleBarControls}
>
<View style={[styles.content, styles.button]}>
<View style={[styles.content, styles.checkboxContainer]}>
<AccentColourContext.Consumer children={accent_colour => (
<Button title={auto_refresh ? 'Disable auto refresh' : 'Enable auto refresh'}
onPress={() => setAutoRefresh(auto_refresh ? undefined : 30 * 1000)}
color={'#' + accent_colour} />
<CheckBox
value={!!auto_refresh}
onValueChange={v => setAutoRefresh(v ? 30 * 1000 : undefined)}
color={'#' + accent_colour}
style={styles.checkbox}
/>
)} />
<TouchableOpacity onPress={() => setAutoRefresh(auto_refresh ? undefined : 30 * 1000)}>
<Text style={theme.text}>Enable auto refresh</Text>
</TouchableOpacity>
</View>
</Sidebar>
@@ -51,7 +58,8 @@ export default function App(props: AppProps) {
<ScrollView style={styles.scroller} contentContainerStyle={styles.scrollerContent}>
<Update />
{selectedUser ? <Main key={selectedUser.user.id} user={selectedUser} /> : null}
{selectedUser ? <Main key={selectedUser.user.id} user={selectedUser}
autoRefresh={auto_refresh} /> : null}
</ScrollView>
</View>
</Root>;
@@ -74,8 +82,14 @@ const styles = StyleSheet.create({
paddingVertical: 16,
paddingHorizontal: 20,
},
button: {
checkboxContainer: {
flex: 1,
marginRight: 20,
flexDirection: 'row',
alignItems: 'center',
},
checkbox: {
marginRight: 10,
},
});

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Button, StyleSheet, Text, View } from 'react-native';
import React, { useCallback, useEffect } from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import ipc, { events } from '../ipc.js';
import { RequestState, useAccentColour, useAsync, useColourScheme, useEventListener, User } from '../util.js';
import Friends from './friends.js';
@@ -8,6 +8,7 @@ import Event from './event.js';
import Section from './section.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import SetupDiscordPresence from './discord-setup.js';
import { Button } from '../components/index.js';
export default function Main(props: {
user: User;
@@ -69,7 +70,7 @@ function MoonOnlyUser() {
<Text style={[styles.moonOnlyUserText, theme.text]}>Login to the Nintendo Switch Online app to view details here, or use the nxapi command to access Parental Controls data.</Text>
<View style={styles.moonOnlyUserButton}>
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} />
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} primary />
</View>
</View>
</Section>;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { useAccentColour, useColourScheme } from '../util.js';
import { BORDER_COLOUR_DARK, BORDER_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import { BORDER_COLOUR_LIGHT, BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc from '../ipc.js';
export default function Section(props: React.PropsWithChildren<{
title: string;
@@ -23,15 +24,17 @@ export default function Section(props: React.PropsWithChildren<{
const styles = StyleSheet.create({
container: {
borderBottomWidth: 1,
marginBottom: ipc.platform === 'win32' ? 10 : 0,
borderBottomWidth: ipc.platform === 'win32' ? 0 : 1,
},
header: {
paddingVertical: 16,
paddingHorizontal: 20,
paddingVertical: ipc.platform === 'win32' ? 20 : 16,
paddingHorizontal: ipc.platform === 'win32' ? 24 : 20,
flexDirection: 'row',
},
headerText: {
flex: 1,
fontSize: ipc.platform === 'win32' ? 24 : 14,
},
activityIndicator: {
marginLeft: 10,
@@ -49,7 +52,7 @@ const light = StyleSheet.create({
const dark = StyleSheet.create({
container: {
borderBottomColor: BORDER_COLOUR_DARK,
borderBottomColor: BORDER_COLOUR_SECONDARY_DARK,
},
text: {
color: TEXT_COLOUR_DARK,

View File

@@ -109,7 +109,7 @@ const styles = StyleSheet.create({
sidebar: {
flex: 1,
maxWidth: 250,
borderRightWidth: 1,
borderRightWidth: ipc.platform === 'win32' ? 0 : 1,
},
top: {

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import ipc from '../ipc.js';
import { useAccentColour, useEventListener } from '../util.js';
import type { UpdateCacheData } from '../../../common/update.js';
import { TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
import { Button } from '../components/index.js';
export default function Update() {
const accent_colour = useAccentColour();

View File

@@ -44,8 +44,8 @@ function WebService(props: {
const styles = StyleSheet.create({
content: {
paddingBottom: 16,
paddingLeft: 20,
paddingRight: 6,
paddingLeft: ipc.platform === 'win32' ? 24 : 20,
paddingRight: ipc.platform === 'win32' ? 10 : 6,
flexDirection: 'row',
},
@@ -57,7 +57,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
webserviceImage: {
borderRadius: 2,
borderRadius: ipc.platform === 'win32' ? 0 : 2,
marginBottom: 12,
},
webserviceName: {

View File

@@ -10,6 +10,8 @@ import { SavedToken } from '../../common/auth/nso.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, DEFAULT_ACCENT_COLOUR } from './constants.js';
export const WindowFocusedContext = React.createContext(false);
export function Root(props: React.PropsWithChildren<{
title?: string;
titleUser?: User | SavedToken;
@@ -24,6 +26,18 @@ export function Root(props: React.PropsWithChildren<{
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
const [window_focused, setWindowFocus] = useState(true);
useEffect(() => {
const handler = (event: FocusEvent) => setWindowFocus(event.type === 'focus');
window.addEventListener('focus', handler);
window.addEventListener('blur', handler);
return () => {
window.removeEventListener('focus', handler);
window.removeEventListener('blur', handler);
};
}, []);
useEffect(() => {
const user_na = props.titleUser?.user;
const user_nso = (props.titleUser && 'nso' in props.titleUser ? props.titleUser.nso : props.titleUser)?.nsoAccount.user;
@@ -48,25 +62,29 @@ export function Root(props: React.PropsWithChildren<{
document.documentElement.style.overflowY = props.scrollable ? 'auto' : 'hidden';
}, [props.scrollable]);
const content = <View style={[
props.scrollable ? styles.appScrollable : styles.app,
!props.vibrancy ? theme.appNoVibrancy : null,
props.style,
]}>
{props.autoresize && preventingFocus ? <View
key={'focuslock'}
focusable
// @ts-expect-error react-native-web
onFocus={unlockFocus}
/> : null}
{props.autoresize ? <View
key={'autoresize'}
onLayout={props.autoresize ? onLayout : undefined}
>{props.children}</View> : props.children}
</View>;
return <ColourSchemeContext.Provider value={colour_scheme}>
<AccentColourContext.Provider value={accent_colour ?? DEFAULT_ACCENT_COLOUR}>
<View style={[
props.scrollable ? styles.appScrollable : styles.app,
!props.vibrancy ? theme.appNoVibrancy : null,
props.style,
]}>
{props.autoresize && preventingFocus ? <View
key={'focuslock'}
focusable
// @ts-expect-error react-native-web
onFocus={unlockFocus}
/> : null}
{props.autoresize ? <View
key={'autoresize'}
onLayout={props.autoresize ? onLayout : undefined}
>{props.children}</View> : props.children}
</View>
<WindowFocusedContext.Provider value={window_focused}>
{content}
</WindowFocusedContext.Provider>
</AccentColourContext.Provider>
</ColourSchemeContext.Provider>;
}

View File

@@ -64,6 +64,8 @@ const ipc = {
removeEventListener: (event: string, listener: (args: any[]) => void) => events.removeListener(event, listener),
getAccentColour: () => accent_colour,
platform: process.platform,
};
export type NxapiElectronIpc = typeof ipc;