Started on general window layout

This commit is contained in:
Gregory Schier
2023-02-19 23:11:59 -08:00
parent 0a0839cc3c
commit b95429dbeb
17 changed files with 337 additions and 99 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -3502,10 +3502,12 @@ dependencies = [
name = "tauri-app" name = "tauri-app"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"cocoa",
"deno_ast", "deno_ast",
"deno_core", "deno_core",
"futures", "futures",
"http", "http",
"objc",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -22,6 +22,8 @@ tokio = { version = "1.25.0", features = ["full"] }
futures = { version = "0.3.26" } futures = { version = "0.3.26" }
deno_core = { version = "0.171.0" } deno_core = { version = "0.171.0" }
deno_ast = { version = "0.24.0", features = ["transpiling"] } deno_ast = { version = "0.24.0", features = ["transpiling"] }
objc = "0.2.7"
cocoa = "0.24.1"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@@ -46,8 +46,16 @@ pub async fn send_request(
let req = client let req = client
.request(m, abs_url.to_string()) .request(m, abs_url.to_string())
.headers(headers) .headers(headers)
.build() .build();
.unwrap();
let req = match req {
Ok(v) => v,
Err(e) => {
println!("Error: {}", e);
return Err(e.to_string());
}
};
let resp = client.execute(req).await; let resp = client.execute(req).await;
let elapsed = start.elapsed().as_millis(); let elapsed = start.elapsed().as_millis();

View File

@@ -3,11 +3,36 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
mod commands; mod commands;
mod runtime; mod runtime;
mod window_ext;
use tauri::{Manager, WindowEvent};
use window_ext::WindowExt;
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.setup(|app| {
let win = app.get_window("main").unwrap();
win.position_traffic_lights();
Ok(())
})
.on_window_event(|e| {
let apply_offset = || {
let win = e.window();
win.position_traffic_lights();
};
match e.event() {
WindowEvent::Resized(..) => apply_offset(),
WindowEvent::ThemeChanged(..) => apply_offset(),
_ => {}
}
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::send_request, commands::send_request,
commands::greet commands::greet

View File

@@ -0,0 +1,49 @@
use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 20.0;
pub trait WindowExt {
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self);
}
impl<R: Runtime> WindowExt for Window<R> {
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let window = self.ns_window().unwrap() as cocoa::base::id;
let x = TRAFFIC_LIGHT_OFFSET_X;
let y = TRAFFIC_LIGHT_OFFSET_Y;
unsafe {
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
let miniaturize =
window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between);
button.setFrameOrigin(rect.origin);
}
}
}
}

View File

