feat(app): Workspace-Umbau Schritt 4 — zoombarer Canvas + Fokus/Uebersicht
Der Chat-Tab hostet ab jetzt den Workspace-Canvas (eine ChatScreen-Instanz,
kein Doppel-Mount). Zwei Ebenen:
- Welt-Ebene (reanimated scale/translate): leichte Thumbnail-Kacheln, 2-Finger
Pinch-Zoom + 2-Finger-Pan nur in der Uebersicht (gesture-handler).
- Identity-Content-Ebene (Scale 1): schwere Inhalte (ChatScreen + kommende
WebViews) immer gemountet, nur die fokussierte per display sichtbar → Touch/
Keyboard bleiben korrekt, nichts remountet beim Fokuswechsel.
Tap auf Kachel = Fokus (voll interaktiv), "⤢ Uebersicht"/Hardware-Back = zurueck
zur Landkarte. Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert
→ verhaelt sich exakt wie der bisherige Vollbild-Chat.
Neue Dateien unter android/src/workspace/: layout.ts (Kamera-Mathe, Center-
Origin fuer RN 0.73), WorkspaceCanvas.tsx, WorkspaceScreen.tsx, Tile.tsx,
tiles/{ChatTile,CodeEditorTile,VncTile,PreviewTile}.tsx. Editor/VNC sind noch
Platzhalter (Commit 5/7). tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -12,7 +12,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|||||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
|
||||||
import ChatScreen from './src/screens/ChatScreen';
|
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
@@ -166,7 +166,7 @@ const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Chat"
|
name="Chat"
|
||||||
component={ChatScreen}
|
component={WorkspaceScreen}
|
||||||
options={{
|
options={{
|
||||||
title: 'ARIA Chat',
|
title: 'ARIA Chat',
|
||||||
headerTitle: 'ARIA Cockpit',
|
headerTitle: 'ARIA Cockpit',
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Tile — leichte Thumbnail-Darstellung einer Kachel in der gezoomten "Landkarte".
|
||||||
|
*
|
||||||
|
* Zeigt NUR Icon + Titel (+ optionalen Untertitel). Die schweren, interaktiven
|
||||||
|
* Inhalte (ChatScreen, WebViews) liegen NICHT hier, sondern in der separaten
|
||||||
|
* Identity-Content-Ebene des WorkspaceCanvas — Thumbnails werden nie skaliert
|
||||||
|
* interaktiv. Ein Tap fokussiert die Kachel (zoomt sie voll auf).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
|
import { runOnJS } from 'react-native-reanimated';
|
||||||
|
import { TileId, TileRect, TILE_META } from './layout';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: TileId;
|
||||||
|
/** Welt-View-lokales Rect (relativ zur Bounds-Ecke). */
|
||||||
|
rect: TileRect;
|
||||||
|
subtitle?: string;
|
||||||
|
onFocus: (id: TileId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tile: React.FC<Props> = ({ id, rect, subtitle, onFocus }) => {
|
||||||
|
const meta = TILE_META[id];
|
||||||
|
const tap = Gesture.Tap()
|
||||||
|
.maxDuration(300)
|
||||||
|
.onEnd((_e, success) => {
|
||||||
|
if (success) runOnJS(onFocus)(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={tap}>
|
||||||
|
<View style={[styles.tile, { left: rect.x, top: rect.y, width: rect.w, height: rect.h }]}>
|
||||||
|
<Text style={styles.icon}>{meta.icon}</Text>
|
||||||
|
<Text style={styles.title}>{meta.title}</Text>
|
||||||
|
{!!subtitle && <Text style={styles.subtitle} numberOfLines={2}>{subtitle}</Text>}
|
||||||
|
<Text style={styles.hint}>Tippen zum Öffnen</Text>
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
tile: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: '#12122A',
|
||||||
|
borderRadius: 32,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 40,
|
||||||
|
},
|
||||||
|
icon: { fontSize: 220, marginBottom: 24 },
|
||||||
|
title: { color: '#FFFFFF', fontSize: 96, fontWeight: '800' },
|
||||||
|
subtitle: { color: '#9090B0', fontSize: 52, marginTop: 20, textAlign: 'center' },
|
||||||
|
hint: { color: '#555570', fontSize: 46, marginTop: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Tile;
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus.
|
||||||
|
*
|
||||||
|
* Zwei Ebenen uebereinander:
|
||||||
|
* 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier
|
||||||
|
* wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht).
|
||||||
|
* 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren,
|
||||||
|
* interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln
|
||||||
|
* sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar.
|
||||||
|
* Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet
|
||||||
|
* beim Fokuswechsel.
|
||||||
|
*
|
||||||
|
* Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der
|
||||||
|
* "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte.
|
||||||
|
* Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der
|
||||||
|
* Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { BackHandler, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||||
|
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
|
import {
|
||||||
|
boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal,
|
||||||
|
} from './layout';
|
||||||
|
import Tile from './Tile';
|
||||||
|
import ChatTile from './tiles/ChatTile';
|
||||||
|
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||||
|
import VncTile from './tiles/VncTile';
|
||||||
|
import PreviewTile from './tiles/PreviewTile';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
visibleTiles: TileId[];
|
||||||
|
subtitles?: Partial<Record<TileId, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIM = { duration: 260 };
|
||||||
|
const MIN_SCALE = 0.15;
|
||||||
|
const MAX_SCALE = 4;
|
||||||
|
|
||||||
|
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
|
||||||
|
const { width: vw, height: vh } = useWindowDimensions();
|
||||||
|
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('chat');
|
||||||
|
|
||||||
|
const visibleKey = visibleTiles.join(',');
|
||||||
|
const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]);
|
||||||
|
const worldW = bounds.w;
|
||||||
|
const worldH = bounds.h;
|
||||||
|
const single = visibleTiles.length <= 1;
|
||||||
|
|
||||||
|
// Kamera (shared values fuer 60fps auf dem UI-Thread).
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const tx = useSharedValue(0);
|
||||||
|
const ty = useSharedValue(0);
|
||||||
|
const savedScale = useSharedValue(1);
|
||||||
|
const savedTx = useSharedValue(0);
|
||||||
|
const savedTy = useSharedValue(0);
|
||||||
|
|
||||||
|
// Bei nur einer Kachel: immer fokussiert. Verschwindet die fokussierte
|
||||||
|
// Kachel aus der Sichtbarkeit, auf Chat zurueckfallen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (single) {
|
||||||
|
setFocusedTileId(visibleTiles[0] || 'chat');
|
||||||
|
} else if (focusedTileId && !visibleTiles.includes(focusedTileId)) {
|
||||||
|
setFocusedTileId('chat');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visibleKey]);
|
||||||
|
|
||||||
|
// Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht).
|
||||||
|
useEffect(() => {
|
||||||
|
const cam: Camera = focusedTileId
|
||||||
|
? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh)
|
||||||
|
: overviewCamera(worldW, worldH, vw, vh);
|
||||||
|
scale.value = withTiming(cam.scale, ANIM);
|
||||||
|
tx.value = withTiming(cam.tx, ANIM);
|
||||||
|
ty.value = withTiming(cam.ty, ANIM);
|
||||||
|
savedScale.value = cam.scale;
|
||||||
|
savedTx.value = cam.tx;
|
||||||
|
savedTy.value = cam.ty;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [focusedTileId, visibleKey, vw, vh]);
|
||||||
|
|
||||||
|
// Hardware-Back: im Fokus (und mehr als eine Kachel) → zurueck zur Uebersicht.
|
||||||
|
useEffect(() => {
|
||||||
|
const onBack = () => {
|
||||||
|
if (focusedTileId && !single) {
|
||||||
|
setFocusedTileId(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [focusedTileId, single]);
|
||||||
|
|
||||||
|
// Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel).
|
||||||
|
const gesturesEnabled = !focusedTileId && !single;
|
||||||
|
const canvasGesture = useMemo(() => {
|
||||||
|
const pinch = Gesture.Pinch()
|
||||||
|
.enabled(gesturesEnabled)
|
||||||
|
.onUpdate((e) => {
|
||||||
|
'worklet';
|
||||||
|
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale));
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
'worklet';
|
||||||
|
savedScale.value = scale.value;
|
||||||
|
});
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.enabled(gesturesEnabled)
|
||||||
|
.minPointers(2)
|
||||||
|
.onUpdate((e) => {
|
||||||
|
'worklet';
|
||||||
|
tx.value = savedTx.value + e.translationX;
|
||||||
|
ty.value = savedTy.value + e.translationY;
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
'worklet';
|
||||||
|
savedTx.value = tx.value;
|
||||||
|
savedTy.value = ty.value;
|
||||||
|
});
|
||||||
|
return Gesture.Simultaneous(pinch, pan);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gesturesEnabled]);
|
||||||
|
|
||||||
|
const worldStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{ translateX: tx.value },
|
||||||
|
{ translateY: ty.value },
|
||||||
|
{ scale: scale.value },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderContent = (id: TileId) => {
|
||||||
|
switch (id) {
|
||||||
|
case 'chat': return <ChatTile />;
|
||||||
|
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||||
|
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
|
||||||
|
case 'preview': return <PreviewTile />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
|
||||||
|
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
|
||||||
|
<GestureDetector gesture={canvasGesture}>
|
||||||
|
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
|
||||||
|
{visibleTiles.map((id) => (
|
||||||
|
<Tile
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
rect={toLocal(TILE_RECTS[id], bounds)}
|
||||||
|
subtitle={subtitles?.[id]}
|
||||||
|
onFocus={setFocusedTileId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
|
||||||
|
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
|
||||||
|
{visibleTiles.map((id) => (
|
||||||
|
<View
|
||||||
|
key={id}
|
||||||
|
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
|
||||||
|
>
|
||||||
|
{renderContent(id)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Steuerung */}
|
||||||
|
{focusedTileId && !single && (
|
||||||
|
<TouchableOpacity style={styles.overviewBtn} onPress={() => setFocusedTileId(null)} activeOpacity={0.8}>
|
||||||
|
<Text style={styles.overviewBtnText}>⤢ Übersicht</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{!focusedTileId && (
|
||||||
|
<View style={styles.hintBar} pointerEvents="none">
|
||||||
|
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||||
|
world: { position: 'absolute', left: 0, top: 0 },
|
||||||
|
overviewBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
right: 12,
|
||||||
|
backgroundColor: 'rgba(18,18,42,0.92)',
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
overviewBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||||
|
hintBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
hintText: {
|
||||||
|
color: '#9090B0',
|
||||||
|
fontSize: 12,
|
||||||
|
backgroundColor: 'rgba(18,18,42,0.85)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WorkspaceCanvas;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas.
|
||||||
|
*
|
||||||
|
* Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind:
|
||||||
|
* - projectFocus: aktives Projekt + dessen kind ('code'|'chat')
|
||||||
|
* - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors)
|
||||||
|
* - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel)
|
||||||
|
*
|
||||||
|
* Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas,
|
||||||
|
* bleibt aber genau eine Instanz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||||
|
import codeFile from '../services/codeFile';
|
||||||
|
import desktop from '../services/desktop';
|
||||||
|
import { TileId, visibleTilesFor } from './layout';
|
||||||
|
import WorkspaceCanvas from './WorkspaceCanvas';
|
||||||
|
|
||||||
|
const WorkspaceScreen: React.FC = () => {
|
||||||
|
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
|
||||||
|
const [hasCode, setHasCode] = useState(false);
|
||||||
|
const [hasDesktop, setHasDesktop] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||||
|
|
||||||
|
const pid = focus.focusedProjectId;
|
||||||
|
const kind = projectFocus.getProjectKind(pid);
|
||||||
|
|
||||||
|
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||||
|
useEffect(() => {
|
||||||
|
setHasCode(codeFile.getFiles(pid).length > 0);
|
||||||
|
return codeFile.subscribe((u) => {
|
||||||
|
if ((u.projectId || '') === (pid || '')) setHasCode(true);
|
||||||
|
});
|
||||||
|
}, [pid]);
|
||||||
|
|
||||||
|
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
|
||||||
|
useEffect(() => {
|
||||||
|
setHasDesktop(desktop.getStatus().available);
|
||||||
|
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
|
||||||
|
if (kind === 'code') desktop.requestCheck(pid);
|
||||||
|
return unsub;
|
||||||
|
}, [pid, kind]);
|
||||||
|
|
||||||
|
const visibleTiles: TileId[] = useMemo(
|
||||||
|
() => visibleTilesFor({ kind, hasCode, hasDesktop }),
|
||||||
|
[kind, hasCode, hasDesktop],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitles = useMemo(
|
||||||
|
() => ({ chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat' } as Partial<Record<TileId, string>>),
|
||||||
|
[pid, focus.projectNameById],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <WorkspaceCanvas projectId={pid} visibleTiles={visibleTiles} subtitles={subtitles} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceScreen;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* layout — Kachel-Geometrie + Kamera-Mathematik fuer den Workspace-Canvas.
|
||||||
|
*
|
||||||
|
* Welt-Koordinaten = Pixel bei Scale 1. Kacheln liegen auf einem festen 2x2-
|
||||||
|
* Raster. Die "Welt-View" (Animated.View) ist exakt die Bounding-Box der gerade
|
||||||
|
* sichtbaren Kacheln; Kinder werden relativ zu deren Ursprung positioniert.
|
||||||
|
*
|
||||||
|
* RN 0.73 kennt noch kein transformOrigin — Scale dreht um die View-MITTE.
|
||||||
|
* Alle Kamera-Formeln rechnen deshalb mit Center-Origin:
|
||||||
|
* screen = center + scale*(p - center) + translate
|
||||||
|
* wobei center = (worldW/2, worldH/2) (die Welt-View sitzt bei screen 0,0).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
||||||
|
|
||||||
|
export interface TileRect { x: number; y: number; w: number; h: number; }
|
||||||
|
|
||||||
|
export interface TileDef { id: TileId; title: string; icon: string; }
|
||||||
|
|
||||||
|
// Feste Kachelgroesse im Welt-Raster (Pixel bei Scale 1).
|
||||||
|
const TILE_W = 1100;
|
||||||
|
const TILE_H = 1500;
|
||||||
|
const GAP = 140;
|
||||||
|
|
||||||
|
/** Absolute Welt-Rects pro Kachel (2x2-Raster). */
|
||||||
|
export const TILE_RECTS: Record<TileId, TileRect> = {
|
||||||
|
chat: { x: 0, y: 0, w: TILE_W, h: TILE_H },
|
||||||
|
editor: { x: TILE_W + GAP, y: 0, w: TILE_W, h: TILE_H },
|
||||||
|
vnc: { x: 0, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
|
||||||
|
preview: { x: TILE_W + GAP, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TILE_META: Record<TileId, TileDef> = {
|
||||||
|
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||||
|
editor: { id: 'editor', title: 'Editor', icon: '📝' },
|
||||||
|
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||||
|
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reihenfolge fuer stabiles Rendering.
|
||||||
|
export const TILE_ORDER: TileId[] = ['chat', 'editor', 'vnc', 'preview'];
|
||||||
|
|
||||||
|
export interface VisibilitySignals {
|
||||||
|
kind: 'code' | 'chat';
|
||||||
|
hasCode: boolean; // schon ein code_file empfangen
|
||||||
|
hasDesktop: boolean; // Desktop verfuegbar gemeldet
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Kacheln sind fuer den aktuellen Kontext sichtbar?
|
||||||
|
* Chat ist immer da; Code-Projekt (explizit ODER durch ein Live-Signal)
|
||||||
|
* blendet Editor/Desktop/Vorschau ein. */
|
||||||
|
export function visibleTilesFor(sig: VisibilitySignals): TileId[] {
|
||||||
|
const isCode = sig.kind === 'code' || sig.hasCode || sig.hasDesktop;
|
||||||
|
if (!isCode) return ['chat'];
|
||||||
|
return ['chat', 'editor', 'vnc', 'preview'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding-Box mehrerer Kacheln. */
|
||||||
|
export function boundsOf(ids: TileId[]): TileRect {
|
||||||
|
if (ids.length === 0) return { x: 0, y: 0, w: TILE_W, h: TILE_H };
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const id of ids) {
|
||||||
|
const r = TILE_RECTS[id];
|
||||||
|
minX = Math.min(minX, r.x);
|
||||||
|
minY = Math.min(minY, r.y);
|
||||||
|
maxX = Math.max(maxX, r.x + r.w);
|
||||||
|
maxY = Math.max(maxY, r.y + r.h);
|
||||||
|
}
|
||||||
|
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rect in Welt-View-lokale Koordinaten (relativ zur Bounds-Ecke) umrechnen. */
|
||||||
|
export function toLocal(rect: TileRect, bounds: TileRect): TileRect {
|
||||||
|
return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Camera { scale: number; tx: number; ty: number; }
|
||||||
|
|
||||||
|
/** Kamera, die ein (lokales) Rect bildschirmfuellend zeigt (Fokus-Modus). */
|
||||||
|
export function focusCamera(localRect: TileRect, worldW: number, worldH: number, vw: number, vh: number): Camera {
|
||||||
|
const s = Math.min(vw / localRect.w, vh / localRect.h);
|
||||||
|
const pcx = localRect.x + localRect.w / 2;
|
||||||
|
const pcy = localRect.y + localRect.h / 2;
|
||||||
|
const tx = vw / 2 - worldW / 2 - s * (pcx - worldW / 2);
|
||||||
|
const ty = vh / 2 - worldH / 2 - s * (pcy - worldH / 2);
|
||||||
|
return { scale: s, tx, ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kamera, die die gesamte Welt zentriert einpasst (Uebersicht). */
|
||||||
|
export function overviewCamera(worldW: number, worldH: number, vw: number, vh: number, pad = 0.86): Camera {
|
||||||
|
const s = Math.min(vw / worldW, vh / worldH) * pad;
|
||||||
|
const tx = vw / 2 - worldW / 2;
|
||||||
|
const ty = vh / 2 - worldH / 2;
|
||||||
|
return { scale: s, tx, ty };
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* ChatTile — hostet die bestehende ChatScreen unveraendert als Workspace-Kachel.
|
||||||
|
*
|
||||||
|
* ChatScreen bleibt genau EINE Instanz (der Workspace-Tab ersetzt den alten
|
||||||
|
* Chat-Tab) und wird nie beim Fokuswechsel remountet — sie liegt in der
|
||||||
|
* Identity-Content-Ebene und wird nur per display ein-/ausgeblendet. So
|
||||||
|
* behaelt sie RVS-Abos, Audio, Queue-State und Keyboard-Verhalten wie bisher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import ChatScreen from '../../screens/ChatScreen';
|
||||||
|
|
||||||
|
const ChatTile: React.FC = () => <ChatScreen />;
|
||||||
|
|
||||||
|
export default React.memo(ChatTile);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* CodeEditorTile — Live-Code-Editor (CodeMirror in einer WebView).
|
||||||
|
* Platzhalter fuer Commit 4; die CodeMirror-Bridge folgt in Commit 5.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeEditorTile: React.FC<Props> = () => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.icon}>📝</Text>
|
||||||
|
<Text style={styles.text}>Code-Editor</Text>
|
||||||
|
<Text style={styles.sub}>Wird geladen …</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
icon: { fontSize: 64, marginBottom: 16 },
|
||||||
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||||
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CodeEditorTile;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* PreviewTile — zeigt den letzten Screenshot/Vorschau-Frame eines Code-Projekts
|
||||||
|
* (z. B. aria-vm screenshot). Fuellt sich, sobald ARIA ein Bild in den
|
||||||
|
* Vorschau-Kanal legt; bis dahin ein ruhiger Platzhalter.
|
||||||
|
*
|
||||||
|
* (Screenshot-Anbindung folgt mit dem QEMU-Track; hier zunaechst die Kachel.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Image, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imageUri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreviewTile: React.FC<Props> = ({ imageUri }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{imageUri ? (
|
||||||
|
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="contain" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.icon}>🖼️</Text>
|
||||||
|
<Text style={styles.text}>Noch keine Vorschau</Text>
|
||||||
|
<Text style={styles.sub}>Screenshots der VM erscheinen hier.</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
image: { width: '100%', height: '100%' },
|
||||||
|
icon: { fontSize: 64, marginBottom: 16 },
|
||||||
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||||
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PreviewTile;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||||
|
* Platzhalter fuer Commit 4; der noVNC-Tunnel folgt in Commit 7.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VncTile: React.FC<Props> = () => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.icon}>🖥️</Text>
|
||||||
|
<Text style={styles.text}>Desktop</Text>
|
||||||
|
<Text style={styles.sub}>Kein Desktop verbunden</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
icon: { fontSize: 64, marginBottom: 16 },
|
||||||
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||||
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VncTile;
|
||||||
Reference in New Issue
Block a user