diff --git a/README.md b/README.md index 8dedf3e..a6af09a 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,10 @@ The selected theme persists to local storage. ### Responsive Layouts +FigUI switches between **mobile**, **tablet**, and **desktop** from viewport width and pointer type (`auto`, or a forced layout in Settings). + +On **short landscape tablets** (visible height under 640px), the tablet UI stacks POSITION and JOG side by side and puts Viewer/Files/Macros in a full-width tab strip, with page scroll so jog targets stay usable. Taller tablets keep the two-column landscape layout. + Tablet Layout ![Tablet](docs/screenshots/figUI-tablet.png) diff --git a/src/App.tsx b/src/App.tsx index cf71613..85749a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,6 @@ import { Header } from './components/Header' import { DRO } from './components/DRO' import { TabletJogPad } from './components/JogPad' import { ProbeOrProgramPanel } from './components/ProgramExecutionPanel' -import { TabletAccordion } from './components/TabletAccordion' import { GCodeViewer } from './components/GCodeViewer' import { FileManager, prefetchInternalFiles } from './components/FileManager' @@ -29,6 +28,9 @@ import { getEffectiveLayout } from './types' import { PluginFrame } from './components/PluginFrame' import { DesktopLayout } from './components/DesktopLayout' import { ManualATCPrompt } from './components/ManualATCPrompt' +import { TabletMainShell } from './components/TabletMainShell' +import { ViewportProvider, useViewportMetrics } from './lib/viewport' +import type { TabletTabId } from './lib/tabletTabs' const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ { id: 'files', label: 'Files' }, @@ -37,22 +39,14 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ ] type MobilePanel = 'control' | 'viewer' | 'right' | 'terminal' -type TabletRightTab = 'viewer' | 'files' | 'macros' | 'terminal' type Phase = 'connecting' | 'error' | 'ready' function useActiveLayout(layoutMode: 'auto' | 'tablet' | 'desktop'): ActiveLayout { - const [width, setWidth] = useState(() => - typeof window === 'undefined' ? 1270 : window.innerWidth, - ) + const { innerWidth: width } = useViewportMetrics() const [isCoarsePointer, setIsCoarsePointer] = useState(() => typeof window === 'undefined' ? false : window.matchMedia('(pointer: coarse)').matches, ) - useEffect(() => { - const onResize = () => setWidth(window.innerWidth) - window.addEventListener('resize', onResize) - return () => window.removeEventListener('resize', onResize) - }, []) useEffect(() => { const mq = window.matchMedia('(pointer: coarse)') const onChange = (e: MediaQueryListEvent) => setIsCoarsePointer(e.matches) @@ -69,6 +63,14 @@ function useActiveLayout(layoutMode: 'auto' | 'tablet' | 'desktop'): ActiveLayou } export function App() { + return ( + + + + ) +} + +function AppContent() { const connected = useMachineStore(s => s.connected) const restarting = useMachineStore(s => s.restarting) const sidebarTab = useMachineStore(s => s.sidebarTab) @@ -84,6 +86,8 @@ export function App() { const spindleSpeed = useMachineStore(s => s.status.spindle) const spindleSpinupMs = useMachineStore(s => s.controllerSettings.spindleSpinupMs ?? 0) const activeLayout = useActiveLayout(layoutMode) + const { isCompactLandscape } = useViewportMetrics() + const compactLandscapeScroll = isCompactLandscape && activeLayout === 'tablet' const loadGCodeFile = useGCodeStore(s => s.loadFile) const senderPhase = useGCodeSenderStore(s => s.phase) const senderFileName = useGCodeSenderStore(s => s.fileName) @@ -103,7 +107,7 @@ export function App() { }, []) const [aboutOpen, setAboutOpen] = useState(false) const [mobilePanel, setMobilePanel] = useState('control') - const [tabletTab, setTabletTab] = useState('viewer') + const [tabletTab, setTabletTab] = useState('viewer') const [workspacePlugin, setWorkspacePlugin] = useState(null) const [controlsPlugin, setControlsPlugin] = useState(null) const [fullPlugin, setFullPlugin] = useState(null) @@ -539,7 +543,11 @@ export function App() { ) : null return ( -
+
{restarting ? (
@@ -560,6 +568,7 @@ export function App() {
setSettingsOpen(true)} onAboutClick={() => setAboutOpen(true)} + sticky={compactLandscapeScroll} /> @@ -636,47 +645,18 @@ export function App() { } - {!fullPlugin && activeLayout === 'tablet' && !workspacePlugin && !controlsPlugin && ( -
-
- - {jogPlugin ? ( -
- setJogPlugin(null)} inline /> -
- ) : ( - - )} -
- -
- )} - - {!fullPlugin && activeLayout === 'tablet' && workspacePlugin && ( -
-
- - {jogPlugin ? ( -
- setJogPlugin(null)} inline /> -
- ) : ( - - )} -
-
- setWorkspacePlugin(null)} inline /> -
-
- )} - - {!fullPlugin && activeLayout === 'tablet' && controlsPlugin && ( -
-
- setControlsPlugin(null)} inline /> -
- -
+ {!fullPlugin && activeLayout === 'tablet' && ( + setJogPlugin(null)} + workspacePlugin={workspacePlugin} + onCloseWorkspacePlugin={() => setWorkspacePlugin(null)} + controlsPlugin={controlsPlugin} + onCloseControlsPlugin={() => setControlsPlugin(null)} + /> )} {!fullPlugin && activeLayout === 'desktop' && ( diff --git a/src/components/DRO.tsx b/src/components/DRO.tsx index 4192fa2..762100b 100644 --- a/src/components/DRO.tsx +++ b/src/components/DRO.tsx @@ -7,6 +7,7 @@ import { clearMachineAlarm } from '../lib/alarm' import { droFeedUnitLabel, formatAxisCoord, formatFeedRate } from '../lib/units' import { useControllerJobStarting } from '../lib/jobState' import { useManualAtcStore } from '../store/manualAtc' +import { useIsPortrait } from '../lib/viewport' const ALARM_MESSAGES: Record = { 1: 'Hard limit triggered', @@ -50,19 +51,6 @@ const E_STOP_HIDE_DELAY_MS = 700 const HOME_ALL_ACTION_AXIS = 'all' const WORK_ORIGINS = ['G54', 'G55', 'G56', 'G57', 'G58', 'G59'] as const -function useIsPortrait() { - const [portrait, setPortrait] = useState(() => - typeof window !== 'undefined' && window.matchMedia('(orientation: portrait)').matches - ) - useEffect(() => { - const mq = window.matchMedia('(orientation: portrait)') - const handler = (e: MediaQueryListEvent) => setPortrait(e.matches) - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) - }, []) - return portrait -} - function useMotionControlLock(jobActive: boolean) { const [locked, setLocked] = useState(jobActive) @@ -79,7 +67,14 @@ function useMotionControlLock(jobActive: boolean) { return locked } -export function DRO({ isTablet = false }: { isTablet?: boolean }) { +export function DRO({ + isTablet = false, + layout = 'default', +}: { + isTablet?: boolean + /** Fit beside Jog in short-height landscape without internal scroll. */ + layout?: 'default' | 'topBand' +}) { const status = useMachineStore(s => s.status) const controllerResetPending = useMachineStore(s => s.controllerResetPending) const controllerJobStarting = useControllerJobStarting() @@ -96,9 +91,47 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) { const workOriginRef = useRef(null) const pos = activePosition(status, positionMode) const isPortrait = useIsPortrait() - const tabletBtnSize = isTablet && isPortrait ? 'w-20 h-20' : isTablet ? 'w-14 h-14' : 'w-8 h-8' - const tabletIconSize = isTablet && isPortrait ? 22 : isTablet ? 16 : 11 - const tabletHomeIconSize = isTablet && isPortrait ? 30 : isTablet ? 22 : 13 + const topBandLayout = layout === 'topBand' + const tightLayout = topBandLayout + const tabletBtnSize = isTablet && isPortrait + ? 'w-20 h-20' + : topBandLayout + ? 'w-12 h-12' + : isTablet + ? 'w-14 h-14' + : 'w-8 h-8' + const tabletIconSize = isTablet && isPortrait ? 22 : topBandLayout ? 14 : isTablet ? 16 : 11 + const tabletHomeIconSize = isTablet && isPortrait ? 30 : topBandLayout ? 18 : isTablet ? 22 : 13 + const tabletCoordBothSize = topBandLayout + ? 'text-[1.45rem]' + : isTablet + ? 'text-[2.25rem]' + : 'text-[1.05rem]' + const tabletCoordSingleSize = topBandLayout + ? 'text-[1.75rem]' + : isTablet + ? 'text-[3rem]' + : 'text-[1.75rem]' + const tabletAxisLabelSize = topBandLayout ? 'text-xl' : isTablet ? 'text-2xl' : 'text-base' + const tabletActionBtnClass = isTablet && isPortrait + ? 'h-20 text-xl' + : topBandLayout + ? 'h-11 text-base' + : isTablet + ? 'h-14 text-lg' + : 'h-7 text-base' + const tabletStopBtnClass = isTablet && isPortrait + ? 'h-20 text-xl' + : topBandLayout + ? 'h-11 text-sm' + : isTablet + ? 'h-14 text-lg' + : 'h-7 text-sm' + const tabletFooterTextSize = topBandLayout + ? 'text-sm' + : isTablet + ? 'text-xl' + : 'text-base' const activeWorkOrigin = WORK_ORIGINS.includes(status.gcodeModes?.wcs as (typeof WORK_ORIGINS)[number]) ? status.gcodeModes?.wcs as (typeof WORK_ORIGINS)[number] : null @@ -204,10 +237,10 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) { } return ( -
-
- Position -
+
+
+ Position +
{(['WPos', 'MPos'] as const).map(m => { const active = positionMode === m || positionMode === 'Both' @@ -223,7 +256,7 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) { setPositionMode(positionMode === 'Both' ? 'WPos' : 'Both') } }} - className={`px-2.5 py-0.5 text-base rounded-sm transition-colors ${active + className={`${tightLayout ? 'px-1.5 py-0.5 text-sm' : 'px-2.5 py-0.5 text-base'} rounded-sm transition-colors ${active ? 'bg-surface border border-border text-text-primary shadow-sm' : 'text-text-muted hover:text-text-primary' }`} @@ -236,7 +269,7 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) {
{/* Axis rows – compact */} -
+
{positionMode === 'Both' && (
@@ -298,9 +331,9 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) {
)} {visibleAxes.map(ax => ( -
+
{ax} @@ -308,13 +341,13 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) { {positionMode === 'Both' ? (
{formatAxisCoord(wCoords[ax], ax, units)} {formatAxisCoord(mCoords[ax], ax, units)} @@ -322,7 +355,7 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) {
) : ( {formatAxisCoord(coordValues[ax], ax, units)} @@ -401,10 +434,10 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) {
{/* Action buttons */} -
+
{!shouldHideMotionControls && !isHomeAllPending && ( )} {!shouldHideMotionControls && isHomeAllPending && ( )} @@ -474,7 +507,9 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) { )} {/* Feed / Spindle readout */} -
+
F {formatFeedRate(status.feed, units)} @@ -487,7 +522,7 @@ export function DRO({ isTablet = false }: { isTablet?: boolean }) {
- + {!topBandLayout && }
) } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0753977..d129740 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -19,9 +19,10 @@ interface Props { onSettingsClick: () => void onAboutClick: () => void isTablet?: boolean + sticky?: boolean } -export function Header({ onSettingsClick, onAboutClick, isTablet }: Props) { +export function Header({ onSettingsClick, onAboutClick, isTablet, sticky }: Props) { const connected = useMachineStore(s => s.connected) const status = useMachineStore(s => s.status) const controllerResetPending = useMachineStore(s => s.controllerResetPending) @@ -59,7 +60,7 @@ export function Header({ onSettingsClick, onAboutClick, isTablet }: Props) { } return ( -
+
FluidNC
diff --git a/src/components/JogPad.tsx b/src/components/JogPad.tsx index 2b51f8e..d70ed9e 100644 --- a/src/components/JogPad.tsx +++ b/src/components/JogPad.tsx @@ -60,6 +60,18 @@ function alwaysCapturePointer(e: React.PointerEvent) { e.currentTarget.setPointerCapture(e.pointerId) } +function tabletJogPointerHandlers(start: () => void, stop: () => void) { + return { + onPointerDown: (e: React.PointerEvent) => { + alwaysCapturePointer(e) + start() + }, + onPointerUp: stop, + onPointerCancel: stop, + onLostPointerCapture: stop, + } +} + /** * Touch screens raise `contextmenu` on a long press, so holding a continuous * jog control pops the browser menu on release. Spread this onto a jog @@ -1285,12 +1297,19 @@ export function OverridesPanel({ className, isTablet }: { className?: string; is ) } -export function TabletJogPad({ onSwitchStyle }: { onSwitchStyle?: () => void } = {}) { +export function TabletJogPad({ + onSwitchStyle, + layout = 'default', +}: { + onSwitchStyle?: () => void + layout?: 'default' | 'topBand' +} = {}) { const status = useMachineStore(s => s.status) const controllerResetPending = useMachineStore(s => s.controllerResetPending) const controllerJobStarting = useControllerJobStarting() const units = useMachineStore(s => s.units) const controllerSettings = useMachineStore(s => s.controllerSettings) + const topBand = layout === 'topBand' const [xyFeed, setXyFeed] = useState(() => loadPersistedJogFeed('jog.xyFeed', 1000)) const [zFeed, setZFeed] = useState(() => loadPersistedJogFeed('jog.zFeed', 200)) @@ -1344,6 +1363,24 @@ export function TabletJogPad({ onSwitchStyle }: { onSwitchStyle?: () => void } = const { start: startZm, stop: stopZm } = useHoldJog('Z', -1, zFeed, commandStepSize, continuous, jogDisabled) const steps = units === 'in' ? [0.001, 0.01, 0.1, 1] : [0.1, 1, 10, 100] + const stepBtnClass = topBand + ? 'flex-1 px-1 py-2 font-bold text-sm transition-colors' + : 'flex-1 px-2 sm:px-4 portrait:px-4 portrait:py-4 max-sm:portrait:py-2 font-bold text-base sm:text-lg portrait:text-xl max-sm:portrait:text-base transition-colors' + const jogBtnClass = topBand + ? 'flex items-center justify-center w-full h-full bg-elevated border border-border rounded-xl font-bold shadow-md active:scale-95 active:shadow-inner transition-transform text-xl' + : 'flex items-center justify-center w-full h-full bg-elevated border border-border rounded-xl font-bold shadow-md active:scale-95 active:shadow-inner transition-transform text-xl sm:text-3xl portrait:text-3xl max-sm:portrait:text-2xl' + const jogGridClass = topBand + ? 'grid grid-cols-3 grid-rows-3 gap-1.5 aspect-square h-full max-h-full w-auto shrink-0' + : 'grid grid-cols-3 grid-rows-3 gap-1.5 sm:gap-4 portrait:gap-4 max-sm:portrait:gap-2 h-full max-h-full aspect-square max-w-[min(100%,calc(100%-4rem))]' + const zColClass = topBand + ? 'flex flex-col gap-1.5 h-full max-h-full justify-between w-[3.75rem] shrink-0' + : 'flex flex-col gap-1.5 sm:gap-4 portrait:gap-4 max-sm:portrait:gap-2 h-full max-h-full justify-between aspect-[1/3] max-w-[28%]' + const jogAreaPadding = topBand ? 'p-1.5' : 'p-2 sm:p-4 portrait:p-5 landscape:p-6 max-sm:portrait:p-2' + const jogPadRootClass = onSwitchStyle + ? 'flex-none h-[440px]' + : topBand + ? 'h-full min-h-0' + : 'flex-1 min-h-0' useEffect(() => { if (!steps.includes(stepSize)) setStepSize(steps[1]) @@ -1370,9 +1407,9 @@ export function TabletJogPad({ onSwitchStyle }: { onSwitchStyle?: () => void } = return ( <> -
+
-
+
JOG {onSwitchStyle && ( ))}
+
-
+
-
+
{/* Jog controls */} -
+
{onSwitchStyle && continuous && ( )} -
-
+
+
-
+
-
+ {topBand &&
}
@@ -1527,7 +1559,7 @@ export function TabletJogPad({ onSwitchStyle }: { onSwitchStyle?: () => void } = onClick={() => setFeedModal(null)} >
e.stopPropagation()} >
diff --git a/src/components/TabletAccordion.tsx b/src/components/TabletAccordion.tsx index 1c72502..78c8037 100644 --- a/src/components/TabletAccordion.tsx +++ b/src/components/TabletAccordion.tsx @@ -1,27 +1,41 @@ -import { useEffect, useState } from 'react' -import { ChevronDown, ChevronRight, Eye, FolderOpen, Puzzle, Sliders, Target, TerminalSquare, Wrench, Zap } from '../icons' -import { Power } from '../icons' +import { useEffect, useMemo, useState } from 'react' +import { ChevronDown, ChevronRight } from '../icons' +import { ProgramExecutionPanel } from './ProgramExecutionPanel' +import { TabletTabbedPanel } from './TabletTabbedPanel' import { GCodeViewer } from './GCodeViewer' import { FileManager } from './FileManager' import { Macros } from './Macros' import { ProbePanel } from './ProbePanel' import { ManualATCPanel } from './ManualATCPanel' import { Terminal } from './Terminal' -import { ProgramExecutionPanel } from './ProgramExecutionPanel' import { OverridesPanel, SpindlePanel } from './JogPad' import { PluginLauncher } from './PluginLauncher' import type { Plugin } from '../types' import { useMachineStore } from '../store' +import { + buildFullTabletTabs, + buildLandscapeAccordionTabs, + type TabletTabId, +} from '../lib/tabletTabs' +import { COMPACT_LANDSCAPE_VIEWER_CLASS } from '../lib/compactLandscapeLayout' +import { useIsPortrait } from '../lib/viewport' interface TabletAccordionProps { - tabletTab: string - setTabletTab: (s: any) => void + tabletTab: TabletTabId + setTabletTab: (tab: TabletTabId) => void onLaunchPanel?: (plugin: Plugin) => void + /** Full-width tab strip below Position/Jog (compact landscape category). */ + variant?: 'default' | 'stacked' } -export function TabletAccordion({ tabletTab, setTabletTab, onLaunchPanel }: TabletAccordionProps) { +export function TabletAccordion({ + tabletTab, + setTabletTab, + onLaunchPanel, + variant = 'default', +}: TabletAccordionProps) { const [expanded, setExpanded] = useState<'visualizer' | 'program' | 'controls'>('visualizer') - const [portraitTab, setPortraitTab] = useState('viewer') + const isPortrait = useIsPortrait() const spindleMax = useMachineStore(s => s.controllerSettings.spindleMax) const hasSpindle = Boolean(spindleMax) const reportedHasProbe = useMachineStore(s => s.controllerSettings.hasProbe) @@ -32,115 +46,96 @@ export function TabletAccordion({ tabletTab, setTabletTab, onLaunchPanel }: Tabl const isProgramRunning = (status.state === 'Run' || status.state === 'Hold') && (!!status.sdFilename || status.plannerLineNumber != null) + const landscapeTabs = useMemo( + () => buildLandscapeAccordionTabs(hasProbingInput, hasManualATC), + [hasProbingInput, hasManualATC], + ) + const fullTabs = useMemo( + () => buildFullTabletTabs(hasProbingInput, hasSpindle, hasManualATC), + [hasProbingInput, hasSpindle, hasManualATC], + ) + useEffect(() => { if (!isProgramRunning && expanded === 'program') setExpanded('visualizer') - if (!hasProbingInput && portraitTab === 'probing') setPortraitTab('viewer') if (!hasProbingInput && tabletTab === 'probing') setTabletTab('viewer') - if (!hasManualATC && portraitTab === 'tooling') setPortraitTab('viewer') if (!hasManualATC && tabletTab === 'tooling') setTabletTab('viewer') - }, [isProgramRunning, expanded, hasProbingInput, hasManualATC, portraitTab, tabletTab, setTabletTab]) - - const TABS = [ - { id: 'viewer', label: 'Viewer', Icon: Eye }, - { id: 'files', label: 'Files', Icon: FolderOpen }, - { id: 'macros', label: 'Macros', Icon: Zap }, - ...(hasManualATC ? [{ id: 'tooling', label: 'Tooling', Icon: Wrench }] : []), - ...(hasProbingInput ? [{ id: 'probing', label: 'Probing', Icon: Target }] : []), - { id: 'terminal', label: 'Terminal', Icon: TerminalSquare }, - { id: 'plugins', label: 'Plugins', Icon: Puzzle }, - ] - const PORTRAIT_TABS = [ - { id: 'viewer', label: 'Viewer', Icon: Eye }, - { id: 'files', label: 'Files', Icon: FolderOpen }, - { id: 'macros', label: 'Macros', Icon: Zap }, - ...(hasManualATC ? [{ id: 'tooling', label: 'Tooling', Icon: Wrench }] : []), - ...(hasProbingInput ? [{ id: 'probing', label: 'Probing', Icon: Target }] : []), - { id: 'terminal', label: 'Terminal', Icon: TerminalSquare }, - ...(hasSpindle ? [{ id: 'spindle', label: 'Spindle', Icon: Power }] : []), - { id: 'overrides', label: 'Overrides', Icon: Sliders }, - { id: 'plugins', label: 'Plugins', Icon: Puzzle }, - ] + const activeTabs = variant === 'stacked' || isPortrait ? fullTabs : landscapeTabs + if (!activeTabs.some(t => t.id === tabletTab)) setTabletTab('viewer') + }, [ + isProgramRunning, + expanded, + hasProbingInput, + hasManualATC, + tabletTab, + setTabletTab, + variant, + isPortrait, + fullTabs, + landscapeTabs, + ]) + + if (variant === 'stacked') { + return ( +
+ {isProgramRunning && } + +
+ ) + } return (
- {/* ═══ PORTRAIT LAYOUT: tabbed panel → Viewer at bottom ═══ */}
- {isProgramRunning && } - - {/* All-in-one tab panel */} -
-
- {PORTRAIT_TABS.map(({ id, label, Icon }) => ( - - ))} -
-
- {portraitTab === 'viewer' && ( -
- -
- )} - {portraitTab === 'files' && } - {portraitTab === 'macros' && } - {portraitTab === 'tooling' && hasManualATC &&
} - {portraitTab === 'probing' && hasProbingInput &&
} - {portraitTab === 'terminal' && } - {portraitTab === 'spindle' && hasSpindle && ( -
- -
- )} - {portraitTab === 'overrides' && ( -
- -
- )} - {portraitTab === 'plugins' && } -
-
- +
- {/* ═══ LANDSCAPE LAYOUT: accordion unchanged ═══ */}
- {/* Visualizer / tabs panel */}
{expanded !== 'visualizer' && ( )} {expanded === 'visualizer' && (
- {TABS.map(({ id, label, Icon }) => ( + {landscapeTabs.map(({ id, label, Icon }) => (
)} @@ -190,7 +191,6 @@ export function TabletAccordion({ tabletTab, setTabletTab, onLaunchPanel }: Tabl
)} - {/* Controls (Spindle & Overrides) panel */}
{expanded !== 'controls' && (
- {hasSpindle && <> - -
- } + {hasSpindle && ( + <> + +
+ + )}
diff --git a/src/components/TabletCompactLandscapeLayout.tsx b/src/components/TabletCompactLandscapeLayout.tsx new file mode 100644 index 0000000..74c3c76 --- /dev/null +++ b/src/components/TabletCompactLandscapeLayout.tsx @@ -0,0 +1,85 @@ +import { DRO } from './DRO' +import { TabletJogPad } from './JogPad' +import { TabletAccordion } from './TabletAccordion' +import { PluginFrame } from './PluginFrame' +import type { Plugin } from '../types' +import type { TabletTabId } from '../lib/tabletTabs' +import { + COMPACT_LANDSCAPE_PLUGIN_MIN_HEIGHT, + useCompactLandscapeTopRowHeight, +} from '../lib/compactLandscapeLayout' + +interface TabletCompactLandscapeLayoutProps { + tabletTab: TabletTabId + setTabletTab: (tab: TabletTabId) => void + onLaunchPanel?: (plugin: Plugin) => void + jogPlugin: Plugin | null + onCloseJogPlugin: () => void + workspacePlugin?: Plugin | null + onCloseWorkspacePlugin?: () => void + controlsPlugin?: Plugin | null + onCloseControlsPlugin?: () => void +} + +const TOP_ROW_COLUMN_CLASS = + 'flex flex-col flex-1 min-w-0 basis-1/2 h-full min-h-0 overflow-hidden' + +/** + * Short-viewport landscape tablets (layout height < 640px): + * Row 1 — POSITION | JOG (side by side, equal height) + * Row 2 — tabbed workspace (natural height; page scrolls) + */ +export function TabletCompactLandscapeLayout({ + tabletTab, + setTabletTab, + onLaunchPanel, + jogPlugin, + onCloseJogPlugin, + workspacePlugin, + onCloseWorkspacePlugin, + controlsPlugin, + onCloseControlsPlugin, +}: TabletCompactLandscapeLayoutProps) { + const topRowHeight = useCompactLandscapeTopRowHeight() + + return ( +
+
+
+ +
+
+ {jogPlugin ? ( +
+ +
+ ) : ( + + )} +
+
+ +
+ {workspacePlugin && onCloseWorkspacePlugin ? ( +
+ +
+ ) : controlsPlugin && onCloseControlsPlugin ? ( +
+ +
+ ) : ( + + )} +
+
+ ) +} diff --git a/src/components/TabletMainShell.tsx b/src/components/TabletMainShell.tsx new file mode 100644 index 0000000..97cd4cb --- /dev/null +++ b/src/components/TabletMainShell.tsx @@ -0,0 +1,112 @@ +import { DRO } from './DRO' +import { TabletJogPad } from './JogPad' +import { TabletAccordion } from './TabletAccordion' +import { TabletCompactLandscapeLayout } from './TabletCompactLandscapeLayout' +import { PluginFrame } from './PluginFrame' +import type { Plugin } from '../types' +import type { TabletTabId } from '../lib/tabletTabs' +import { useIsCompactLandscape } from '../lib/viewport' + +interface TabletMainShellProps { + tabletTab: TabletTabId + setTabletTab: (tab: TabletTabId) => void + onLaunchPanel?: (plugin: Plugin) => void + jogPlugin: Plugin | null + onCloseJogPlugin: () => void + workspacePlugin: Plugin | null + onCloseWorkspacePlugin: () => void + controlsPlugin: Plugin | null + onCloseControlsPlugin: () => void +} + +const TABLET_LEFT_COLUMN_CLASS = + 'flex flex-col gap-1 portrait:shrink-0 landscape:flex-1 landscape:basis-1/2 landscape:min-h-0 landscape:overflow-hidden' + +function TabletLeftColumn({ + jogPlugin, + onCloseJogPlugin, +}: { + jogPlugin: Plugin | null + onCloseJogPlugin: () => void +}) { + return ( +
+
+ +
+ {jogPlugin ? ( +
+ +
+ ) : ( + + )} +
+ ) +} + +export function TabletMainShell({ + tabletTab, + setTabletTab, + onLaunchPanel, + jogPlugin, + onCloseJogPlugin, + workspacePlugin, + onCloseWorkspacePlugin, + controlsPlugin, + onCloseControlsPlugin, +}: TabletMainShellProps) { + const isCompactLandscape = useIsCompactLandscape() + + if (isCompactLandscape) { + return ( + + ) + } + + const shellClass = + 'flex-1 min-h-[0px] flex portrait:flex-col landscape:flex landscape:flex-row gap-3 p-3 overflow-y-auto landscape:overflow-hidden' + + if (workspacePlugin) { + return ( +
+ +
+ +
+
+ ) + } + + if (controlsPlugin) { + return ( +
+
+ +
+ +
+ ) + } + + return ( +
+ + +
+ ) +} diff --git a/src/components/TabletTabbedPanel.tsx b/src/components/TabletTabbedPanel.tsx new file mode 100644 index 0000000..97a3e8a --- /dev/null +++ b/src/components/TabletTabbedPanel.tsx @@ -0,0 +1,95 @@ +import { GCodeViewer } from './GCodeViewer' +import { FileManager } from './FileManager' +import { Macros } from './Macros' +import { ProbePanel } from './ProbePanel' +import { ManualATCPanel } from './ManualATCPanel' +import { Terminal } from './Terminal' +import { OverridesPanel, SpindlePanel } from './JogPad' +import { PluginLauncher } from './PluginLauncher' +import type { Plugin } from '../types' +import type { TabletTabDef, TabletTabId } from '../lib/tabletTabs' + +interface TabletTabbedPanelProps { + tabs: TabletTabDef[] + activeTab: TabletTabId + onTabChange: (tab: TabletTabId) => void + onLaunchPanel?: (plugin: Plugin) => void + hasProbingInput: boolean + hasSpindle: boolean + hasManualATC?: boolean + portraitMinHeight?: boolean + tabLabelFontSize?: string + viewerClassName?: string + fitToViewSignal?: boolean +} + +export function TabletTabbedPanel({ + tabs, + activeTab, + onTabChange, + onLaunchPanel, + hasProbingInput, + hasSpindle, + hasManualATC = false, + portraitMinHeight = false, + tabLabelFontSize = 'clamp(10px, 2.2vw, 20px)', + viewerClassName, + fitToViewSignal, +}: TabletTabbedPanelProps) { + const viewerClass = viewerClassName ?? (portraitMinHeight ? 'min-h-[55vh]' : 'flex-1 min-h-[300px]') + + return ( +
+
+ {tabs.map(({ id, label, Icon }) => ( + + ))} +
+
+ {activeTab === 'viewer' && ( +
+ +
+ )} + {activeTab === 'files' && } + {activeTab === 'macros' && } + {activeTab === 'tooling' && hasManualATC && ( +
+ +
+ )} + {activeTab === 'probing' && hasProbingInput && ( +
+ +
+ )} + {activeTab === 'terminal' && } + {activeTab === 'spindle' && hasSpindle && ( +
+ +
+ )} + {activeTab === 'overrides' && ( +
+ +
+ )} + {activeTab === 'plugins' && ( + + )} +
+
+ ) +} diff --git a/src/lib/compactLandscapeLayout.ts b/src/lib/compactLandscapeLayout.ts new file mode 100644 index 0000000..2f91b13 --- /dev/null +++ b/src/lib/compactLandscapeLayout.ts @@ -0,0 +1,32 @@ +import { useViewportMetrics, type ViewportMetrics } from './viewport' + +/** Share of layout viewport height for the Position + Jog band. */ +export const COMPACT_LANDSCAPE_TOP_ROW_RATIO = 0.55 + +export const COMPACT_LANDSCAPE_TOP_ROW_MIN_PX = 228 + +export const COMPACT_LANDSCAPE_TOP_ROW_MAX_PX = 360 + +/** Stacked G-code viewer; dvh accounts for browser chrome. */ +export const COMPACT_LANDSCAPE_VIEWER_CLASS = 'h-[min(62dvh,28rem)]' + +/** Min height for inline plugin panels below the top row. */ +export const COMPACT_LANDSCAPE_PLUGIN_MIN_HEIGHT = 'min(50dvh, 24rem)' + +export function layoutViewportHeight(metrics: ViewportMetrics): number { + return metrics.visualViewportHeight ?? metrics.innerHeight +} + +export function compactLandscapeTopRowHeightPx(metrics: ViewportMetrics): number { + const h = layoutViewportHeight(metrics) + return Math.min( + Math.max(Math.round(h * COMPACT_LANDSCAPE_TOP_ROW_RATIO), COMPACT_LANDSCAPE_TOP_ROW_MIN_PX), + COMPACT_LANDSCAPE_TOP_ROW_MAX_PX, + ) +} + +export function useCompactLandscapeTopRowHeight(): number | undefined { + const metrics = useViewportMetrics() + if (!metrics.isCompactLandscape) return undefined + return compactLandscapeTopRowHeightPx(metrics) +} diff --git a/src/lib/tabletTabs.ts b/src/lib/tabletTabs.ts new file mode 100644 index 0000000..dc207e3 --- /dev/null +++ b/src/lib/tabletTabs.ts @@ -0,0 +1,52 @@ +import { Eye, FolderOpen, Puzzle, Sliders, Target, TerminalSquare, Wrench, Zap } from '../icons' +import { Power } from '../icons' + +export type TabletTabId = + | 'viewer' + | 'files' + | 'macros' + | 'tooling' + | 'probing' + | 'terminal' + | 'spindle' + | 'overrides' + | 'plugins' + +export interface TabletTabDef { + id: TabletTabId + label: string + Icon: typeof Eye +} + +export function buildLandscapeAccordionTabs( + hasProbingInput: boolean, + hasManualATC = false, +): TabletTabDef[] { + return [ + { id: 'viewer', label: 'Viewer', Icon: Eye }, + { id: 'files', label: 'Files', Icon: FolderOpen }, + { id: 'macros', label: 'Macros', Icon: Zap }, + ...(hasManualATC ? [{ id: 'tooling' as const, label: 'Tooling', Icon: Wrench }] : []), + ...(hasProbingInput ? [{ id: 'probing' as const, label: 'Probing', Icon: Target }] : []), + { id: 'terminal', label: 'Terminal', Icon: TerminalSquare }, + { id: 'plugins', label: 'Plugins', Icon: Puzzle }, + ] +} + +export function buildFullTabletTabs( + hasProbingInput: boolean, + hasSpindle: boolean, + hasManualATC = false, +): TabletTabDef[] { + return [ + { id: 'viewer', label: 'Viewer', Icon: Eye }, + { id: 'files', label: 'Files', Icon: FolderOpen }, + { id: 'macros', label: 'Macros', Icon: Zap }, + ...(hasManualATC ? [{ id: 'tooling' as const, label: 'Tooling', Icon: Wrench }] : []), + ...(hasProbingInput ? [{ id: 'probing' as const, label: 'Probing', Icon: Target }] : []), + { id: 'terminal', label: 'Terminal', Icon: TerminalSquare }, + ...(hasSpindle ? [{ id: 'spindle' as const, label: 'Spindle', Icon: Power }] : []), + { id: 'overrides', label: 'Overrides', Icon: Sliders }, + { id: 'plugins', label: 'Plugins', Icon: Puzzle }, + ] +} diff --git a/src/lib/viewport.tsx b/src/lib/viewport.tsx new file mode 100644 index 0000000..3352808 --- /dev/null +++ b/src/lib/viewport.tsx @@ -0,0 +1,100 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +/** Short landscape viewports (7–8" tablets, browser chrome, split-screen). */ +export const COMPACT_LANDSCAPE_MAX_HEIGHT = 640 + +export interface ViewportMetrics { + innerWidth: number + innerHeight: number + visualViewportWidth: number | null + visualViewportHeight: number | null + devicePixelRatio: number + isPortrait: boolean + isLandscape: boolean + isCompactLandscape: boolean +} + +function readViewportMetrics(): ViewportMetrics { + const innerWidth = typeof window === 'undefined' ? 1270 : window.innerWidth + const innerHeight = typeof window === 'undefined' ? 800 : window.innerHeight + const vv = typeof window === 'undefined' ? null : window.visualViewport + const layoutHeight = vv?.height ?? innerHeight + const isPortrait = typeof window === 'undefined' + ? false + : window.matchMedia('(orientation: portrait)').matches + const isLandscape = !isPortrait + + return { + innerWidth, + innerHeight, + visualViewportWidth: vv?.width ?? null, + visualViewportHeight: vv?.height ?? null, + devicePixelRatio: typeof window === 'undefined' ? 1 : window.devicePixelRatio, + isPortrait, + isLandscape, + isCompactLandscape: isLandscape && layoutHeight < COMPACT_LANDSCAPE_MAX_HEIGHT, + } +} + +let pending = false +const listeners = new Set<() => void>() + +function scheduleViewportUpdate() { + if (pending || typeof window === 'undefined') return + pending = true + requestAnimationFrame(() => { + pending = false + listeners.forEach(listener => listener()) + }) +} + +function subscribeViewport(listener: () => void) { + listeners.add(listener) + if (listeners.size === 1 && typeof window !== 'undefined') { + window.addEventListener('resize', scheduleViewportUpdate) + window.addEventListener('orientationchange', scheduleViewportUpdate) + window.visualViewport?.addEventListener('resize', scheduleViewportUpdate) + } + return () => { + listeners.delete(listener) + if (listeners.size === 0 && typeof window !== 'undefined') { + window.removeEventListener('resize', scheduleViewportUpdate) + window.removeEventListener('orientationchange', scheduleViewportUpdate) + window.visualViewport?.removeEventListener('resize', scheduleViewportUpdate) + } + } +} + +const ViewportContext = createContext(null) + +export function ViewportProvider({ children }: { children: ReactNode }) { + const [metrics, setMetrics] = useState(readViewportMetrics) + + useEffect(() => { + const update = () => setMetrics(readViewportMetrics()) + update() + return subscribeViewport(update) + }, []) + + return ( + + {children} + + ) +} + +export function useViewportMetrics(): ViewportMetrics { + const ctx = useContext(ViewportContext) + if (!ctx) { + throw new Error('useViewportMetrics must be used within ViewportProvider') + } + return ctx +} + +export function useIsPortrait(): boolean { + return useViewportMetrics().isPortrait +} + +export function useIsCompactLandscape(): boolean { + return useViewportMetrics().isCompactLandscape +}