Browse Source

Add on-screen virtual keyboard for SpoolBuddy kiosk UI

  The Raspberry Pi kiosk has no physical keyboard and system-level virtual
  keyboards (squeekboard, wvkbd) don't auto-show/hide with labwc/Chromium.
  Add a react-simple-keyboard QWERTY keyboard that auto-shows on input
  focus, with dark theme, shift/caps/backspace, email keys (@, .), and a
  two-phase close that prevents ghost-click passthrough to elements below.
  Inputs with data-vkb="false" opt out (e.g. SpoolBuddySettingsPage numpad).
maziggy 2 months ago
parent
commit
bffbac54e4

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 
 ### New Features
+- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
 - **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and a compact printers list with live status indicators; right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card.

+ 10 - 0
frontend/package-lock.json

@@ -32,6 +32,7 @@
         "react-dom": "^19.2.0",
         "react-i18next": "^16.3.5",
         "react-router-dom": "^7.12.0",
+        "react-simple-keyboard": "^3.8.164",
         "recharts": "^3.5.1",
         "three": "^0.181.2"
       },
@@ -6856,6 +6857,15 @@
         "react-dom": ">=18"
       }
     },
+    "node_modules/react-simple-keyboard": {
+      "version": "3.8.164",
+      "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.164.tgz",
+      "integrity": "sha512-VwmLyclUizzkpRy/2DeLZRtzjR3K6MLWDVV98492DC5a0ZUHt9JK0R27ZXbcn51OA80U84XTZsUZlU8iYXkgxQ==",
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",

+ 1 - 0
frontend/package.json

@@ -38,6 +38,7 @@
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
     "react-router-dom": "^7.12.0",
+    "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
     "three": "^0.181.2"
   },

+ 2 - 0
frontend/src/App.tsx