@@ -1,12 +1,12 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState } from 'react';
import { Helmet } from 'react-helmet-async';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import Editor from './components/Editor/Editor'; import Editor from './components/Editor/Editor';
import { Input } from './components/Input'; import { Input } from './components/Input';
import { Stacks } from './components/Stacks'; import { HStack, VStack } from './components/Stacks';
import { Button } from './components/Button'; import { Button } from './components/Button';
import { Grid } from './components/Grid';
import { DropdownMenuRadio } from './components/Dropdown'; import { DropdownMenuRadio } from './components/Dropdown';
import { WindowDragRegion } from './components/WindowDragRegion';
import { IconButton } from './components/IconButton';
interface Response { interface Response {
url: string; url: string;
@@ -19,6 +19,7 @@ interface Response {
} }
function App() { function App() {
const [error, setError] = useState<string | null>(null);
const [responseBody, setResponseBody] = useState<Response | null>(null); const [responseBody, setResponseBody] = useState<Response | null>(null);
const [url, setUrl] = useState('https://go-server.schier.dev/debug'); const [url, setUrl] = useState('https://go-server.schier.dev/debug');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,74 +28,107 @@ function App() {
async function sendRequest(e: FormEvent<HTMLFormElement>) { async function sendRequest(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
const resp = (await invoke('send_request', { method, url })) as Response; setError(null);
console.log('RESP', resp);
if (resp.body.includes('<head>')) { try {
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`); const resp = (await invoke('send_request', { method, url })) as Response;
if (resp.body.includes('<head>')) {
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`);
}
setLoading(false);
setResponseBody(resp);
} catch (err) {
setLoading(false);
setError(`${err}`);
} }
setLoading(false);
setResponseBody(resp);
} }
const contentType = responseBody?.headers['content-type']?.split(';')[0] ?? 'text/plain'; const contentType = responseBody?.headers['content-type']?.split(';')[0] ?? 'text/plain';
return ( return (
<> <>
<Helmet> <div className="grid grid-cols-[auto_1fr] h-full">
<body className="bg-background" /> <nav className="w-52 bg-gray-50 h-full border-r border-gray-500/10">
</Helmet> <HStack as={WindowDragRegion} className="pl-24 px-1" items="center" justify="end">
<div className="w-full h-7 bg-gray-50" data-tauri-drag-region="" /> <IconButton icon="archive" size="sm" />
<div className="p-12 h-full w-full overflow-auto"> <DropdownMenuRadio
<Stacks as="form" className="mt-5 items-end" onSubmit={sendRequest}> onValueChange={null}
<DropdownMenuRadio value={'get'}
onValueChange={setMethod} items={[
value={method} { label: 'This is a cool one', value: 'get' },
items={[ { label: 'But this one is better', value: 'put' },
{ label: 'GET', value: 'get' }, { label: 'This one is just alright', value: 'post' },
{ label: 'PUT', value: 'put' }, ]}
{ label: 'POST', value: 'post' }, >
]} <IconButton icon="camera" size="sm" />
> </DropdownMenuRadio>
<Button className="mr-1" disabled={loading} color="secondary"> </HStack>
{method.toUpperCase()} </nav>
</Button> <div className="h-full w-full overflow-auto">
</DropdownMenuRadio> <HStack as={WindowDragRegion} items="center" className="pl-4 pr-1">
<Input <h5>Hello, Friend!</h5>
hideLabel <IconButton icon="gear" className="ml-auto" size="sm" />
name="url" </HStack>
label="Enter URL" <VStack className="p-4 max-w-[40rem] mx-auto" space={3}>
className="mr-1 w-[20rem]" <HStack as="form" className="items-end" onSubmit={sendRequest} space={2}>
onChange={(e) => setUrl(e.currentTarget.value)} <DropdownMenuRadio
value={url} onValueChange={setMethod}
placeholder="Enter a URL..." value={method}
/> items={[
<Button className="mr-1" type="submit" disabled={loading}> { label: 'GET', value: 'get' },
{loading ? 'Sending...' : 'Send'} { label: 'PUT', value: 'put' },
</Button> { label: 'POST', value: 'post' },
</Stacks> ]}
{responseBody !== null && ( >
<> <Button disabled={loading} color="secondary" forDropdown>
<div className="pt-6"> {method.toUpperCase()}
{responseBody?.method.toUpperCase()} </Button>
&nbsp;&bull;&nbsp; </DropdownMenuRadio>
{responseBody?.status} <HStack>
&nbsp;&bull;&nbsp; <Input
{responseBody?.elapsed}ms &nbsp;&bull;&nbsp; hideLabel
{responseBody?.elapsed2}ms name="url"
</div> label="Enter URL"
<Grid cols={2} rows={2} gap={1}> className="rounded-r-none font-mono"
<Editor value={responseBody?.body} contentType={contentType} /> onChange={(e) => setUrl(e.currentTarget.value)}
<div className="iframe-wrapper"> value={url}
<iframe placeholder="Enter a URL..."
title="Response preview"
srcDoc={responseBody.body}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-lg"
/> />
</div> <Button
</Grid> className="mr-1 rounded-l-none -ml-3"
</> color="primary"
)} type="submit"
disabled={loading}
>
{loading ? 'Sending...' : 'Send'}
</Button>
</HStack>
</HStack>
{error && <div className="text-white bg-red-500 px-4 py-1 rounded">{error}</div>}
{responseBody !== null && (
<>
<div>
{responseBody?.method.toUpperCase()}
&nbsp;&bull;&nbsp;
{responseBody?.status}
&nbsp;&bull;&nbsp;
{responseBody?.elapsed}ms &nbsp;&bull;&nbsp;
{responseBody?.elapsed2}ms
</div>
{contentType.includes('html') ? (
<iframe
title="Response preview"
srcDoc={responseBody.body}
sandbox="allow-scripts allow-same-origin"
className="h-[70vh] w-full rounded-lg"
/>
) : (
<Editor value={responseBody?.body} contentType={contentType} />
)}
</>
)}
</VStack>
</div>
</div> </div>
</> </>
); );

View File

@@ -1,12 +1,15 @@
import classnames from 'classnames'; import classnames from 'classnames';
import { ButtonHTMLAttributes, forwardRef } from 'react'; import { ButtonHTMLAttributes, forwardRef } from 'react';
import { Icon } from './Icon';
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
color?: 'primary' | 'secondary'; color?: 'primary' | 'secondary';
size?: 'sm' | 'md';
forDropdown?: boolean;
}; };
export const Button = forwardRef<HTMLButtonElement, Props>(function Button( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, color = 'primary', ...props }: Props, { className, children, size = 'md', forDropdown, color, ...props }: ButtonProps,
ref, ref,
) { ) {
return ( return (
@@ -14,11 +17,17 @@ export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
ref={ref} ref={ref}
className={classnames( className={classnames(
className, className,
'h-10 px-5 rounded-lg text-white', 'rounded-md text-white flex items-center',
{ 'bg-blue-500': color === 'primary' }, { 'h-10 px-4': size === 'md' },
{ 'bg-violet-500': color === 'secondary' }, { 'h-8 px-3': size === 'sm' },
{ 'hover:bg-gray-500/[0.1] active:bg-gray-500/[0.15]': color === undefined },
{ 'bg-blue-500 hover:bg-blue-500/90 active:bg-blue-500/80': color === 'primary' },
{ 'bg-violet-500 hover:bg-violet-500/90 active:bg-violet-500/80': color === 'secondary' },
)} )}
{...props} {...props}
/> >
{children}
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
</button>
); );
}); });

View File

@@ -14,7 +14,7 @@ import { HotKey } from './HotKey';
interface DropdownMenuRadioProps { interface DropdownMenuRadioProps {
children: ReactNode; children: ReactNode;
onValueChange: (value: string) => void; onValueChange: ((value: string) => void) | null;
value: string; value: string;
items: { items: {
label: string; label: string;
@@ -33,7 +33,7 @@ export function DropdownMenuRadio({
<DropdownMenuTrigger>{children}</DropdownMenuTrigger> <DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuRadioGroup onValueChange={onValueChange} value={value}> <DropdownMenuRadioGroup onValueChange={onValueChange ?? undefined} value={value}>
{items.map((item) => ( {items.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}> <DropdownMenuRadioItem key={item.value} value={item.value}>
{item.label} {item.label}

View File

@@ -1,6 +1,5 @@
.cm-editor { .cm-editor {
width: 100%; width: 100%;
height: calc(100vh - 270px);
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
} }
@@ -11,20 +10,26 @@
} }
.cm-editor .cm-line { .cm-editor .cm-line {
padding-left: 1.5em; padding-left: 1em;
padding-right: 1.5em; padding-right: 1.5em;
color: hsl(var(--color-gray-900)); color: hsl(var(--color-gray-900));
} }
.cm-editor .cm-gutters { .cm-editor .cm-gutters {
background-color: transparent; background-color: transparent;
border-right: 1px solid hsl(var(--color-gray-100)); border-right: 0;
color: hsl(var(--color-gray-300)); color: hsl(var(--color-gray-300));
} }
.cm-editor .cm-foldPlaceholder {
background-color: hsl(var(--color-gray-100));
border: 1px solid hsl(var(--color-gray-200));
padding: 0 0.3em;
}
.cm-editor .cm-activeLineGutter, .cm-editor .cm-activeLineGutter,
.cm-editor .cm-activeLine { .cm-editor .cm-activeLine {
background-color: hsl(var(--color-gray-100) / 0.5); background-color: hsl(var(--color-gray-50));
} }
.cm-editor * { .cm-editor * {
@@ -41,9 +46,9 @@
} }
.cm-editor .cm-selectionBackground { .cm-editor .cm-selectionBackground {
background-color: rgba(180, 180, 180, 0.3); background-color: hsl(var(--color-gray-100));
} }
.cm-editor.cm-focused .cm-selectionBackground { .cm-editor.cm-focused .cm-selectionBackground {
background-color: rgba(180, 180, 180, 0.3); background-color: hsl(var(--color-gray-100));
} }

View File

@@ -1,4 +1,4 @@
import useCodeMirror, { EditorLanguage } from '../../hooks/useCodemirror'; import useCodeMirror from '../../hooks/useCodemirror';
import './Editor.css'; import './Editor.css';
interface Props { interface Props {

View File

@@ -0,0 +1,34 @@
import { ComponentType } from 'react';
import {
ArchiveIcon,
CameraIcon,
ChevronDownIcon,
GearIcon,
HomeIcon,
TriangleDownIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
type IconName = 'archive' | 'home' | 'camera' | 'gear' | 'triangle-down';
const icons: Record<IconName, ComponentType> = {
archive: ArchiveIcon,
home: HomeIcon,
camera: CameraIcon,
gear: GearIcon,
'triangle-down': TriangleDownIcon,
};
export interface IconProps {
icon: IconName;
className?: string;
}
export function Icon({ icon, className }: IconProps) {
const Component = icons[icon];
return (
<div className={classnames(className, 'flex items-center')}>
<Component />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { forwardRef } from 'react';
import { Icon, IconProps } from './Icon';
import { Button, ButtonProps } from './Button';
type Props = ButtonProps & IconProps;
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, ...props }: Props,
ref,
) {
return (
<Button ref={ref} className="group" {...props}>
<Icon icon={icon} className="text-gray-700 group-hover:text-gray-900" />
</Button>
);
});

View File

@@ -12,7 +12,7 @@ interface Props extends InputHTMLAttributes<HTMLInputElement> {
export function Input({ label, labelClassName, hideLabel, className, name, ...props }: Props) { export function Input({ label, labelClassName, hideLabel, className, name, ...props }: Props) {
const id = `input-${name}`; const id = `input-${name}`;
return ( return (
<VStack> <VStack className="w-full">
<label <label
htmlFor={name} htmlFor={name}
className={classnames(labelClassName, 'font-semibold text-sm uppercase text-gray-700', { className={classnames(labelClassName, 'font-semibold text-sm uppercase text-gray-700', {
@@ -25,7 +25,7 @@ export function Input({ label, labelClassName, hideLabel, className, name, ...pr
id={id} id={id}
className={classnames( className={classnames(
className, className,
'border-2 border-gray-100 bg-gray-50 h-10 pl-5 pr-2 rounded-lg text-sm focus:outline-none', 'border-2 border-gray-100 bg-gray-50 h-10 pl-3 pr-2 rounded-md text-sm focus:outline-none',
)} )}
{...props} {...props}
/> />

View File

@@ -1,21 +1,30 @@
import React, { Children, Fragment, HTMLAttributes, ReactNode } from 'react'; import React, { Children, Fragment, HTMLAttributes, ReactNode } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
const spaceClasses = { const spaceClassesX = {
'0': 'pt-0', 0: 'pr-0',
'1': 'pt-1', 1: 'pr-1',
2: 'pr-2',
3: 'pr-3',
4: 'pr-4',
}; };
type Space = keyof typeof spaceClasses; const spaceClassesY = {
0: 'pt-0',
1: 'pt-1',
2: 'pt-2',
3: 'pt-3',
4: 'pt-4',
};
interface HStackProps extends BoxProps { interface HStackProps extends BaseStackProps {
space?: Space; space?: keyof typeof spaceClassesX;
children: ReactNode; children: ReactNode;
} }
export function Stacks({ className, space, children, ...props }: HStackProps) { export function HStack({ className, space, children, ...props }: HStackProps) {
return ( return (
<BaseStack className={classnames(className, 'flex-row')} {...props}> <BaseStack className={classnames(className, 'w-full flex-row')} {...props}>
{space {space
? Children.toArray(children) ? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children .filter(Boolean) // Remove null/false/undefined children
@@ -23,7 +32,7 @@ export function Stacks({ className, space, children, ...props }: HStackProps) {
<Fragment key={i}> <Fragment key={i}>
{i > 0 ? ( {i > 0 ? (
<div <div
className={classnames(className, spaceClasses[space], 'pointer-events-none')} className={classnames(spaceClassesX[space], 'pointer-events-none')}
aria-hidden aria-hidden
/> />
) : null} ) : null}
@@ -35,14 +44,14 @@ export function Stacks({ className, space, children, ...props }: HStackProps) {
); );
} }
export interface VStackProps extends BoxProps { export interface VStackProps extends BaseStackProps {
space?: Space; space?: keyof typeof spaceClassesY;
children: ReactNode; children: ReactNode;
} }
export function VStack({ className, space, children, ...props }: VStackProps) { export function VStack({ className, space, children, ...props }: VStackProps) {
return ( return (
<BaseStack className={classnames(className, 'flex-col')} {...props}> <BaseStack className={classnames(className, 'h-full flex-col')} {...props}>
{space {space
? Children.toArray(children) ? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children .filter(Boolean) // Remove null/false/undefined children
@@ -50,7 +59,7 @@ export function VStack({ className, space, children, ...props }: VStackProps) {
<Fragment key={i}> <Fragment key={i}>
{i > 0 ? ( {i > 0 ? (
<div <div
className={classnames(spaceClasses[space], 'pointer-events-none')} className={classnames(spaceClassesY[space], 'pointer-events-none')}
aria-hidden aria-hidden
/> />
) : null} ) : null}
@@ -62,11 +71,23 @@ export function VStack({ className, space, children, ...props }: VStackProps) {
); );
} }
interface BoxProps extends HTMLAttributes<HTMLElement> { interface BaseStackProps extends HTMLAttributes<HTMLElement> {
items?: 'start' | 'center';
justify?: 'start' | 'end';
as?: React.ElementType; as?: React.ElementType;
} }
function BaseStack({ className, as = 'div', ...props }: BoxProps) { function BaseStack({ className, items, justify, as = 'div', ...props }: BaseStackProps) {
const Component = as; const Component = as;
return <Component className={classnames(className, 'flex flex-grow-0')} {...props} />; return (
<Component
className={classnames(className, 'flex flex-grow-0', {
'items-center': items === 'center',
'items-start': items === 'start',
'justify-start': justify === 'start',
'justify-end': justify === 'end',
})}
{...props}
/>
);
} }

View File

@@ -0,0 +1,14 @@
import classnames from 'classnames';
import { HTMLAttributes } from 'react';
type Props = HTMLAttributes<HTMLDivElement>;
export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
className={classnames(className, 'w-full h-11 border-b border-gray-500/10')}
data-tauri-drag-region=""
{...props}
/>
);
}

View File

@@ -14,6 +14,13 @@
cursor: default; cursor: default;
} }
html, body, #root {
width: 100%;
height: 100%;
background-color: hsl(var(--color-background));
overflow: hidden;
}
@layer base { @layer base {
:root { :root {
/* Colors */ /* Colors */
@@ -42,6 +49,17 @@
--color-violet-800: 258 90% 20%; --color-violet-800: 258 90% 20%;
--color-violet-900: 258 90% 10%; --color-violet-900: 258 90% 10%;
--color-red-50: 0 84% 95%;
--color-red-100: 0 84% 88%;
--color-red-200: 0 84% 76%;
--color-red-300: 0 84% 70%;
--color-red-400: 0 84% 65%;
--color-red-500: 0 84% 58%;
--color-red-600: 0 84% 43%;
--color-red-700: 0 84% 30%;
--color-red-800: 0 84% 20%;
--color-red-900: 0 84% 10%;
--color-gray-50: 217 21% 95%; --color-gray-50: 217 21% 95%;
--color-gray-100: 217 21% 88%; --color-gray-100: 217 21% 88%;
--color-gray-200: 217 21% 76%; --color-gray-200: 217 21% 76%;

View File

@@ -20,6 +20,7 @@ module.exports = {
gray: color('gray'), gray: color('gray'),
blue: color('blue'), blue: color('blue'),
violet: color('violet'), violet: color('violet'),
red: color('red'),
} }
}, },
plugins: [], plugins: [],