@@ -28,6 +28,7 @@ import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
+import { VirtualKeyboard } from './components/VirtualKeyboard';
 
 const queryClient = new QueryClient({
   defaultOptions: {
@@ -149,6 +150,7 @@ function App() {
                 </Route>
               </Routes>
             </BrowserRouter>
+            <VirtualKeyboard />
           </AuthProvider>
         </QueryClientProvider>
       </ToastProvider>

+ 82 - 0
frontend/src/components/VirtualKeyboard.css

@@ -0,0 +1,82 @@
+/*
+ * Dark theme for react-simple-keyboard — matches bambu-dark / bambu-green palette.
+ * Tailwind v4 preflight resets button display/flex, so we must explicitly
+ * restore the layout that react-simple-keyboard expects.
+ */
+
+.simple-keyboard.vkb-theme {
+  background: #1a1a1a;
+  border-top: 1px solid #333;
+  padding: 8px 4px;
+  font-family: inherit;
+}
+
+/* Row layout — Tailwind preflight strips flex from generic elements */
+.simple-keyboard.vkb-theme .hg-row {
+  display: flex !important;
+  flex-direction: row !important;
+  flex-wrap: nowrap !important;
+  gap: 4px;
+  margin-bottom: 4px;
+}
+
+.simple-keyboard.vkb-theme .hg-row:last-child {
+  margin-bottom: 0;
+}
+
+/* Key buttons — must restore inline-flex sizing */
+.simple-keyboard.vkb-theme .hg-button {
+  display: inline-flex !important;
+  align-items: center;
+  justify-content: center;
+  flex-grow: 1;
+  flex-shrink: 1;
+  flex-basis: auto;
+  background: #2d2d2d;
+  color: #e0e0e0;
+  border: none;
+  border-radius: 6px;
+  height: 44px;
+  font-size: 16px;
+  font-weight: 500;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
+  transition: background 0.1s;
+  cursor: pointer;
+  padding: 0 2px;
+  min-width: 0;
+}
+
+.simple-keyboard.vkb-theme .hg-button:active {
+  background: #00ae42;
+  color: #fff;
+}
+
+/* Functional keys */
+.simple-keyboard.vkb-theme .hg-button-bksp,
+.simple-keyboard.vkb-theme .hg-button-shift,
+.simple-keyboard.vkb-theme .hg-button-lock {
+  background: #3a3a3a;
+  color: #aaa;
+  flex-grow: 1.5;
+}
+
+.simple-keyboard.vkb-theme .hg-button-close {
+  background: #3a3a3a;
+  color: #aaa;
+  flex-grow: 2;
+  font-weight: 600;
+}
+
+.simple-keyboard.vkb-theme .hg-button-close:active {
+  background: #555;
+}
+
+.simple-keyboard.vkb-theme .hg-button-space {
+  flex-grow: 7;
+}
+
+/* Active shift/caps indicator */
+.simple-keyboard.vkb-theme .hg-activeButton {
+  background: #00ae42;
+  color: #fff;
+}

+ 187 - 0
frontend/src/components/VirtualKeyboard.tsx

@@ -0,0 +1,187 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import Keyboard from 'react-simple-keyboard';
+import 'react-simple-keyboard/build/css/index.css';
+import './VirtualKeyboard.css';
+
+const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
+
+/**
+ * Set value on a controlled React input using the native setter,
+ * then dispatch an input event so React picks up the change.
+ */
+function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
+  const setter =
+    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ??
+    Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
+  setter?.call(input, value);
+  input.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+export function VirtualKeyboard() {
+  const [visible, setVisible] = useState(false);
+  const [closing, setClosing] = useState(false);
+  const closingRef = useRef(false);
+  const [layoutName, setLayoutName] = useState('default');
+  const activeInput = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
+  const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const handleFocusIn = useCallback((e: FocusEvent) => {
+    if (closingRef.current) return;
+    const target = e.target as HTMLElement;
+
+    // Skip inputs that opt out (e.g. SpoolBuddySettingsPage numpad field)
+    if (target.closest('[data-vkb="false"]')) return;
+
+    if (target instanceof HTMLInputElement) {
+      if (!FOCUSABLE_TYPES.has(target.type)) return;
+    } else if (!(target instanceof HTMLTextAreaElement)) {
+      return;
+    }
+
+    activeInput.current = target as HTMLInputElement | HTMLTextAreaElement;
+    setVisible(true);
+    setLayoutName('default');
+
+    // Sync keyboard display with current value
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (keyboardRef.current as any)?.setInput?.(activeInput.current.value);
+
+    // Scroll input into view above the keyboard
+    setTimeout(() => {
+      target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    }, 100);
+  }, []);
+
+  const handleFocusOut = useCallback((_e: FocusEvent) => {
+    // Delay to allow click on keyboard buttons to register
+    setTimeout(() => {
+      const active = document.activeElement;
+      // Keep visible if focus moved to keyboard or back to same input
+      if (
+        active &&
+        (containerRef.current?.contains(active) || active === activeInput.current)
+      ) {
+        return;
+      }
+      setVisible(false);
+      activeInput.current = null;
+    }, 150);
+  }, []);
+
+  useEffect(() => {
+    document.addEventListener('focusin', handleFocusIn);
+    document.addEventListener('focusout', handleFocusOut);
+    return () => {
+      document.removeEventListener('focusin', handleFocusIn);
+      document.removeEventListener('focusout', handleFocusOut);
+    };
+  }, [handleFocusIn, handleFocusOut]);
+
+  const onKeyPress = useCallback((button: string) => {
+    const input = activeInput.current;
+    if (!input) return;
+
+    if (button === '{shift}') {
+      setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
+      return;
+    }
+    if (button === '{lock}') {
+      setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
+      return;
+    }
+    if (button === '{close}') {
+      dismiss();
+      return;
+    }
+    if (button === '{bksp}') {
+      setNativeValue(input, input.value.slice(0, -1));
+    } else if (button === '{space}') {
+      setNativeValue(input, input.value + ' ');
+    } else {
+      setNativeValue(input, input.value + button);
+      // Auto-unshift after typing one character (like mobile keyboards)
+      if (layoutName === 'shift') {
+        setLayoutName('default');
+      }
+    }
+
+    // Keep focus on the input
+    input.focus();
+    // Sync keyboard internal state
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    (keyboardRef.current as any)?.setInput?.(input.value);
+  }, [layoutName]);
+
+  // Two-phase close: hide the keyboard immediately but keep the backdrop
+  // alive for 400ms to absorb the ghost click that touch devices synthesize.
+  const dismiss = useCallback(() => {
+    closingRef.current = true;
+    setClosing(true);
+    activeInput.current?.blur();
+    activeInput.current = null;
+    setTimeout(() => {
+      setVisible(false);
+      setClosing(false);
+      closingRef.current = false;
+    }, 400);
+  }, []);
+
+  if (!visible) return null;
+
+  return (
+    <>
+      {/* Backdrop: absorbs taps so they don't reach elements under the keyboard.
+          Stays alive during closing phase to catch ghost clicks. */}
+      <div
+        className="fixed inset-0 z-[9998] bg-transparent"
+        onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
+        onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
+        onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
+      />
+      {!closing && (
+      <div
+        ref={containerRef}
+        className="fixed bottom-0 left-0 right-0 z-[9999]"
+        onMouseDown={(e) => e.preventDefault()}
+        onTouchStart={(e) => {
+          // Prevent focus loss but allow button interaction
+          if (!(e.target as HTMLElement).closest('.hg-button')) {
+            e.preventDefault();
+          }
+        }}
+      >
+        <Keyboard
+        keyboardRef={(r: ReturnType<typeof Keyboard>) => { keyboardRef.current = r; }}
+        layoutName={layoutName}
+        onKeyPress={onKeyPress}
+        theme="simple-keyboard vkb-theme"
+        layout={{
+          default: [
+            '1 2 3 4 5 6 7 8 9 0 {bksp}',
+            'q w e r t y u i o p',
+            '{lock} a s d f g h j k l',
+            '{shift} z x c v b n m . @',
+            '{space} {close}',
+          ],
+          shift: [
+            '! @ # $ % ^ & * ( ) {bksp}',
+            'Q W E R T Y U I O P',
+            '{lock} A S D F G H J K L',
+            '{shift} Z X C V B N M , _',
+            '{space} {close}',
+          ],
+        }}
+        display={{
+          '{bksp}': '\u232B',
+          '{close}': '\u2715 Close',
+          '{shift}': '\u21E7',
+          '{lock}': '\u21EA',
+          '{space}': ' ',
+        }}
+      />
+      </div>
+      )}
+    </>
+  );
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BO0c-VbW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BSznhDCP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BW2QyQko.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BZSO31OK.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BSznhDCP.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BZSO31OK.css">
+    <script type="module" crossorigin src="/assets/index-BO0c-VbW.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BW2QyQko.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff