Panels: show real Browser, History, Assistant and Render scene content (#11309) (#13199)

* claim: #11316

* Panels: support keyboard navigation and accessible controls (#11316)

* Panels: mount existing content in the shared layout

* Panels: fix validation imports and simplify host setup

* Panels: recover legacy sizes and preserve splitter controls

* Panels: confine legacy size recovery to shared import

* Assistant: keep the composer visible in short shared panels

* Assistant: reserve picker spacing above the composer

* Keep shared Assistant top controls within composer scroll budget
This commit is contained in:
Nathan Dane
2026-11-08 05:28:07 +00:00
committed by GitHub
parent 8f1143b940
commit eaf8f27a7f
42 changed files with 4258 additions and 230 deletions
@@ -0,0 +1,90 @@
{
"ar": {
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
"cmd.panel.toggle": "تبديل طي اللوحة المحددة"
},
"de": {
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen"
},
"en": {
"cmd.panel.focus.next": "Focus Next Panel",
"cmd.panel.focus.previous": "Focus Previous Panel",
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
"cmd.panel.sash.down": "Resize Panel Sash Down",
"cmd.panel.sash.left": "Resize Panel Sash Left",
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
"cmd.panel.sash.right": "Resize Panel Sash Right",
"cmd.panel.sash.up": "Resize Panel Sash Up",
"cmd.panel.toggle": "Toggle Focused Panel"
},
"he": {
"cmd.panel.focus.next": "מיקוד בלוח הבא",
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד"
},
"ja": {
"cmd.panel.focus.next": "次のパネルにフォーカス",
"cmd.panel.focus.previous": "前のパネルにフォーカス",
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
"cmd.panel.sash.down": "パネルの区切りを下に移動",
"cmd.panel.sash.left": "パネルの区切りを左に移動",
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
"cmd.panel.sash.right": "パネルの区切りを右に移動",
"cmd.panel.sash.up": "パネルの区切りを上に移動",
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え"
},
"ko": {
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
"cmd.panel.toggle": "초점이 있는 패널 접기 전환"
},
"vi": {
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm"
},
"zh": {
"cmd.panel.focus.next": "聚焦下一个面板",
"cmd.panel.focus.previous": "聚焦上一个面板",
"cmd.panel.move.next_zone": "将面板移至下一个区域",
"cmd.panel.sash.down": "向下移动面板分隔条",
"cmd.panel.sash.left": "向左移动面板分隔条",
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
"cmd.panel.sash.right": "向右移动面板分隔条",
"cmd.panel.sash.up": "向上移动面板分隔条",
"cmd.panel.toggle": "切换当前焦点面板的折叠状态"
}
}
@@ -0,0 +1,34 @@
{
"ar": {
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات."
},
"de": {
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam."
},
"en": {
"prefs.shared_panel_layout": "Use shared panel layout",
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied."
},
"he": {
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות."
},
"ja": {
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。"
},
"ko": {
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다."
},
"vi": {
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn."
},
"zh": {
"prefs.shared_panel_layout": "使用共享面板布局",
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。"
}
}
+76
View File
@@ -108,6 +108,22 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"target or legacy id, expected_revision?: reopen retained state, activate and request focus"
),
command!(
"panel.focus.next",
"fit",
"Focus Next Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.focus.previous",
"fit",
"Focus Previous Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.list",
"list",
@@ -124,6 +140,14 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"target or legacy id, destination: new_section, join_tab or relative, activate?: false, expected_revision?"
),
command!(
"panel.move.next_zone",
"move",
"Move Panel to Next Zone",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.native_close",
"restore",
@@ -180,4 +204,56 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"first, second, first_size, second_size, expected_revision?: conserve adjacent dock pair extent"
),
command!(
"panel.sash.down",
"scale",
"Resize Panel Sash Down",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Down"),
command!(
"panel.sash.left",
"scale",
"Resize Panel Sash Left",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Left"),
command!(
"panel.sash.reset",
"restore",
"Reset Focused Panel Sash",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.sash.right",
"scale",
"Resize Panel Sash Right",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Right"),
command!(
"panel.sash.up",
"scale",
"Resize Panel Sash Up",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Up"),
command!(
"panel.toggle",
"fold",
"Toggle Focused Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
];
+26 -1
View File
@@ -297,8 +297,11 @@ pub const IDS: &[&str] = &[
"panel.dismiss",
"panel.float",
"panel.focus",
"panel.focus.next",
"panel.focus.previous",
"panel.list",
"panel.move",
"panel.move.next_zone",
"panel.native_close",
"panel.open",
"panel.peek",
@@ -306,6 +309,12 @@ pub const IDS: &[&str] = &[
"panel.reset_pair",
"panel.resize",
"panel.resize_pair",
"panel.sash.down",
"panel.sash.left",
"panel.sash.reset",
"panel.sash.right",
"panel.sash.up",
"panel.toggle",
];
#[derive(Debug)]
@@ -314,6 +323,7 @@ pub enum PanelCommand {
Get,
Open(wire::Open),
HeaderAction(wire::HeaderAction),
Contextual { action: ContextualPanelAction, params: wire::Revision },
Select { operation: &'static str, params: wire::Select },
Move { operation: &'static str, params: wire::Move },
Resize(wire::Resize),
@@ -353,6 +363,11 @@ pub fn parse_command(id: &str, params: &Value) -> PanelResult<PanelCommand> {
}
serde_json::to_writer(Counter(0), params).map_err(|_| error(Rule::CollectionLimit, "panel command byte cap"))?;
let id = crate::find_command(id).map(|s| s.id).ok_or_else(|| error(Rule::MissingTarget, "unknown panel command"))?;
if let Some(action) = ContextualPanelAction::from_command_id(id) {
let command = PanelCommand::Contextual { action, params: read(params)? };
validate_command(&command)?;
return Ok(command);
}
let command = match id {
"panel.list" => {
let _: wire::Empty = read(params)?;
@@ -810,6 +825,11 @@ pub fn execute_command(command: PanelCommand, context: &mut PanelCommandContext<
validate_command(&command)?;
let state = context.sink.inspect();
match command {
PanelCommand::Contextual { action, params } => {
revision(params.expected_revision, &state)?;
let action = context.sink.resolve_contextual(action)?;
return value(execute_layout_action(action, context)?);
}
PanelCommand::List => return value(wire::ListReply { state, kinds: context.registry.specs().cloned().collect() }),
PanelCommand::Get => return value(wire::GetReply { state, workspaces: context.catalogue.state().summaries() }),
command @ PanelCommand::HeaderAction(_) => {
@@ -1038,6 +1058,7 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
let mut replacement = false;
let mut mutation = false;
let mut header_action = false;
let mut contextual = false;
for step in steps {
let id =
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
@@ -1045,6 +1066,7 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
match parse_command(id, step.get("params").unwrap_or(&empty))? {
PanelCommand::List | PanelCommand::Get | PanelCommand::Save(_) => {}
PanelCommand::HeaderAction(_) => header_action = true,
PanelCommand::Contextual { .. } => contextual = true,
PanelCommand::Apply(_) | PanelCommand::Reset(_) => replacement = true,
PanelCommand::Workspace(params) => {
replacement |= params.workspace != selected;
@@ -1053,6 +1075,9 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
_ => mutation = true,
}
}
if contextual && steps.len() != 1 {
return Err(error(Rule::Capability, "contextual panel actions require a standalone transaction"));
}
if header_action && steps.len() != 1 {
return Err(error(Rule::Capability, "header actions require a standalone transaction"));
}
@@ -1075,7 +1100,7 @@ pub fn execute_batch(steps: &[Value], context: &mut PanelCommandContext<'_>) ->
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
let empty = json!({});
let command = parse_command(id, step.get("params").unwrap_or(&empty))?;
if matches!(&command, PanelCommand::HeaderAction(_)) {
if matches!(&command, PanelCommand::HeaderAction(_) | PanelCommand::Contextual { .. }) {
let result = execute_command(command, context)?;
return Ok(json!({"completed":1,"results":[result],"undone":0}));
}
+1
View File
@@ -29,6 +29,7 @@ pub fn synthetic_registry() -> PanelResult<PanelRegistry> {
surface: PanelSurface::FollowZone,
features: PanelFeatures { multi_instance: multi, ..PanelFeatures::default() },
side_height_policy: SideHeightPolicy::Content,
fixed_bottom_height: None,
header_actions: Vec::new(),
text_heavy: false,
})?;
+43 -7
View File
@@ -197,12 +197,33 @@ pub struct PanelSpec {
pub features: PanelFeatures,
#[serde(default)]
pub side_height_policy: SideHeightPolicy,
/// A rigid, expanded Bottom row. Other zones retain their ordinary bounds
/// and collapse capability. The declared height must satisfy those bounds.
#[serde(default)]
pub fixed_bottom_height: Option<f32>,
#[serde(default)]
pub header_actions: Vec<HeaderActionSpec>,
#[serde(default)]
pub text_heavy: bool,
}
impl PanelSpec {
pub fn size_bounds(&self, zone: Zone) -> (Size, Size) {
let (mut min, mut max) = (self.min_size, self.max_size);
if zone == Zone::Bottom
&& let Some(height) = self.fixed_bottom_height
{
min.height = height;
max.height = height;
}
(min, max)
}
pub fn collapsible_in(&self, zone: Zone) -> bool {
self.features.collapsible && !(zone == Zone::Bottom && self.fixed_bottom_height.is_some())
}
}
#[derive(Clone, Debug, Default)]
pub struct PanelRegistry {
specs: BTreeMap<PanelKind, PanelSpec>,
@@ -231,6 +252,11 @@ impl PanelRegistry {
if !spec.default_size.within(spec.min_size, spec.max_size) {
return Err(error(Rule::Bounds, "min <= default <= max required in both dimensions"));
}
if spec.fixed_bottom_height.is_some_and(|height| {
!height.is_finite() || height < spec.min_size.height || height > spec.max_size.height || !spec.allowed_zones.contains(&Zone::Bottom)
}) {
return Err(error(Rule::Bounds, "fixed Bottom height must satisfy registered bounds in an allowed Bottom zone"));
}
if !spec.allowed_zones.contains(&Zone::Floating) || !spec.allowed_zones.contains(&spec.default_zone) {
return Err(error(Rule::Placement, "Floating and default_zone must be allowed"));
}
@@ -875,10 +901,11 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
let mut max = Size { width: MAX_LOGICAL, height: MAX_LOGICAL };
for id in &section.tabs {
let spec = registry.get(&layout.instance(id)?.kind)?;
min.width = min.width.max(spec.min_size.width);
min.height = min.height.max(spec.min_size.height);
max.width = max.width.min(spec.max_size.width);
max.height = max.height.min(spec.max_size.height);
let (lower, upper) = spec.size_bounds(section.location.zone());
min.width = min.width.max(lower.width);
min.height = min.height.max(lower.height);
max.width = max.width.min(upper.width);
max.height = max.height.min(upper.height);
}
if min.width > max.width || min.height > max.height {
return Err(error(Rule::IncompatibleTabs, "tab size constraints have no intersection"));
@@ -886,6 +913,15 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
Ok((min, max))
}
pub fn section_collapsible(layout: &Layout, registry: &PanelRegistry, section: &Section) -> PanelResult<bool> {
for id in &section.tabs {
if !registry.get(&layout.instance(id)?.kind)?.collapsible_in(section.location.zone()) {
return Ok(false);
}
}
Ok(true)
}
pub fn column_bounds(layout: &Layout, registry: &PanelRegistry, column: &ColumnId) -> PanelResult<(f32, f32)> {
layout.column(column)?;
let mut min: f32 = 0.0;
@@ -1024,7 +1060,7 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
if s.tabs.len() > 1 && !spec.features.tabbable {
return Err(error(Rule::FeatureDisabled, "panel cannot join tabs"));
}
if s.collapsed && !spec.features.collapsible {
if s.collapsed && !spec.collapsible_in(zone) {
return Err(error(Rule::FeatureDisabled, "panel cannot collapse"));
}
if zone == Zone::Window && !spec.features.popout {
@@ -1055,9 +1091,9 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
for id in &s.tabs {
let spec = registry.get(&layout.instance(id)?.kind)?;
if !spec.allowed_zones.contains(&return_to.zone)
|| !return_to.size.within(spec.min_size, spec.max_size)
|| !return_to.size.within(spec.size_bounds(return_to.zone).0, spec.size_bounds(return_to.zone).1)
|| return_to.column_width.is_some_and(|width| width < spec.min_size.width || width > spec.max_size.width)
|| (return_to.collapsed && !spec.features.collapsible)
|| (return_to.collapsed && !spec.collapsible_in(return_to.zone))
{
return Err(error(Rule::Placement, "native return violates member constraints"));
}
+110 -9
View File
@@ -180,8 +180,55 @@ pub struct LayoutReply {
pub availability: AvailabilityMap,
}
/// Keyboard actions whose target belongs to the live panel host, never the document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContextualPanelAction {
FocusNext,
FocusPrevious,
Toggle,
MoveNextZone,
ResizeLeft,
ResizeRight,
ResizeUp,
ResizeDown,
ResetSash,
}
impl ContextualPanelAction {
pub const ALL: [Self; 9] = [
Self::FocusNext,
Self::FocusPrevious,
Self::Toggle,
Self::MoveNextZone,
Self::ResizeLeft,
Self::ResizeRight,
Self::ResizeUp,
Self::ResizeDown,
Self::ResetSash,
];
pub const fn command_id(self) -> &'static str {
match self {
Self::FocusNext => "panel.focus.next",
Self::FocusPrevious => "panel.focus.previous",
Self::Toggle => "panel.toggle",
Self::MoveNextZone => "panel.move.next_zone",
Self::ResizeLeft => "panel.sash.left",
Self::ResizeRight => "panel.sash.right",
Self::ResizeUp => "panel.sash.up",
Self::ResizeDown => "panel.sash.down",
Self::ResetSash => "panel.sash.reset",
}
}
pub fn from_command_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|action| action.command_id() == id)
}
}
/// Same boundary for GUI and programmatic callers. No mutable Session reference.
pub trait ActionSink {
/// Optional live-host target resolution. Headless sinks cannot infer UI focus.
fn resolve_contextual(&self, _action: ContextualPanelAction) -> PanelResult<LayoutAction> {
Err(error(Rule::Availability, "live panel host keyboard context is unavailable"))
}
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply>;
fn inspect(&self) -> LayoutReply;
/// Pure projection on the authoritative service. Adapters stage bounded
@@ -931,9 +978,15 @@ impl Reservations<'_> {
}
}
fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reservations: &Reservations<'_>) -> PanelResult<()> {
fn place(
layout: &mut Layout,
id: &InstanceId,
destination: Destination,
reservations: &Reservations<'_>,
new_column_width: Option<f32>,
) -> PanelResult<()> {
match destination {
Destination::NewSection { section, index, mut location, size } => {
Destination::NewSection { section, index, mut location, mut size } => {
fresh_section(layout, id, &section)?;
if let SectionLocation::Window { return_to, .. } = &mut location {
if return_to.zone.side() && return_to.column.is_none() {
@@ -970,11 +1023,19 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
.filter(|p| p.memory.section == section && p.memory.location.zone() == location.zone())
.map(|p| p.memory.clone());
let zone = location.zone();
let mut created_column = false;
let column = if zone.side() {
if let Some(memory) = &memory
&& let Some(column) = &memory.column
{
Some(restore_column(layout, column.clone(), zone, memory.column_index, memory.column_width.unwrap_or(size.width))?)
created_column = layout.column(column).is_err();
Some(restore_column(
layout,
column.clone(),
zone,
memory.column_index,
new_column_width.unwrap_or_else(|| memory.column_width.unwrap_or(size.width)),
)?)
} else {
let existing = layout
.sections
@@ -986,14 +1047,21 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
Some(match existing {
Some(column) => column,
None => {
created_column = true;
let column = next_column_id_with_reservations(layout, reservations.columns)?;
restore_column(layout, column, zone, MAX_SECTIONS, size.width)?
restore_column(layout, column, zone, MAX_SECTIONS, new_column_width.unwrap_or(size.width))?
}
})
}
} else {
None
};
if created_column
&& new_column_width.is_some()
&& let Some(column) = &column
{
size.width = layout.column(column)?.width;
}
let index = if let Some(column) = &column { column_insert_index(layout, column, index)? } else { index };
layout.sections.insert(
index,
@@ -1019,7 +1087,7 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
s.tabs.insert(index, id.clone());
// Joining never steals active tab or resizes the group.
}
Destination::Relative { section, placement, new_section, new_column, size } => {
Destination::Relative { section, placement, new_section, new_column, mut size } => {
fresh_section(layout, id, &new_section)?;
size.validate()?;
let target = layout.section(&section)?.clone();
@@ -1039,6 +1107,9 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
(target_column.clone(), target_index + usize::from(placement == RelativePlacement::Below), weight)
}
RelativePlacement::BeforeColumn | RelativePlacement::AfterColumn => {
if let Some(width) = new_column_width {
size.width = width;
}
let column = new_column.ok_or_else(|| error(Rule::Placement, "column placement requires identity"))?;
valid_id(&column.0)?;
if layout.columns.iter().any(|c| c.id == column)
@@ -1136,6 +1207,8 @@ fn apply(
mutation: Mutation,
) -> PanelResult<()> {
let MutationContext { registry, availability, caps, reservations, defer_availability } = *context;
let changes_placement =
matches!(&mutation, Mutation::Open { .. } | Mutation::Move { .. } | Mutation::Focus { .. } | Mutation::NativeClose { .. });
match mutation {
Mutation::Open { kind, instance, destination } => {
valid_id(&instance.0)?;
@@ -1156,7 +1229,7 @@ fn apply(
let mut destination = destination.unwrap_or_else(|| reopen_destination(layout, &p));
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
let created = !matches!(&destination, Destination::JoinTab { .. });
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, None)?;
if created {
let section = layout.containing(&instance)?.id.clone();
let section = section_mut(layout, &section)?;
@@ -1175,7 +1248,7 @@ fn apply(
}
} else {
let destination = destination.ok_or_else(|| error(Rule::Placement, "new instance requires explicit destination"))?;
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, None)?;
let s = layout.containing(&instance)?.clone();
layout.instances.push(PanelInstance {
id: instance.clone(),
@@ -1224,6 +1297,10 @@ fn apply(
}
let collapsed = layout.containing(&instance)?.collapsed;
let source = layout.containing(&instance)?.clone();
let column_width = match &source.column {
Some(column) => layout.column(column)?.width,
None => registry.get(&layout.instance(&instance)?.kind)?.default_size.width,
};
if let Destination::NewSection { location: SectionLocation::Window { return_to, .. }, .. } = &mut destination
&& source.location.zone() == return_to.zone
&& source.location.zone().docked()
@@ -1257,7 +1334,7 @@ fn apply(
Destination::Relative { new_section, .. } => Some(new_section.clone()),
_ => None,
};
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, Some(column_width))?;
if let Some(id) = new_section {
let section = section_mut(layout, &id)?;
section.collapsed = collapsed;
@@ -1276,7 +1353,7 @@ fn apply(
if !p.requested_open {
let mut destination = reopen_destination(layout, &p);
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, None)?;
if let Some(entry) = layout.instances.iter_mut().find(|p| p.id == instance) {
entry.requested_open = true;
}
@@ -1535,6 +1612,7 @@ fn apply(
let index = join.index.min(target.tabs.len());
drop(target.tabs.splice(index..index, section.tabs));
let members = target.tabs.clone();
normalize_fixed_bottom_rows(layout, registry)?;
for instance in members {
remember(layout, &instance)?;
}
@@ -1566,6 +1644,9 @@ fn apply(
layout.sections.insert(index, section);
}
}
if changes_placement {
normalize_fixed_bottom_rows(layout, registry)?;
}
// Every live member gets the same committed group geometry/proportions.
// Closed memories are intentionally independent until their panel reopens.
let members: Vec<_> = layout.sections.iter().flat_map(|s| s.tabs.iter().cloned()).collect();
@@ -1581,6 +1662,26 @@ fn apply(
Ok(())
}
fn normalize_fixed_bottom_rows(layout: &mut Layout, registry: &PanelRegistry) -> PanelResult<()> {
let mut fixed = Vec::new();
for section in layout.sections.iter().filter(|section| section.location.zone() == Zone::Bottom) {
let mut declared = false;
for instance in &section.tabs {
declared |= registry.get(&layout.instance(instance)?.kind)?.fixed_bottom_height.is_some();
}
if declared {
let (min, _) = tab_bounds(layout, registry, section)?;
fixed.push((section.id.clone(), min.height));
}
}
for (id, height) in fixed {
let section = section_mut(layout, &id)?;
section.size.height = height;
section.collapsed = false;
}
Ok(())
}
fn prune_columns(layout: &mut Layout) {
let mut used = std::collections::BTreeSet::new();
for column in layout.sections.iter().filter_map(|s| s.column.clone()).chain(layout.instances.iter().filter_map(|p| p.memory.column.clone())) {
+247 -3
View File
@@ -1,3 +1,4 @@
use super::commands::LayoutCatalogue;
use super::*;
fn id(value: &str) -> InstanceId {
@@ -22,6 +23,172 @@ fn reject(service: &mut LayoutService, mutations: Vec<Mutation>, rule: Rule) {
assert_eq!(service.inspect(), before, "failed action must preserve layout/revision/interaction");
}
#[test]
fn fixed_bottom_height_is_validated_without_weakening_other_zone_bounds() {
let base = synthetic_registry().unwrap().get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
for height in [f32::NAN, f32::INFINITY, 0.0, 119.0, 1201.0] {
let mut spec = base.clone();
spec.fixed_bottom_height = Some(height);
assert_eq!(PanelRegistry::default().register(spec).unwrap_err().rule, Rule::Bounds);
}
let mut spec = base;
spec.fixed_bottom_height = Some(160.0);
assert_eq!(spec.size_bounds(Zone::Bottom), (size(160.0, 160.0), size(1200.0, 160.0)));
assert_eq!(spec.size_bounds(Zone::Left), (spec.min_size, spec.max_size));
assert!(!spec.collapsible_in(Zone::Bottom));
assert!(spec.collapsible_in(Zone::Left));
PanelRegistry::default().register(spec).unwrap();
}
#[test]
fn fixed_bottom_rows_normalize_placement_but_reject_collapse_and_height_commands_atomically() {
use serde_json::json;
let mut registry = synthetic_registry().unwrap();
let mut spec = registry.get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
spec.kind = PanelKind::new("fixture.rigid-row").unwrap();
spec.fixed_bottom_height = Some(160.0);
registry.register(spec).unwrap();
let mut service =
LayoutService::new(registry.clone(), Layout::empty(WorkspaceId::new("fixture.workspace").unwrap()), HostCapabilities::default()).unwrap();
service.dispatch(transaction(vec![fixture_open("fixture.rigid-row", "row", "side", Zone::Left, 0).unwrap()])).unwrap();
service.dispatch(transaction(vec![Mutation::Collapse { section: section("side"), collapsed: true }])).unwrap();
service
.dispatch(transaction(vec![Mutation::Move {
instance: id("row"),
destination: Destination::NewSection {
section: section("bottom"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Bottom },
size: size(500.0, 300.0),
},
}]))
.unwrap();
let bottom = service.layout().section(&section("bottom")).unwrap();
assert_eq!(bottom.size.height, 160.0);
assert!(!bottom.collapsed);
assert!(!section_collapsible(service.layout(), &registry, bottom).unwrap());
reject(&mut service, vec![Mutation::Collapse { section: section("bottom"), collapsed: true }], Rule::FeatureDisabled);
reject(&mut service, vec![Mutation::SetSize { section: section("bottom"), size: size(500.0, 161.0) }], Rule::Bounds);
reject(&mut service, vec![Mutation::SetBottomHeight { section: section("bottom"), height: 161.0 }], Rule::Bounds);
let mut backend = commands::InMemoryPanelBackend::new(service, fixture_command_catalogue(&registry).unwrap());
for (command, params, rule) in [
("panel.collapse", json!({"target":command_target("row"),"collapsed":true}), Rule::FeatureDisabled),
("panel.resize", json!({"target":command_target("row"),"size":{"width":500,"height":161}}), Rule::Bounds),
("panel.resize", json!({"target":command_target("row"),"size":{"width":1,"height":161},"thickness_only":true}), Rule::Bounds),
] {
let before = backend.service.inspect();
let catalogue = backend.catalogue.state().clone();
assert_eq!(backend.execute(command, &params).unwrap_err().rule, rule);
assert_eq!(backend.service.inspect(), before);
assert_eq!(backend.catalogue.state(), &catalogue);
}
backend.service.dispatch(transaction(vec![Mutation::Close { instance: id("row") }])).unwrap();
backend
.service
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.rigid-row").unwrap(), instance: id("row"), destination: None }]))
.unwrap();
assert_eq!(backend.service.layout().containing(&id("row")).unwrap().size.height, 160.0);
backend
.service
.dispatch(transaction(vec![Mutation::Move {
instance: id("row"),
destination: Destination::NewSection {
section: section("right"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: size(500.0, 300.0),
},
}]))
.unwrap();
backend.service.dispatch(transaction(vec![Mutation::Collapse { section: section("right"), collapsed: true }])).unwrap();
assert_eq!(backend.service.layout().section(&section("right")).unwrap().size.height, 300.0);
backend.service.dispatch(transaction(vec![fixture_open("fixture.log", "ordinary", "ordinary", Zone::Bottom, 1).unwrap()])).unwrap();
backend
.service
.dispatch(transaction(vec![Mutation::Open {
kind: PanelKind::new("fixture.rigid-row").unwrap(),
instance: id("inactive"),
destination: Some(Destination::JoinTab { section: section("ordinary"), index: 1 }),
}]))
.unwrap();
let ordinary = backend.service.layout().section(&section("ordinary")).unwrap();
assert_eq!(ordinary.active, id("ordinary"));
assert_eq!(ordinary.size.height, 160.0, "inactive fixed-row metadata still constrains its group");
reject(&mut backend.service, vec![Mutation::Collapse { section: section("ordinary"), collapsed: true }], Rule::FeatureDisabled);
}
#[test]
fn new_column_moves_preserve_source_column_width_or_use_the_registered_default() {
for source_zone in [Zone::Left, Zone::Right, Zone::Bottom, Zone::Floating] {
for relative in [true, false] {
let mut service = fixture_service(false).unwrap();
let target_zone = if source_zone == Zone::Right { Zone::Left } else { Zone::Right };
service
.dispatch(transaction(vec![
fixture_open("fixture.log", "source", "source", source_zone, 0).unwrap(),
fixture_open("fixture.log", "anchor", "anchor", target_zone, 1).unwrap(),
]))
.unwrap();
let target_column = service.layout().containing(&id("anchor")).unwrap().column.clone().unwrap();
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: target_column.clone(), width: 330.0 }])).unwrap();
if source_zone.side() {
let source_column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: source_column, width: 410.0 }])).unwrap();
} else {
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
}
let destination = if relative {
Destination::Relative {
section: section("anchor"),
placement: RelativePlacement::BeforeColumn,
new_section: section("moved"),
new_column: Some(ColumnId::new("moved-column").unwrap()),
size: size(999.0, 240.0),
}
} else {
Destination::NewSection {
section: section("moved"),
index: 0,
location: SectionLocation::Dock { zone: target_zone },
size: size(999.0, 240.0),
}
};
service.dispatch(transaction(vec![Mutation::Move { instance: id("source"), destination }])).unwrap();
let moved = service.layout().containing(&id("source")).unwrap();
let column = service.layout().column(moved.column.as_ref().unwrap()).unwrap();
let expected = if relative { if source_zone.side() { 410.0 } else { 280.0 } } else { 330.0 };
assert_eq!(column.width, expected);
assert_eq!(moved.size.width, if relative { expected } else { 999.0 });
assert_eq!(service.layout().column(&target_column).unwrap().width, 330.0, "joining an existing column never resizes it");
}
}
let mut service = fixture_service(false).unwrap();
service.dispatch(transaction(vec![fixture_open("fixture.log", "source", "source", Zone::Floating, 0).unwrap()])).unwrap();
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
service
.dispatch(transaction(vec![Mutation::Move {
instance: id("source"),
destination: Destination::NewSection {
section: section("moved"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Left },
size: size(999.0, 240.0),
},
}]))
.unwrap();
let column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
assert_eq!(service.layout().column(&column).unwrap().width, 280.0);
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: column.clone(), width: 730.0 }])).unwrap();
service.dispatch(transaction(vec![Mutation::Close { instance: id("source") }])).unwrap();
service
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.log").unwrap(), instance: id("source"), destination: None }]))
.unwrap();
assert_eq!(service.layout().column(&column).unwrap().width, 730.0, "reopen restores the saved column width");
}
#[test]
fn registry_constraints_and_identifiers_are_bounded() {
let base = synthetic_registry().unwrap().specs().next().unwrap().clone();
@@ -1430,7 +1597,7 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
floating["location"] = json!({"type":"floating","origin":{"x":20,"y":30}});
let mut native = floating.clone();
native["location"] = json!({"type":"window","host":"native","origin":{"x":20,"y":30},"return_to":{"zone":"left","index":0,"size":{"width":280,"height":240},"collapsed":false}});
vec![
let mut inputs = vec![
("layout.apply", json!({"name":"Fixture default"})),
("layout.get", json!({})),
("layout.reset", json!({})),
@@ -1458,7 +1625,9 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
"panel.resize_pair",
json!({"first":"s-one","second":"s-two","first_size":{"width":280,"height":260},"second_size":{"width":280,"height":220}}),
),
]
];
inputs.extend(ContextualPanelAction::ALL.into_iter().map(|action| (action.command_id(), json!({}))));
inputs
}
#[test]
@@ -1510,6 +1679,10 @@ fn panel_command_success_replies_describe_committed_state_for_every_executable_o
backend.execute("panel.collapse", &serde_json::json!({"target":command_target("one"),"collapsed":true})).unwrap();
}
let result = backend.execute(id, &input);
if ContextualPanelAction::from_command_id(id).is_some() {
assert_eq!(result.unwrap_err().rule, Rule::Availability, "headless cannot infer shared-control focus");
continue;
}
if id == "panel.action" {
assert_eq!(result.unwrap_err().rule, Rule::Capability, "headless cannot invoke an unbound controller");
continue;
@@ -2064,6 +2237,73 @@ fn command_sidecar_collisions_and_aggregate_catalogue_growth_are_rejected_atomic
assert!(rejected, "aggregate bytes reject before count cap");
}
#[test]
fn contextual_panel_commands_resolve_once_and_commit_through_the_registered_sink() {
use commands::LayoutCatalogue;
struct FocusSink {
service: LayoutService,
resolutions: std::cell::Cell<usize>,
}
impl ActionSink for FocusSink {
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
assert_eq!(action, ContextualPanelAction::FocusNext);
self.resolutions.set(self.resolutions.get() + 1);
Ok(LayoutAction::Apply {
expected_revision: Some(self.service.inspect().revision),
mutations: vec![Mutation::Focus { instance: InstanceId::new("two").unwrap() }],
})
}
fn inspect(&self) -> LayoutReply {
self.service.inspect()
}
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
self.service.project(action)
}
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
self.service.dispatch(action)
}
}
let backend = command_ready_backend();
let mut sink = FocusSink { service: backend.service, resolutions: std::cell::Cell::new(0) };
let mut catalogue = backend.catalogue;
let registry = sink.service.registry().clone();
let before = sink.inspect();
let original_catalogue = catalogue.state().clone();
let mut context = commands::PanelCommandContext { sink: &mut sink, registry: &registry, catalogue: &mut catalogue, web: false };
assert_eq!(
commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":u64::MAX}), &mut context).unwrap_err().rule,
Rule::Revision
);
assert_eq!(context.sink.inspect(), before);
let result = commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":before.revision}), &mut context).unwrap();
let reply: LayoutReply = serde_json::from_value(result).unwrap();
assert_eq!(reply.layout, context.sink.inspect().layout);
assert_eq!(reply.revision, context.sink.inspect().revision);
assert_eq!(reply.interaction, context.sink.inspect().interaction);
assert_eq!(reply.interaction.focused, Some(InstanceId::new("two").unwrap()));
assert_eq!(sink.resolutions.get(), 1, "revision rejection precedes live focus resolution");
assert_eq!(catalogue.state(), &original_catalogue, "focus does not change the saved arrangement");
}
#[test]
fn contextual_panel_commands_without_live_focus_refuse_without_effects_including_batches() {
use commands::LayoutCatalogue;
for action in ContextualPanelAction::ALL {
let mut backend = command_ready_backend();
let before = backend.service.inspect();
let catalogue = backend.catalogue.state().clone();
let reason = backend.execute(action.command_id(), &serde_json::json!({})).unwrap_err();
assert_eq!(reason.rule, Rule::Availability);
assert!(reason.detail.contains("live panel host keyboard context"));
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()})]).unwrap_err();
assert_eq!(reason.rule, Rule::Availability);
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()}), serde_json::json!({"command":"layout.get"})]).unwrap_err();
assert_eq!(reason.rule, Rule::Capability);
assert_eq!(backend.service.inspect(), before);
assert_eq!(backend.catalogue.state(), &catalogue);
}
}
#[test]
fn session_only_panel_commands_report_capability_without_touching_document_or_model() {
let mut session = crate::Session::default();
@@ -3846,7 +4086,11 @@ fn closed_panel_reopens_with_column_identity_width_rank_and_saved_weight() {
}]))
.unwrap();
service
.dispatch(transaction(vec![Mutation::Collapse { section: section("inner"), collapsed: true }, Mutation::Close { instance: id("two") }]))
.dispatch(transaction(vec![
Mutation::SetColumnWidth { column: column.clone(), width: 310.0 },
Mutation::Collapse { section: section("inner"), collapsed: true },
Mutation::Close { instance: id("two") },
]))
.unwrap();
let memory = service.layout().instance(&id("two")).unwrap().memory.clone();
assert_eq!(memory.column, Some(column.clone()));
+1 -1
View File
@@ -49,7 +49,7 @@ use crate::i18n::{format as trf, tr};
use crate::{ControlRequest, SolveApp};
use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus};
pub(crate) use panel::{left_inset, owns_keyboard_input, tool_space};
pub(crate) use panel::{finish_shared, left_inset, owns_keyboard_input, prepare_shared, request_new_chat, shared_content, tool_space};
pub use panel::{show, tip as panel_tip, toggle, toggle_shortcut};
/// Most entries kept in the conversation; older ones are dropped.
+131 -17
View File
@@ -56,6 +56,39 @@ const PREVIEW_MAX: (f32, f32) = (560.0, 380.0);
/// Show or hide the panel (a folded panel unfolds first).
pub fn toggle(app: &mut SolveApp) {
use solidcraft_engine::panels::{LayoutAction, Mutation};
let shared = app.panel_runtime().filter(|_| !app.preferences.agent_chat_popped).and_then(|runtime| {
let layout = runtime.layout().layout();
let panel = layout.instances.iter().find(|panel| panel.kind.0 == "solidcraft.assistant")?;
let section = layout.containing(&panel.id).ok();
let reveal = !app.agent_chat.open || !panel.requested_open || section.is_some_and(|section| section.collapsed || section.active != panel.id);
let mutations = if reveal {
let section = section.map_or_else(|| panel.memory.section.clone(), |section| section.id.clone());
let mut mutations = Vec::new();
if !panel.requested_open {
mutations.push(Mutation::Open { kind: panel.kind.clone(), instance: panel.id.clone(), destination: None });
}
mutations.push(Mutation::Collapse { section, collapsed: false });
mutations.push(Mutation::Activate { instance: panel.id.clone() });
mutations
} else {
vec![Mutation::Close { instance: panel.id.clone() }]
};
Some((reveal, LayoutAction::Apply { expected_revision: None, mutations }))
});
if let Some((reveal, action)) = shared {
let was_open = app.agent_chat.open;
if reveal {
app.agent_chat.open = true;
}
if let Err(reason) = app.dispatch_panel_layout(action) {
app.agent_chat.open = was_open;
let _ = crate::panel_runtime::refresh_production(app);
app.set_status(solidcraft_engine::panels::commands::error_text(reason), true);
}
return;
}
let c = &mut app.agent_chat;
if !c.open {
c.open = true;
@@ -80,7 +113,8 @@ pub fn tip(app: &SolveApp) -> String {
/// panel in this window, never label selections belonging to another native viewport.
/// Called before layout so even a click and a shortcut in the same frame are routed here.
pub(crate) fn owns_keyboard_input(app: &mut SolveApp, ctx: &egui::Context) -> bool {
let rect = app.agent_chat.panel_rect.filter(|_| shown(app) && app.agent_chat.open && !app.preferences.agent_chat_minimized);
let shared = app.universal_panel_host_enabled() && !app.preferences.agent_chat_popped;
let rect = app.agent_chat.panel_rect.filter(|_| shown(app) && app.agent_chat.open && (shared || !app.preferences.agent_chat_minimized));
let Some(rect) = rect else {
app.agent_chat.keyboard_owned = false;
return false;
@@ -113,12 +147,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
app.agent_chat.window_placed = false;
}
// New chat, when the chat has focus (its input, or the pointer over the panel).
let in_chat = ctx.memory(|m| m.has_focus(Id::unique("sc_agent_chat_input")))
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
request_new_chat(app);
}
new_chat_shortcut(app, &ctx);
if !app.agent_chat.open {
return;
}
@@ -180,6 +209,48 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
/// Prepare the shared docked presentation without polling its retained service.
/// Read the previous visible rectangle before clearing this frame's geometry.
pub(crate) fn prepare_shared(app: &mut SolveApp, ctx: &egui::Context) {
if shown(app) {
new_chat_shortcut(app, ctx);
#[cfg(not(target_arch = "wasm32"))]
if app.agent_chat.open && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, ctx);
}
}
app.agent_chat.panel_rect = None;
app.agent_chat.drop_rect = None;
app.agent_chat.dock_space = None;
app.agent_chat.window_placed = false;
}
/// Independent viewers and drop feedback keep their existing window policy.
pub(crate) fn finish_shared(app: &mut SolveApp, ctx: &egui::Context) {
if !shown(app) || !app.agent_chat.open {
app.agent_chat.keyboard_owned = false;
return;
}
if app.agent_chat.panel_rect.is_none() {
app.agent_chat.keyboard_owned = false;
}
#[cfg(not(target_arch = "wasm32"))]
gallery_window(app, ctx);
show_drop_hint(app, ctx);
}
/// Existing Assistant controls and conversation, inside the shared host's body.
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
let rect = ui.max_rect().intersect(ui.clip_rect());
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
return;
}
app.agent_chat.panel_rect = Some(rect);
app.agent_chat.drop_rect = Some(rect);
crate::scenario::publish_rect("agent_chat:panel", rect);
ui_kit::scope(ui, |ui| content(app, ui));
}
/// Horizontal room reserved before the history and browser when Assistant is on the left.
pub(crate) fn left_inset(app: &SolveApp, available: Rect) -> f32 {
if Container::for_layout(app.ui.layout, Surface::Panel).floating()
@@ -532,7 +603,7 @@ pub const NEW_CHAT_SHORTCUT: egui::KeyboardShortcut =
egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::L);
/// Clear the chat, asking first when something would be lost.
fn request_new_chat(app: &mut SolveApp) {
pub(crate) fn request_new_chat(app: &mut SolveApp) {
if new_chat_needs_confirm(app) {
app.agent_chat.confirm_new = true;
} else {
@@ -540,6 +611,14 @@ fn request_new_chat(app: &mut SolveApp) {
}
}
fn new_chat_shortcut(app: &mut SolveApp, ctx: &egui::Context) {
let in_chat = ctx.memory(|m| m.has_focus(Id::unique("sc_agent_chat_input")))
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
request_new_chat(app);
}
}
/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and
/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one.
pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> {
@@ -825,8 +904,14 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
header(app, ui, left);
content(app, ui);
}
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
#[cfg(not(target_arch = "wasm32"))]
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
}
#[cfg(target_arch = "wasm32")]
{
ui.label(tr("ui.agent_chat_desktop_only"));
@@ -837,6 +922,8 @@ fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
#[cfg(not(target_arch = "wasm32"))]
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = ui.cursor().top();
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, ui.ctx());
}
@@ -859,16 +946,34 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
});
});
}
// Discovery/install guidance must leave room for the pinned composer even when
// several agents are listed, or when a long message grows the editor.
egui::ScrollArea::vertical()
// Shared top controls must yield scroll space to the pinned composer, including
// when discovery lists several agents or a long message grows the editor.
let picker_max = if shared {
let t = Tokens::get();
let composer = crate::panel_runtime::shared_content_composer_height(ui.ctx())
.unwrap_or(48.0 + t.control_height * 3.0 + t.space_8 * 2.0 + t.space_6 * 3.0);
(ui.available_height() - composer - ui.spacing().item_spacing.y).max(0.0)
} else {
ui.available_height() / 3.0
};
let picker = egui::ScrollArea::vertical()
.id_salt("sc_agent_chat_picker_scroll")
.max_height(ui.available_height() / 3.0)
.min_scrolled_height(0.0)
.max_height(picker_max)
.auto_shrink([false, true])
.show(ui, |ui| picker(app, ui));
if state == State::NeedsAuth {
.show(ui, |ui| {
if shared {
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
}
picker(app, ui);
if shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
});
if !shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
// The input sits at the bottom of the panel and grows upwards: a long message makes its box
// taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It
// is a panel of its own at the bottom, as high as its content this frame, so the buttons under
@@ -887,7 +992,7 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
let mut remove_picked_at = None;
let frame = Container::new(ui.ctx(), Surface::Viewport).edge_frame(egui::Color32::TRANSPARENT);
let composer = Container::new(ui.ctx(), Surface::Viewport).edge("sc_agent_chat_composer", Edge::Bottom);
composer.resizable(false).show_separator_line(false).frame(frame).show(ui, |ui| {
let composer = composer.resizable(false).show_separator_line(false).frame(frame).show(ui, |ui| {
let footer = ui_kit::assistant_composer_footer(ui, |ui| {
let boxed = ui_kit::assistant_composer(ui, focused, |ui| {
// What Pick context chose for this message, each item with an ×.
@@ -919,6 +1024,7 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
})
.inner;
crate::scenario::publish_rect("agent_chat:input", r.rect);
crate::panel_runtime::register_content_focus(ui.ctx(), &r);
// macOS shows a window's cursors only while it is the active window: over the text box of a
// chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat
// window becomes active, so the text cursor shows and the first click places the caret.
@@ -1031,15 +1137,20 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::scenario::publish_rect("agent_chat:composer_box", boxed.response.rect);
});
crate::scenario::publish_rect("agent_chat:composer_footer", footer.response.rect);
// The enclosing bottom panel can clamp its response to the remaining
// viewport. Reserve the actual footer content on the next frame.
footer.response.rect.height()
});
crate::panel_runtime::report_content_composer_height(ui.ctx(), composer.inner);
if let Some(at) = remove_picked_at {
remove_picked(app, at);
}
if pick_clicked {
start_pick(app);
}
egui::ScrollArea::vertical()
let conversation = egui::ScrollArea::vertical()
.id_salt("sc_agent_chat_scroll")
.min_scrolled_height(0.0)
.max_height(ui.available_height())
.auto_shrink([false, false])
.stick_to_bottom(true)
@@ -1051,6 +1162,8 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
// Last, so the scroll goes to this area and not to one nested in the conversation.
scroll_while_selecting(ui);
});
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
if esc || stop_clicked {
cancel(app);
}
@@ -1931,6 +2044,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
(Some((i, _)), Some(buf)) if *i == n => {
let te = ui_kit::message_field(ui, Id::unique(("sc_agent_pending_input", n)), buf, "");
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
crate::panel_runtime::register_content_focus(ui.ctx(), &te);
ui.horizontal(|ui| {
if ui_kit::button(ui, tr("ui.agent_chat_save"), ui_kit::Button::Normal).clicked() {
edit_done = Some((n, Some(buf.clone())));
+32 -2
View File
@@ -1362,6 +1362,14 @@ fn render_card(app: &mut SolveApp, ui: &mut egui::Ui) {
);
if let Some(rect) = rect {
crate::scenario::publish_rect("browser:card", rect);
}
render_appearances(app, ui, rect);
}
/// Keep the existing appearances companion below either Browser host.
pub(crate) fn render_appearances(app: &mut SolveApp, ui: &mut egui::Ui, browser: Option<Rect>) {
if let Some(rect) = browser {
let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap());
crate::render_workspace::appearances_card(app, ui, crate::ui_kit::Panel::stack_next_bounds(bounds, rect));
} else {
crate::shell::register_panel(app, "render:appearances", None, None);
@@ -1429,8 +1437,27 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top() + 1.0, t.toolbar.lerp_to_gamma(t.border, 0.5));
ui.add_space(t.space_3);
}
rows_contents(app, ui, collapsible && !stacked);
}
/// Existing Browser content without placement, title or collapse chrome.
pub(crate) fn panel_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
sync_reveals(app);
let render = crate::render_workspace::active(app);
if render {
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("sc_compact_tree"), true));
}
if !crate::render_workspace::browser_mode(app, ui) {
rows_contents(app, ui, true);
}
if render {
ui.ctx().data_mut(|data| data.remove::<bool>(egui::Id::unique("sc_compact_tree")));
}
}
fn rows_contents(app: &mut SolveApp, ui: &mut egui::Ui, shrink_height: bool) {
crate::edit_in_place::browser_strip(app, ui);
if collapsible && !stacked {
if shrink_height {
browser_rows_hugging(app, ui);
} else {
browser_rows(app, ui);
@@ -1569,7 +1596,8 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
prepare_model(app);
let mut cache = app.tree.cache.take().unwrap_or_default();
cache.prepare(app);
egui::ScrollArea::vertical()
let top = ui.cursor().top();
let output = egui::ScrollArea::vertical()
// The card may animate all the way down to its header.
.min_scrolled_height(0.0)
.auto_shrink([false, shrink_height])
@@ -1586,6 +1614,7 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
component_rows(app, ui, 0, 0, acts, &mut cache);
}
});
crate::panel_runtime::report_content_extent(ui, top, output.content_size.y, true);
app.tree.cache = Some(cache);
}
@@ -3100,6 +3129,7 @@ fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, label_left: f
ui.painter().rect_filled(edit_rect, 3.0, Tokens::get().field);
let id = ui.scope_id().with(("browser_rename", &e.row_key));
let response = crate::ui_kit::inline_name(ui, edit_rect, id, &mut rename.text);
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
crate::scenario::publish_handle(&format!("rename:{}", e.key), edit_rect.center());
let was_focused = rename.focused;
if !rename.focused {
+133 -1
View File
@@ -27,7 +27,7 @@
//! section the viewport shows (no window needed); the ground grid and the origin's axes as the
//! window shows them, unless `grid` or `axes` says
//! - `ui.icon_audit {reset?, stop?}`: collect/drain requested icon names, resolved masters and placeholder flags;
//! - `ui.panel_fixture {enabled?, preset?, translucency_percent?}`: opt-in synthetic content in the real panel host;
//! - `ui.panel_fixture {enabled?, preset?, translucency_percent?, reduced_motion?}`: opt-in synthetic content in the real panel host;
//! `{}` observes the current host, probe and Session without ticking or model commands
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
@@ -287,6 +287,13 @@ fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result<Value, String>
for row in rows.iter_mut().filter_map(Value::as_object_mut) {
let key = row.get("id").and_then(Value::as_str).and_then(|id| crate::keymap::effective(app, id));
row.insert("shortcut".into(), json!(key));
if let Some(action) =
row.get("id").and_then(Value::as_str).and_then(solidcraft_engine::panels::ContextualPanelAction::from_command_id)
{
let reason = app.panel_contextual_disabled_reason(action);
row.insert("enabled".into(), json!(reason.is_none()));
row.insert("disabled_reason".into(), json!(reason));
}
}
}
return Ok(commands);
@@ -1882,6 +1889,74 @@ mod tests {
use super::*;
use crate::Services;
#[test]
fn contextual_command_metadata_matches_live_focus_without_side_effects() {
fn metadata(app: &mut SolveApp, reason_key: Option<&str>) {
let enabled = reason_key.is_none();
let document = app.session.doc.clone();
let model = app.session.model.state();
let revision = app.session.revision;
let evaluations = app.session.evaluation_count();
let undo = app.session.undo.len();
let before = crate::panel_runtime::inspect_host_specimen(app).unwrap();
let command = solidcraft_engine::find_command("panel.focus.next").unwrap();
let interactive = crate::render_workspace::interactive_command_info(app, command);
let commands = execute(app, "engine.commands", json!({})).unwrap();
let row = commands.as_array().unwrap().iter().find(|row| row["id"] == "panel.focus.next").unwrap();
assert_eq!(interactive.enabled, enabled);
assert_eq!(row["enabled"], enabled);
assert_eq!(row["disabled_reason"], json!(interactive.disabled_reason));
assert_eq!(row["disabled_reason"], json!(reason_key.map(|key| app.tr(key))), "each context reports its exact localized refusal");
assert_eq!(crate::panel_runtime::inspect_host_specimen(app).unwrap(), before);
assert!(std::sync::Arc::ptr_eq(&document, &app.session.doc));
assert!(std::sync::Arc::ptr_eq(&model, &app.session.model.state()));
assert_eq!(app.session.revision, revision);
assert_eq!(app.session.evaluation_count(), evaluations);
assert_eq!(app.session.undo.len(), undo);
}
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
metadata(&mut h.app, Some("ui.panel_host.rule.availability"));
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"docks"})).unwrap();
metadata(&mut h.app, Some("ui.panel_host.rule.availability")); // Host exists, but has no presented frame.
h.frames(4);
let header = h
.app
.universal_panel_frame()
.unwrap()
.controls
.iter()
.find(|control| {
control.enabled
&& matches!(
control.target,
crate::panel_runtime::HostControlTarget::Pointer(crate::panel_runtime::HostPointerTarget::Header { .. })
)
})
.unwrap()
.clone();
h.ctx.memory_mut(|memory| memory.request_focus(header.id));
metadata(&mut h.app, None);
h.ctx.memory_mut(|memory| memory.request_focus(egui::Id::unique("foreign-contextual-focus")));
metadata(&mut h.app, Some("ui.panel_host.rule.availability"));
h.ctx.memory_mut(|memory| memory.request_focus(header.id));
let old_document_id = h.app.session.document_id.clone();
h.app.session.document_id = "metadata-incoming-document".into();
metadata(&mut h.app, Some("ui.panel_host.rule.availability"));
h.app.session.document_id = old_document_id;
metadata(&mut h.app, None);
let section = h.app.universal_panel_frame().unwrap().layout.sections[0].section.clone();
let stale_revision = h.app.universal_panel_frame().unwrap().layout.revision;
h.app
.dispatch_panel_layout(solidcraft_engine::panels::LayoutAction::Apply {
expected_revision: Some(stale_revision),
mutations: vec![solidcraft_engine::panels::Mutation::Collapse { section, collapsed: true }],
})
.unwrap();
assert_ne!(h.app.panel_runtime().unwrap().layout().inspect().revision, stale_revision);
metadata(&mut h.app, Some("ui.panel_host.rule.revision"));
}
#[test]
fn panel_fixture_validates_whole_request_before_activation() {
let mut app = SolveApp::new(Session::default(), Services::default());
@@ -1903,6 +1978,9 @@ mod tests {
json!({"preset":"surfaces","translucency_percent":"50"}),
json!({"preset":"surfaces","translucency_percent":null}),
json!({"translucency_percent":50}),
json!({"reduced_motion":true}),
json!({"preset":"docks","reduced_motion":null}),
json!({"enabled":false,"reduced_motion":true}),
json!({"enabled":false,"translucency_percent":50}),
] {
assert!(crate::panel_runtime::control_specimen(&mut app, &params).is_err(), "{params}");
@@ -2032,6 +2110,60 @@ mod tests {
assert!(crate::panel_runtime::control_specimen(&mut h.app, &json!({"translucency_percent":50})).is_err());
}
#[test]
fn panel_fixture_language_and_motion_refresh_preserve_layout_controllers_and_session() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.language = "en".into();
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"surfaces"})).unwrap();
h.frames(4);
let runtime = h.app.panel_runtime().unwrap();
let instance = runtime.layout().layout().instances.iter().find(|instance| instance.kind.0 == "fixture.editor").unwrap().id.clone();
h.app.run("panel.focus", json!({"target":{"type":"instance","id":instance}})).unwrap();
h.frames(4);
let owner = h.app.panel_runtime().unwrap().instance_owner(&instance).unwrap();
let draft_id = egui::Id::unique(("specimen-draft", &instance, &owner));
h.ctx.memory_mut(|memory| memory.request_focus(draft_id));
h.frame_with(vec![egui::Event::Text("retained locale selection".into())]);
let mut state = egui::TextEdit::load_state(&h.ctx, draft_id).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
state.store(&h.ctx, draft_id);
h.frames(1);
let selection = egui::TextEdit::load_state(&h.ctx, draft_id).unwrap().cursor.char_range();
assert!(!selection.unwrap().is_empty(), "exercise an actual nonempty draft selection");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(draft_id));
let before = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
let document = h.app.session.doc.clone();
let model = h.app.session.model.state();
let camera = h.app.cam;
for (language, expected) in [("ar", "rtl"), ("he", "rtl"), ("en", "ltr")] {
h.app.preferences.language = language.into();
h.frames(4);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(draft_id), "locale changes retain the actual focused draft");
assert_eq!(egui::TextEdit::load_state(&h.ctx, draft_id).unwrap().cursor.char_range(), selection);
let after = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
assert_eq!(after["host"]["direction"], expected);
assert_eq!(after["runtime"]["layout"], before["runtime"]["layout"]);
for (old, new) in
before["runtime"]["probe"]["controllers"].as_array().unwrap().iter().zip(after["runtime"]["probe"]["controllers"].as_array().unwrap())
{
assert_eq!(new["controller_identity"], old["controller_identity"]);
assert_eq!(new["draft"], old["draft"]);
assert_eq!(new["transaction_live"], old["transaction_live"]);
}
assert!(std::sync::Arc::ptr_eq(&document, &h.app.session.doc));
assert!(std::sync::Arc::ptr_eq(&model, &h.app.session.model.state()));
assert_eq!(h.app.cam, camera);
}
for reduced in [true, false] {
let updated = crate::panel_runtime::control_specimen(&mut h.app, &json!({"reduced_motion":reduced})).unwrap();
assert_eq!(updated["runtime"]["reduced_motion"], reduced);
assert_eq!(updated["runtime"]["layout"], before["runtime"]["layout"]);
h.frames(4);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(draft_id), "motion updates retain the actual focused draft");
assert_eq!(egui::TextEdit::load_state(&h.ctx, draft_id).unwrap().cursor.char_range(), selection);
}
}
#[test]
fn panel_fixture_teardown_honors_live_editor_guard_atomically() {
fn click_visible_guard(h: &mut crate::scenario::Harness, guard: &str) {
+24
View File
@@ -202,6 +202,16 @@ pub fn effective_language(preference: &str) -> &'static str {
Locale::for_language(preference).0.id
}
/// Interface direction follows the selected catalog, including English fallback.
/// Message contents and document coordinates never determine workspace direction.
pub(crate) fn panel_direction(preference: &str) -> solidcraft_engine::panels::Direction {
use solidcraft_engine::panels::Direction;
match effective_language(preference) {
"ar" | "he" => Direction::Rtl,
_ => Direction::Ltr,
}
}
/// Immutable locale reference. Catalogs parse once; lookups are logarithmic.
#[derive(Clone, Copy)]
pub struct Locale(&'static Catalog);
@@ -468,6 +478,20 @@ pub fn tr_engine_error(error: &str) -> String {
#[cfg(test)]
mod tests {
#[test]
fn panel_direction_follows_effective_catalog_including_missing_message_fallback() {
use solidcraft_engine::panels::Direction;
for language in ["ar", "he"] {
assert_eq!(super::panel_direction(language), Direction::Rtl);
// An untranslated message may be English without making the UI LTR.
assert_eq!(super::Locale::for_language(language).text("unregistered.caption"), "unregistered.caption");
}
for language in ["en", "de", "ja", "unknown"] {
assert_eq!(super::panel_direction(language), Direction::Ltr);
}
assert_eq!(super::panel_direction(super::SYSTEM), super::panel_direction(super::system_language()));
}
/// The system's locale tags pick a catalog: exact tag, then base language, then English (#10138).
#[test]
fn system_tags_match_the_catalog_exact_then_base_then_english() {
+79 -6
View File
@@ -187,7 +187,8 @@ impl Keymap {
Value::Object(self.custom.iter().map(|(id, keys)| (id.clone(), keys_value(keys))).collect())
}
pub fn load(&mut self, v: &Value) {
self.custom = v.as_object().map(|o| o.iter().take(2000).filter_map(|(k, v)| Some((k.clone(), saved_keys(v)?))).collect()).unwrap_or_default();
self.custom =
v.as_object().map(|o| o.iter().take(2000).filter_map(|(k, v)| Some((k.clone(), saved_keys(k, v)?))).collect()).unwrap_or_default();
}
}
@@ -201,7 +202,7 @@ pub(crate) fn keys_value(keys: &[String]) -> Value {
}
/// A command's saved keys: `""` (none), one key, or a list of them.
pub(crate) fn saved_keys(v: &Value) -> Option<Vec<String>> {
pub(crate) fn saved_keys(id: &str, v: &Value) -> Option<Vec<String>> {
let raw: Vec<&str> = match v {
Value::String(key) => vec![key.as_str()],
Value::Array(keys) => keys.iter().filter_map(Value::as_str).filter(|k| !k.is_empty()).take(MAX_KEYS).collect(),
@@ -213,7 +214,7 @@ pub(crate) fn saved_keys(v: &Value) -> Option<Vec<String>> {
}
let mut keys = Vec::new();
for key in raw.into_iter().filter(|key| !key.is_empty()) {
let Some(key) = canonical_key(key).filter(|key| !RESERVED.contains(&key.as_str())) else {
let Some(key) = canonical_key(key).filter(|key| !reserved_for(id, key)) else {
log::warn!("invalid or reserved saved shortcut ignored");
continue;
};
@@ -224,10 +225,41 @@ pub(crate) fn saved_keys(v: &Value) -> Option<Vec<String>> {
Some(keys)
}
/// Panel bindings own arrows only while shared chrome has actual keyboard focus.
/// Every other application-reserved key remains protected for every command.
fn reserved_for(id: &str, key: &str) -> bool {
RESERVED.contains(&key)
&& !(solidcraft_engine::panels::ContextualPanelAction::from_command_id(id).is_some()
&& key.rsplit('+').next().is_some_and(|name| matches!(name, "Left" | "Right" | "Up" | "Down")))
}
/// Effective registered chords for the host's deferred, focus-checked input path.
pub(crate) fn panel_shortcuts(app: &SolveApp) -> Vec<(solidcraft_engine::panels::ContextualPanelAction, egui::KeyboardShortcut)> {
use solidcraft_engine::panels::ContextualPanelAction;
ContextualPanelAction::ALL
.into_iter()
.flat_map(|action| {
keys(app, action.command_id()).into_iter().take(MAX_KEYS).filter_map(move |key| {
let mut shortcut = chord(&key)?;
// Ctrl is the existing primary-modifier alias; explicit Ctrl+Cmd
// continues to require both physical modifiers.
if shortcut.modifiers.command && !shortcut.modifiers.mac_cmd {
shortcut.modifiers.ctrl = false;
}
Some((action, shortcut))
})
})
.collect()
}
/// Read the same chord that recording a key produces, independent of case and modifier order.
/// The last token is the key; the preceding tokens are modifiers. Keep the historical primary
/// Ctrl/Cmd alias, explicit Control+Command chords and old physical modifier-key names.
fn canonical_key(text: &str) -> Option<String> {
chord(text).map(|shortcut| key_name(shortcut.logical_key, shortcut.modifiers))
}
fn chord(text: &str) -> Option<egui::KeyboardShortcut> {
if text.len() > 256 {
return None;
}
@@ -265,7 +297,7 @@ fn canonical_key(text: &str) -> Option<String> {
"return" => egui::Key::Enter,
_ => egui::Key::ALL.iter().copied().find(|key| key.name().eq_ignore_ascii_case(name)).or_else(|| egui::Key::from_name(name))?,
};
Some(key_name(key, mods))
Some(egui::KeyboardShortcut::new(mods, key))
}
/// A key press as a shortcut name: `E`, `Shift+E`, `Ctrl+Alt+K`, `F5`, `Delete`.
@@ -441,7 +473,9 @@ pub fn command_for(app: &SolveApp, key: &str) -> Option<&'static str> {
let enabled: Vec<&'static str> = commands_for(app, key)
.into_iter()
.filter(|id| {
(scope(id) != Scope::Render || here == Scope::Render)
// Shared panel bindings run only in the host's late input path.
solidcraft_engine::panels::ContextualPanelAction::from_command_id(id).is_none()
&& (scope(id) != Scope::Render || here == Scope::Render)
&& (here != Scope::Render || scope(id) != Scope::Sketch)
&& solidcraft_engine::find_command(id).is_some_and(|c| crate::render_workspace::interactive_command_info(app, c).enabled)
})
@@ -520,7 +554,7 @@ pub fn bind_at(app: &mut SolveApp, id: &str, slot: Option<usize>, key: &str, rep
let spec = solidcraft_engine::find_command(id).ok_or_else(|| format!("no command `{id}`"))?;
let canonical = if key.is_empty() { String::new() } else { canonical_key(key).ok_or("unknown shortcut key")? };
let key = canonical.as_str();
if RESERVED.contains(&key) {
if reserved_for(id, key) {
return Err(format!("{key} is used by SolidCraft itself"));
}
let mut list = keys(app, id);
@@ -1050,6 +1084,45 @@ pub fn capturing(app: &SolveApp) -> bool {
mod tests {
use super::*;
#[test]
fn panel_bindings_preserve_contextual_arrows_and_rebind_clear_conflict_and_reset() {
use solidcraft_engine::panels::ContextualPanelAction;
let mut a = app();
let defaults = panel_shortcuts(&a);
assert_eq!(defaults.len(), 4);
for (action, shortcut) in defaults {
assert_eq!(shortcut.modifiers, egui::Modifiers::NONE);
assert!(keys(&a, action.command_id()).contains(&shortcut.logical_key.name().to_string()));
assert_eq!(command_for(&a, shortcut.logical_key.name()), None, "arrows stay out of global dispatch");
}
bind(&mut a, "panel.sash.left", "Ctrl+Shift+Left", false).unwrap();
assert!(bind(&mut a, "solid.box", "Ctrl+Left", true).is_err());
assert!(bind(&mut a, "panel.focus.next", "Escape", true).is_err());
assert!(bind(&mut a, "panel.focus.next", "Ctrl+Z", true).is_err());
assert!(bind(&mut a, "panel.focus.next", "Right", false).is_err(), "existing sash binding conflicts");
bind(&mut a, "panel.focus.next", "Right", true).unwrap();
assert!(keys(&a, "panel.sash.right").is_empty());
bind(&mut a, "panel.toggle", "Ctrl+Alt+F12", true).unwrap();
assert_eq!(command_for(&a, "Ctrl+Alt+F12"), None);
let saved = a.keymap.prefs();
let mut restored = app();
restored.keymap.load(&saved);
assert_eq!(restored.keymap.prefs(), saved);
assert_eq!(keys(&restored, "panel.sash.left"), ["Ctrl+Shift+Left"]);
bind(&mut restored, "panel.sash.left", "", false).unwrap();
assert!(!panel_shortcuts(&restored).iter().any(|(action, _)| *action == ContextualPanelAction::ResizeLeft));
reset(&mut restored, Some("panel.sash.left"));
assert_eq!(keys(&restored, "panel.sash.left"), ["Left"]);
reset(&mut restored, None);
assert_eq!(panel_shortcuts(&restored).len(), 4);
assert!(keys(&restored, "panel.focus.next").is_empty());
let saved = json!({"panel.sash.up":"Ctrl+Up", "solid.box":"Ctrl+Up", "panel.toggle":"Ctrl+S"});
restored.keymap.load(&saved);
assert_eq!(keys(&restored, "panel.sash.up"), ["Ctrl+Up"]);
assert!(keys(&restored, "solid.box").is_empty());
assert!(keys(&restored, "panel.toggle").is_empty());
}
#[test]
fn assistant_shortcut_can_be_rebound_cleared_saved_and_reset() {
let mut h = crate::scenario::Harness::new();
+4
View File
@@ -541,6 +541,8 @@ pub(crate) enum EscapeOwner {
pub struct SolveApp {
/// Common panel foundation, internal opt-in; stays on the app across document tabs.
pub(crate) panel_runtime: Option<panel_runtime::AppPanelRuntime>,
/// Current immutable geometry while production content borrows the app.
pub(crate) panel_content_frame: Option<panel_runtime::HostFrame>,
pub(crate) panel_layout_preferences: panel_runtime::PanelLayoutPreferences,
pub blur: blur::State,
pub(crate) blur_session: blur::Session,
@@ -853,6 +855,7 @@ impl SolveApp {
let (shot_tx, shot_rx) = std::sync::mpsc::channel();
let mut app = SolveApp {
panel_runtime: None,
panel_content_frame: None,
panel_layout_preferences: panel_runtime::PanelLayoutPreferences::default(),
viewport_status: Default::default(),
blur: Default::default(),
@@ -3506,6 +3509,7 @@ impl SolveApp {
// The status bar first: it spans the window's width below History and the side panels.
if self.universal_panel_host_enabled() {
self.universal_workspace_ui(ui);
semantic_content::finalize(self, ui.ctx());
// Browser-owned editors retain their independent legacy windows,
// drafts and capture questions while the Browser host is suppressed.
browser::editors(self, ui);
+201 -22
View File
@@ -16,6 +16,7 @@ pub trait AppPanelServices {
}
pub struct AppPanelRuntime {
pub(super) production: Option<super::production::ProductionState>,
runtime: PanelRuntime,
services: Box<dyn AppPanelServices>,
native: Box<dyn NativeHostAdapter>,
@@ -33,6 +34,7 @@ impl AppPanelRuntime {
return Err(error(Rule::Lifecycle, "install the app adapter before creating or restoring native placement"));
}
Ok(Self {
production: None,
runtime,
services,
native: Box::<InAppNativeAdapter>::default(),
@@ -44,6 +46,47 @@ impl AppPanelRuntime {
preferences: None,
})
}
/// Native composition supplies preferences; standalone runtimes keep their explicit direction.
fn sync_native_direction(&mut self, direction: Direction) -> PanelResult<()> {
if self.runtime.environment().direction == direction {
return Ok(());
}
let mut environment = self.runtime.environment().clone();
environment.direction = direction;
self.with_adapters(|runtime, _native, services| runtime.set_environment(environment, services.local))?;
if let Some(host) = &mut self.host {
host.invalidate_geometry();
host.reconcile_scope(&self.runtime);
}
Ok(())
}
pub(super) fn set_motion(&mut self, motion: Motion) -> PanelResult<()> {
let mut environment = self.runtime.environment().clone();
environment.motion = motion;
self.with_adapters(|runtime, _native, services| runtime.set_environment(environment, services.local))
}
pub(crate) fn set_keyboard_shortcuts(&mut self, shortcuts: Vec<(ContextualPanelAction, egui::KeyboardShortcut)>) {
if let Some(host) = &mut self.host {
host.set_keyboard_shortcuts(shortcuts);
}
}
fn contextual_availability(&self, session: &Session, action: ContextualPanelAction) -> PanelResult<()> {
self.runtime.require_live()?;
if self.preferences.as_ref().is_some_and(|state| state.pending.is_some())
|| self.runtime.environment().document.as_ref().map(|document| document.0.as_str()) != Some(session.document_id.as_str())
{
return Err(error(Rule::Availability, "panel command context is not reconciled"));
}
self.host
.as_ref()
.ok_or_else(|| error(Rule::Availability, "shared panel host unavailable"))?
.contextual_action(&self.runtime, action)
.map(|_| ())
}
pub fn last_error(&self) -> Option<&PanelError> {
self.last_error.as_ref()
}
@@ -514,17 +557,27 @@ impl AppPanelRuntime {
pub fn show_host_deferred(&mut self, session: &Session, ui: &mut egui::Ui) -> PanelResult<()> {
self.show_host_impl(session, ui, true)
}
fn show_host_impl(&mut self, session: &Session, ui: &mut egui::Ui, deferred: bool) -> PanelResult<()> {
let geometry = self.host.as_ref().and_then(WorkspacePanelHost::prepared_geometry);
self.reconcile(session)?;
self.refresh_metrics()?;
if let Some(host) = &mut self.host
&& host.frame().is_none()
&& let Some((domain, capacity, chrome)) = geometry
{
host.prepare_in_domain(&self.runtime, domain, capacity, &chrome)?;
ui.ctx().request_repaint();
fn show_production_host(&mut self, app: &mut crate::SolveApp, ui: &mut egui::Ui) -> PanelResult<()> {
self.prepare_host_paint(&app.session, ui)?;
app.panel_content_frame = self.host.as_ref().and_then(WorkspacePanelHost::frame).cloned();
let mut services = self.services.services();
let host = self.host.as_mut().ok_or_else(|| error(Rule::Lifecycle, "universal panel host disabled"))?;
let pending = self.preferences.as_ref().is_some_and(|state| state.pending.is_some());
if pending {
host.cancel_gesture();
}
let state = self.production.clone().ok_or_else(|| error(Rule::Lifecycle, "production content binding unavailable"))?;
let mut content = super::production::ProductionFrame { app, state };
let result = ui
.add_enabled_ui(!pending, |ui| host.show_deferred_with_content(ui, &mut self.runtime, self.native.as_mut(), &mut services, &mut content))
.inner;
if pending {
host.cancel_gesture();
}
result
}
fn show_host_impl(&mut self, session: &Session, ui: &mut egui::Ui, deferred: bool) -> PanelResult<()> {
self.prepare_host_paint(session, ui)?;
let mut services = self.services.services();
let host = self.host.as_mut().ok_or_else(|| error(Rule::Lifecycle, "universal panel host disabled"))?;
if self.preferences.as_ref().is_some_and(|state| state.pending.is_some()) {
@@ -541,6 +594,19 @@ impl AppPanelRuntime {
host.show(ui, &mut self.runtime, self.native.as_mut(), &mut services, self.catalogue.as_mut())
}
}
fn prepare_host_paint(&mut self, session: &Session, ui: &egui::Ui) -> PanelResult<()> {
let geometry = self.host.as_ref().and_then(WorkspacePanelHost::prepared_geometry);
self.reconcile(session)?;
self.refresh_metrics()?;
if let Some(host) = &mut self.host
&& host.frame().is_none()
&& let Some((domain, capacity, chrome)) = geometry
{
host.prepare_in_domain(&self.runtime, domain, capacity, &chrome)?;
ui.ctx().request_repaint();
}
Ok(())
}
/// Finish after every window/modal has registered its current layer. Refresh
/// current Session again before dispatch, without a background update.
pub fn finish_host(&mut self, session: &Session, ui: &mut egui::Ui) -> PanelResult<()> {
@@ -736,6 +802,9 @@ impl AdmittedActionSink<'_, '_, '_> {
}
}
impl ActionSink for AdmittedActionSink<'_, '_, '_> {
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
self.host.as_ref().ok_or_else(|| error(Rule::Availability, "shared panel host unavailable"))?.contextual_action(self.live.runtime, action)
}
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
self.project_owned(action, None)
}
@@ -753,6 +822,9 @@ impl ActionSink for AdmittedActionSink<'_, '_, '_> {
}
}
impl ActionSink for CommandActionSink<'_> {
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
self.live.resolve_contextual(action)
}
fn project_in_context(&self, action: LayoutAction, context: &LayoutPreparationContext) -> PanelResult<Layout> {
self.live.project_in_context(action, context)
}
@@ -775,7 +847,20 @@ impl ActionSink for CommandActionSink<'_> {
}
impl crate::SolveApp {
/// Read-only command metadata uses the exact host eligibility used at execution.
pub(crate) fn panel_contextual_disabled_reason(&self, action: ContextualPanelAction) -> Option<String> {
let eligibility = self
.panel_runtime
.as_ref()
.ok_or_else(|| error(Rule::Availability, "shared panel host unavailable"))
.and_then(|integration| integration.contextual_availability(&self.session, action));
eligibility
.err()
.map(|reason| crate::i18n::Locale::for_language(&self.preferences.language).text(super::workspace::rule_key(reason.rule)).to_owned())
}
pub fn run_panel_command(&mut self, id: &str, params: &serde_json::Value) -> PanelResult<serde_json::Value> {
super::production::refresh_production(self)?;
// Parse even without a runtime: hostile/unknown fields never become a
// misleading capability success or reach the document command path.
match self.panel_runtime.as_mut() {
@@ -802,10 +887,14 @@ impl crate::SolveApp {
Ok(())
}
pub fn universal_panel_host_enabled(&self) -> bool {
self.panel_runtime.as_ref().is_some_and(|integration| integration.host.is_some())
self.panel_content_frame.is_some() || self.panel_runtime.as_ref().is_some_and(|integration| integration.host.is_some())
}
pub fn universal_panel_frame(&self) -> Option<&HostFrame> {
self.panel_runtime.as_ref()?.host.as_ref()?.frame()
self.panel_runtime
.as_ref()
.and_then(|integration| integration.host.as_ref())
.and_then(WorkspacePanelHost::frame)
.or(self.panel_content_frame.as_ref())
}
/// Internal opt-in only: no preference, control method, command or default change.
pub fn enable_panel_runtime(&mut self, runtime: PanelRuntime, services: Box<dyn AppPanelServices>) -> PanelResult<()> {
@@ -827,6 +916,7 @@ impl crate::SolveApp {
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.set_native_adapter(native)
}
pub fn dispatch_panel_layout(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
super::production::refresh_production(self)?;
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.dispatch(&self.session, action)
}
pub fn mount_panels(&mut self, resolved: &ResolvedLayout, host: &mut dyn PanelHost) -> PanelResult<()> {
@@ -842,6 +932,7 @@ impl crate::SolveApp {
)
}
pub fn panel_content_action(&mut self, instance: &InstanceId, action: &ControlId) -> PanelResult<()> {
super::production::refresh_production(self)?;
self.panel_runtime.as_mut().ok_or_else(|| error(Rule::Lifecycle, "panel runtime disabled"))?.content_action(&self.session, instance, action)
}
pub fn tick_panel_runtime(&mut self) -> PanelResult<()> {
@@ -858,7 +949,36 @@ impl crate::SolveApp {
}
}
pub(crate) fn sync_production_setting(app: &mut crate::SolveApp) -> PanelResult<()> {
let production = app.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some());
if app.preferences.shared_panel_layout && app.panel_runtime.is_none() {
super::production::enable_production(app)
} else if !app.preferences.shared_panel_layout && production {
app.disable_panel_runtime()
} else {
Ok(())
}
}
pub(crate) fn tick_app(app: &mut crate::SolveApp, ctx: &egui::Context) {
// Switching placement systems waits until Preferences Apply/Cancel settles
// its snapshot. Content state itself always remains owned by the app.
if !app.prefs_window.open
&& let Err(reason) = sync_production_setting(app)
{
app.set_status(crate::i18n::tr(super::workspace::rule_key(reason.rule)).to_owned(), true);
if let Some(integration) = &mut app.panel_runtime {
integration.last_error = Some(reason);
}
}
if let Err(reason) = super::production::drain_production_actions(app)
.and_then(|()| super::production::sync_production_visibility(app))
.and_then(|()| super::production::refresh_production(app))
&& let Some(integration) = &mut app.panel_runtime
{
integration.last_error = Some(reason);
}
let direction = crate::i18n::panel_direction(&app.preferences.language);
if let Some(integration) = &mut app.panel_runtime {
let frame = ctx.cumulative_frame_nr();
if integration.last_tick_frame == Some(frame) {
@@ -866,7 +986,7 @@ pub(crate) fn tick_app(app: &mut crate::SolveApp, ctx: &egui::Context) {
}
integration.last_tick_frame = Some(frame);
// Errors remain inspectable without inventing untranslated production UI.
let result = integration.tick(&app.session);
let result = integration.sync_native_direction(direction).and_then(|()| integration.tick(&app.session));
if result.is_err() || !integration.preferences.as_ref().is_some_and(|state| state.failed) {
integration.last_error = result.err();
}
@@ -919,11 +1039,24 @@ pub(crate) fn reserve_host_input(app: &mut crate::SolveApp, ctx: &egui::Context)
}
pub(crate) fn finish_app_host(app: &mut crate::SolveApp, ui: &mut egui::Ui) {
if let Err(reason) = super::production::sync_production_visibility(app).and_then(|()| super::production::refresh_production(app))
&& let Some(integration) = &mut app.panel_runtime
{
integration.last_error = Some(reason);
return;
}
if let Some(integration) = &mut app.panel_runtime
&& let Err(reason) = integration.finish_host(&app.session, ui)
{
integration.last_error = Some(reason);
}
if let Err(reason) = super::production::drain_production_actions(app)
.and_then(|()| super::production::sync_production_visibility(app))
.and_then(|()| super::production::refresh_production(app))
&& let Some(integration) = &mut app.panel_runtime
{
integration.last_error = Some(reason);
}
}
impl crate::SolveApp {
@@ -959,11 +1092,15 @@ impl crate::SolveApp {
} else {
// The root Assistant panel is suppressed. Forget only presentation
// geometry and placement, so a later pop uses the saved window place.
self.agent_chat.panel_rect = None;
self.agent_chat.drop_rect = None;
self.agent_chat.dock_space = None;
self.agent_chat.window_placed = false;
crate::agent_chat::poll(self, ui.ctx());
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) {
crate::agent_chat::prepare_shared(self, ui.ctx());
} else {
self.agent_chat.panel_rect = None;
self.agent_chat.drop_rect = None;
self.agent_chat.dock_space = None;
self.agent_chat.window_placed = false;
}
}
self.shell.browser = None;
self.shell.browser_hidden = false;
@@ -993,6 +1130,31 @@ impl crate::SolveApp {
} else {
self.shell.cards.clear();
}
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) && crate::render_workspace::active(self) {
// These remain legacy content surfaces. Their actual edge reservations
// constrain panel placement without changing the full render domain.
crate::render_ui::shots_strip(self, ui);
crate::render_workspace::panel(self, ui);
if self.ui.show_browser && !crate::render_ui::focused(self) {
// Keep the unmigrated library in its existing lower card slot.
// Reserve its real top edge before admitting shared placements.
let bounds = crate::shell::panel_bounds(self, ui.available_rect_before_wrap());
let upper = crate::ui_kit::Panel::stack_first_bounds(bounds);
crate::render_workspace::appearances_card(self, ui, crate::ui_kit::Panel::stack_next_bounds(bounds, upper));
}
for (id, (bounds, edge)) in &self.shell.workspace_panels {
if *id == "render:appearances" {
placement.max.y = bounds.top().clamp(placement.top(), placement.bottom());
continue;
}
match edge {
Some(crate::ui_kit::Edge::Left) => placement.min.x = bounds.right().clamp(placement.left(), placement.right()),
Some(crate::ui_kit::Edge::Right) => placement.max.x = bounds.left().clamp(placement.left(), placement.right()),
Some(crate::ui_kit::Edge::Bottom) => placement.max.y = bounds.top().clamp(placement.top(), placement.bottom()),
_ => {}
}
}
}
let gap = crate::ui_kit::Container::new(ui.ctx(), crate::ui_kit::Surface::Panel).gap();
placement.min.x += gap.min(placement.width() / 2.0);
placement.max.x -= gap.min(placement.width());
@@ -1006,14 +1168,19 @@ impl crate::SolveApp {
.iter()
.copied()
.chain(self.shell.status_bar)
.chain(self.shell.workspace_panels.values().map(|(bounds, _)| *bounds))
.map(|bounds| bounds.intersect(render_domain))
.filter(|bounds| bounds.is_positive() && bounds.is_finite())
.map(logical_rect)
.collect();
let force_opaque = self.services.reduce_transparency.as_ref().and_then(|read| read(true)) == Some(true);
let direction = crate::i18n::panel_direction(&self.preferences.language);
let shortcuts = crate::keymap::panel_shortcuts(self);
let prepared = match &mut self.panel_runtime {
Some(integration) => {
let result = (|| {
integration.sync_native_direction(direction)?;
integration.set_keyboard_shortcuts(shortcuts);
if let Some(host) = &mut integration.host {
host.note_egui_context(ui.ctx(), force_opaque);
host.set_gap(gap)?;
@@ -1044,12 +1211,24 @@ impl crate::SolveApp {
.central()
.frame(crate::ui_kit::Container::new(ui.ctx(), crate::ui_kit::Surface::Viewport).edge_frame(egui::Color32::TRANSPARENT))
.show(ui, |ui| {
if prepared
&& let Some(integration) = &mut self.panel_runtime
&& let Err(reason) = integration.show_host_deferred(&self.session, ui)
{
integration.last_error = Some(reason);
if prepared && let Some(mut integration) = self.panel_runtime.take() {
let result = if integration.production.is_some() {
// Content commands still see the true full-domain geometry
// while the runtime and mutable content are borrowed apart.
let result = integration.show_production_host(self, ui);
self.panel_content_frame = None;
result
} else {
integration.show_host_deferred(&self.session, ui)
};
if let Err(reason) = result {
integration.last_error = Some(reason);
}
self.panel_runtime = Some(integration);
}
});
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) && !self.preferences.agent_chat_popped {
crate::agent_chat::finish_shared(self, ui.ctx());
}
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ impl PanelController for FixtureController {
actions: if self.editor {
["apply", "cancel", "finish"]
.into_iter()
.map(|id| RuntimeAction { id: ControlId(id.into()), label: id.into(), enabled: true, disabled_reason: None })
.map(|id| RuntimeAction { id: ControlId(id.into()), label: id.into(), enabled: true, disabled_reason: None, pressed: None })
.collect()
} else {
Vec::new()
+31
View File
@@ -145,6 +145,13 @@ pub trait PanelLocalServices {
fn request_overlay_layer(&mut self, scope: &LocalScope, instance: &InstanceId, region: InputRegion, _layer: egui::LayerId) -> PanelResult<()> {
self.request(scope, LocalRequest::Overlay { instance: instance.clone(), region })
}
/// Report a response just created by the current content render. The host
/// uses this optional provenance to retain/restore focus; reporting never
/// requests focus or stores editor state. Hidden or disabled controls are
/// ineligible, and absence of a report must never fall back to an old widget.
fn register_focus_response(&mut self, _scope: &LocalScope, _instance: &InstanceId, _response: &egui::Response) -> PanelResult<()> {
Ok(())
}
fn release(&mut self, scope: &LocalScope, instance: &InstanceId);
}
@@ -154,6 +161,8 @@ pub struct RuntimeAction {
pub label: String,
pub enabled: bool,
pub disabled_reason: Option<String>,
/// Toggle state, when the action represents a persistent on/off choice.
pub pressed: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PanelPresentation {
@@ -197,6 +206,25 @@ pub enum ContentSlot {
Inline,
}
/// A frame-scoped content binding. Application bodies may borrow their existing
/// owner for this paint without storing that borrow in a retained controller.
/// The host supplies the same bounded slot and focus services as ordinary content.
pub trait FramePanelContent {
fn render(
&mut self,
slot: ContentSlot,
ui: &mut egui::Ui,
context: &PanelContext,
content: &mut dyn super::PanelController,
services: &mut super::ContentServices<'_>,
) -> PanelResult<()> {
if slot == ContentSlot::Inline { content.render_inline(ui, context, services) } else { content.render(slot, ui, context, services) }
}
}
pub(super) struct ControllerFrameContent;
impl FramePanelContent for ControllerFrameContent {}
/// Shared host adapter seam. Geometry is resolved by the host; content chooses
/// its own scrolling within slots. The egui adapter below owns one kit Container.
pub trait PanelHost {
@@ -316,6 +344,9 @@ impl PanelHost for EguiPanelHost<'_> {
.inner
})
.inner;
if let Some(pressed) = action.pressed {
ui.ctx().accesskit_node_builder(response.id, |node| node.set_toggled(pressed.into()));
}
let clicked = response.clicked();
if let Some(reason) = &action.disabled_reason {
response.backing_on_disabled_hover_text(reason);
@@ -1224,6 +1224,55 @@ fn begin_gesture(service: &LayoutService, layout: &HostLayout, target: &HostPoin
}
}
/// Keyboard resizing uses the same pure candidate geometry as a pointer preview.
/// It neither captures a pointer nor publishes a second allocation.
pub(crate) fn keyboard_resize_action(
service: &LayoutService,
layout: &HostLayout,
target: &HostPointerTarget,
action: ContextualPanelAction,
) -> PanelResult<LayoutAction> {
use ContextualPanelAction::*;
if action == ResetSash {
return match target {
HostPointerTarget::Sash { first, second } => {
Ok(self::action(layout.revision, vec![Mutation::ResetPair { first: first.clone(), second: second.clone() }]))
}
_ => Err(error(Rule::Availability, "focused control has no neighbour split")),
};
}
let step = crate::theme::Tokens::get().space_8.max(HOST_DRAG_THRESHOLD);
let (dx, dy) = match action {
ResizeLeft => (-step, 0.0),
ResizeRight => (step, 0.0),
ResizeUp => (0.0, -step),
ResizeDown => (0.0, step),
_ => return Err(error(Rule::Availability, "action is not a resize")),
};
let preview = match begin_gesture(service, layout, target)? {
Some(GestureKind::Resize { section, edge, size, outer }) => {
let horizontal = matches!(edge, HostResizeEdge::Left | HostResizeEdge::Right);
if edge != HostResizeEdge::BottomRight && ((horizontal && dx == 0.0) || (!horizontal && dy == 0.0)) {
return Err(error(Rule::Availability, "resize key is perpendicular to focused edge"));
}
resize_preview(service, layout, &section, edge, size, outer, dx, dy)
}
Some(GestureKind::Pair { first, second, first_size, second_size, zone, outer }) => {
if (zone == Zone::Bottom && dx == 0.0) || (zone != Zone::Bottom && dy == 0.0) {
return Err(error(Rule::Availability, "resize key is perpendicular to focused split"));
}
let delta = if zone == Zone::Bottom { dx * if layout.direction == Direction::Rtl { -1.0 } else { 1.0 } } else { dy };
pair_preview(service, layout, &first, &second, first_size, second_size, zone, outer, delta)
}
_ => return Err(error(Rule::Availability, "focused control is not a resize target")),
};
if let Some(reason) = preview.rejection {
return Err(reason);
}
// A clamped split still owns the key, without manufacturing pointer input.
preview.action.ok_or_else(|| error(Rule::Bounds, "focused resize has reached its bound"))
}
fn preview_gesture(capture: &Gesture, service: &LayoutService, layout: &HostLayout, at: Point) -> (HostPreview, Option<PreparedHostLayout>) {
let mut preview = match &capture.kind {
GestureKind::Move { instance, section, targets, .. } => move_preview(service, layout, instance, section, at, targets),
@@ -1375,6 +1424,16 @@ pub fn host_drop_targets(service: &LayoutService, layout: &HostLayout, instance:
}
service.availability(instance).require()?;
let source = service.layout().containing(instance)?;
// A side column supplies its current width. A wide Bottom or floating
// presentation must not stretch a newly created side column to that width.
let column_width = if source.location.zone().side() {
let column = source.column.as_ref().ok_or_else(|| error(Rule::Placement, "side source has no column"))?;
service.layout().column(column)?.width
} else {
let panel = service.layout().instance(instance)?;
service.registry().get(&panel.kind)?.default_size.width
};
let column_size = Size { width: column_width, ..source.size };
let new_section = if source.tabs.len() == 1 {
source.id.clone()
} else {
@@ -1423,7 +1482,7 @@ pub fn host_drop_targets(service: &LayoutService, layout: &HostLayout, instance:
placement,
new_section: new_section.clone(),
new_column: Some(new_column.clone()),
size: source.size,
size: column_size,
},
3,
Some((true, at)),
@@ -1530,11 +1589,16 @@ pub fn host_drop_targets(service: &LayoutService, layout: &HostLayout, instance:
placement: RelativePlacement::BeforeColumn,
new_section: new_section.clone(),
new_column: Some(new_column.clone()),
size: source.size,
size: column_size,
}
} else {
let index = service.layout().sections.iter().filter(|section| !(section.id == source.id && source.tabs.len() == 1)).count();
Destination::NewSection { section: new_section.clone(), index, location: SectionLocation::Dock { zone }, size: source.size }
Destination::NewSection {
section: new_section.clone(),
index,
location: SectionLocation::Dock { zone },
size: if zone.side() { column_size } else { source.size },
}
};
let zone_key = match zone {
Zone::Left => "Left",
+10 -6
View File
@@ -655,7 +655,7 @@ pub struct HostSection {
pub inline: bool,
/// Editable inline content is separate from the shared title drag region.
pub inline_content: Option<LogicalRect>,
pub collapse: LogicalRect,
pub collapse: Option<LogicalRect>,
pub close: Option<LogicalRect>,
pub actions: Vec<(ControlId, LogicalRect)>,
pub footer: Option<LogicalRect>,
@@ -862,7 +862,7 @@ impl HostLayout {
Some(section.header),
section.title,
Some(section.menu),
Some(section.collapse),
section.collapse,
section.close,
section.tab_strip,
section.body,
@@ -1175,12 +1175,13 @@ fn append_section(
outer.validate()?;
let metrics = out.metrics;
let spec = service.registry().get(&proposed.instance(instance)?.kind)?;
let collapsible = section_collapsible(proposed, service.registry(), section)?;
let (flow, _) = responsive(section.location.zone(), outer.size, None)?;
// A single row owns inline controls AND content; no hidden second body.
let inline = !section.collapsed && flow == Flow::Wide && outer.size.height <= metrics.inline_max_height;
let header =
rect(outer.origin.x, outer.origin.y, outer.size.width, if inline { outer.size.height } else { metrics.header_height.min(outer.size.height) });
let control_count = spec.header_actions.len() + 2 + usize::from(spec.features.hot_closable);
let control_count = spec.header_actions.len() + 1 + usize::from(collapsible) + usize::from(spec.features.hot_closable);
let extent = metrics.control_extent.min(header.size.width / control_count.max(1) as f32);
let mut cursor = if out.direction == Direction::Ltr { right(header) } else { header.origin.x };
let take = |cursor: &mut f32| {
@@ -1189,7 +1190,7 @@ fn append_section(
rect(x, header.origin.y, extent, header.size.height)
};
let close = if spec.features.hot_closable { Some(take(&mut cursor)) } else { None };
let collapse = take(&mut cursor);
let collapse = collapsible.then(|| take(&mut cursor));
let menu = take(&mut cursor);
let mut actions = Vec::new();
let (heading, inline_content) = if inline {
@@ -1331,7 +1332,9 @@ fn append_section(
add_control(out, HostPointerTarget::Header { instance: instance.clone() }, title, InputRole::Header, "ui.panel_host.header");
}
add_control(out, HostPointerTarget::Menu { instance: instance.clone() }, menu, InputRole::Action, "ui.panel_host.menu");
add_control(out, HostPointerTarget::Collapse { instance: instance.clone() }, collapse, InputRole::Action, "ui.panel_host.collapse");
if let Some(collapse) = collapse {
add_control(out, HostPointerTarget::Collapse { instance: instance.clone() }, collapse, InputRole::Action, "ui.panel_host.collapse");
}
if let Some(close) = close {
add_control(out, HostPointerTarget::Close { instance: instance.clone() }, close, InputRole::Action, "ui.panel_host.close");
}
@@ -1353,7 +1356,8 @@ fn append_section(
Zone::Left => HostResizeEdge::Right,
_ => HostResizeEdge::Left,
};
if !section.collapsed {
let (min, max) = tab_bounds(proposed, service.registry(), section)?;
if !section.collapsed && !(section.location.zone() == Zone::Bottom && min.height == max.height) {
add_control(
out,
HostPointerTarget::Resize { section: section.id.clone(), edge },
+6 -1
View File
@@ -1,4 +1,4 @@
//! Opt-in common panel runtime. No production registrations or shipping default changes.
//! Opt-in common panel runtime and retained production content bindings.
//! Model/commands, controller state, presentation and layout have separate owners.
mod app;
mod fixtures;
@@ -6,6 +6,7 @@ mod host;
mod interaction;
mod layout;
mod preferences;
mod production;
mod runtime;
mod specimen;
mod surfaces;
@@ -18,6 +19,10 @@ pub use interaction::*;
pub use layout::*;
pub use preferences::*;
pub(crate) use preferences::{PanelLayoutSnapshot, panel_layout_matches, panel_layout_snapshot};
pub(crate) use production::{
refresh_production, register_content_focus, report_content_composer_height, report_content_extent, shared_content_composer_height,
shared_content_measurement,
};
pub use runtime::*;
pub use specimen::*;
pub use surfaces::*;
@@ -152,20 +152,29 @@ impl LegacyPanelPlacements {
let prefs = &app.preferences;
let vertical = app.ui.show_timeline && prefs.timeline_left;
let stacked = vertical && prefs.timeline_under_browser;
// Repair only the import snapshot; Classic keeps its saved preferences.
let defaults = crate::prefs::Prefs::default();
let width = |value: f32, fallback: f32| {
if value.is_finite() && value > 0.0 && value <= MAX_LOGICAL { value } else { fallback }
};
Self {
browser_width: prefs.browser_width,
browser_width: width(prefs.browser_width, defaults.browser_width),
browser_collapsed: if vertical { prefs.timeline_left_browser_collapsed } else { prefs.browser_collapsed },
history_left: prefs.timeline_left,
history_under_browser: stacked,
history_width: prefs.timeline_left_width,
history_width: width(prefs.timeline_left_width, defaults.timeline_left_width),
history_collapsed: prefs.timeline_collapsed,
browser_share: prefs.browser_share,
browser_share: if prefs.browser_share.is_finite() && (0.0..=1.0).contains(&prefs.browser_share) {
prefs.browser_share
} else {
defaults.browser_share
},
assistant_side: if prefs.agent_chat_side == "left" { "left" } else { "right" }.into(),
assistant_width: prefs.agent_chat_width,
assistant_width: width(prefs.agent_chat_width, defaults.agent_chat_width),
assistant_collapsed: prefs.agent_chat_minimized,
assistant_popped: prefs.agent_chat_popped,
render_side: if prefs.render_browser_side == "right" { "right" } else { "left" }.into(),
render_width: prefs.render_browser_width,
render_width: width(prefs.render_browser_width, defaults.render_browser_width),
imported: BTreeSet::new(),
}
}
@@ -0,0 +1,883 @@
//! Bind the shared host to existing application content, without owning that content.
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::panels::*;
use super::*;
const VIEWPORT: &str = "solidcraft.panels";
const MAX_ACTIONS: usize = 16;
const WORKSPACES: [(&str, &str, &str); 5] = [
("solidcraft.classic", "Classic", "classic"),
("solidcraft.modelling", "Modelling", "modelling"),
("solidcraft.sketch", "Sketch", "sketch"),
("solidcraft.render", "Render", "render"),
("solidcraft.review", "Review", "review"),
];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Product {
Browser,
History,
Assistant,
RenderBrowser,
}
impl Product {
const ALL: [Self; 4] = [Self::Browser, Self::History, Self::Assistant, Self::RenderBrowser];
fn index(self) -> usize {
match self {
Self::Browser => 0,
Self::History => 1,
Self::Assistant => 2,
Self::RenderBrowser => 3,
}
}
fn visible(self, app: &crate::SolveApp) -> bool {
match self {
Self::Browser | Self::RenderBrowser => app.ui.show_browser,
Self::History => app.ui.show_timeline,
Self::Assistant => app.agent_chat.open,
}
}
fn set_visible(self, app: &mut crate::SolveApp, open: bool) {
match self {
Self::Browser | Self::RenderBrowser => app.ui.show_browser = open,
Self::History => app.ui.show_timeline = open,
Self::Assistant => app.agent_chat.open = open,
}
}
fn id(self) -> &'static str {
match self {
Self::Browser => "browser",
Self::History => "history",
Self::Assistant => "assistant",
Self::RenderBrowser => "render-browser",
}
}
fn kind(self) -> &'static str {
match self {
Self::Browser => "solidcraft.browser",
Self::History => "solidcraft.history",
Self::Assistant => "solidcraft.assistant",
Self::RenderBrowser => "solidcraft.render_browser",
}
}
fn title_key(self) -> &'static str {
match self {
Self::Browser => "ui.browser_title",
Self::History => "ui.history_title",
Self::Assistant => "ui.assistant",
Self::RenderBrowser => "render.page_scene",
}
}
fn from_instance(instance: &InstanceId) -> PanelResult<Self> {
Self::ALL.into_iter().find(|product| product.id() == instance.0).ok_or_else(|| error(Rule::MissingTarget, "unknown application panel"))
}
fn available(self, app: &crate::SolveApp) -> bool {
let render = crate::render_workspace::active(app);
match self {
Self::Browser => !render && !crate::render_ui::focused(app),
Self::History => !render && !app.preferences.history_in_browser,
Self::Assistant => crate::agent_chat::shown(app) && !app.preferences.agent_chat_popped,
Self::RenderBrowser => render && !crate::render_ui::focused(app),
}
}
fn header_actions(self) -> Vec<HeaderActionSpec> {
let entries: Vec<(&str, &str, &str)> = match self {
Self::History => vec![
("first", "ui.history_first", "timeline_first"),
("back", "ui.history_back", "timeline_back"),
("play", "cmd.timeline_play", "timeline_play"),
("forward", "ui.history_forward", "timeline_forward"),
("last", "ui.history_last", "timeline_last"),
],
Self::Assistant => vec![("new-chat", "ui.agent_chat_new", "new")],
Self::RenderBrowser => vec![
("by-object", "render.tree_by_object", "list"),
("by-look", "render.tree_by_look", "list"),
("search", "render.tree_search", "search"),
],
Self::Browser => Vec::new(),
};
entries.into_iter().map(|(id, key, icon)| HeaderActionSpec { id: ControlId(id.into()), label_key: key.into(), icon: icon.into() }).collect()
}
fn presentation(self, app: &crate::SolveApp, play_button: bool) -> PanelPresentation {
let mut presentation = self.empty_presentation();
presentation.actions = match self {
Self::History => crate::ui_kit::HistoryTransport::shown(play_button)
.iter()
.map(|control| {
let availability = crate::timeline::transport_availability(app, *control);
RuntimeAction {
id: ControlId(control.id().into()),
label: control.label(app.timeline_playback.is_some()).into(),
enabled: availability.is_ok(),
disabled_reason: availability.err(),
pressed: (*control == crate::ui_kit::HistoryTransport::Play).then_some(app.timeline_playback.is_some()),
}
})
.collect(),
Self::Assistant => vec![RuntimeAction {
id: ControlId("new-chat".into()),
label: crate::i18n::tr("ui.agent_chat_new").into(),
enabled: true,
disabled_reason: None,
pressed: None,
}],
Self::RenderBrowser => [
("by-object", "render.tree_by_object", !app.render_workspace.view.by_look),
("by-look", "render.tree_by_look", app.render_workspace.view.by_look),
("search", "render.tree_search", app.render_workspace.view.search_objects),
]
.into_iter()
.map(|(id, key, pressed)| RuntimeAction {
id: ControlId(id.into()),
label: crate::i18n::tr(key).into(),
enabled: true,
disabled_reason: None,
pressed: Some(pressed),
})
.collect(),
Self::Browser => Vec::new(),
};
presentation
}
fn empty_presentation(self) -> PanelPresentation {
PanelPresentation {
title: crate::i18n::tr(self.title_key()).into(),
icon: "list".into(),
status: None,
attention: false,
actions: Vec::new(),
}
}
}
struct PanelSnapshot {
product: Product,
available: bool,
presentation: PanelPresentation,
extent: Option<ContentExtent>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ContentExtent {
width: f32,
height: f32,
width_independent: bool,
flow: Flow,
composer: Option<f32>,
}
struct ProductionSnapshot {
document: DocumentId,
play_button: bool,
visible: [bool; 4],
visibility: [Option<VisibilityIntent>; 4],
panels: [PanelSnapshot; 4],
actions: VecDeque<QueuedAction>,
}
struct VisibilityIntent {
document: DocumentId,
open: bool,
}
struct QueuedAction {
document: DocumentId,
product: Product,
action: ControlId,
}
/// Only bounded presentation metadata and explicit pending actions are shared.
/// Trees, drafts, chat services and selection remain on their existing owners.
#[derive(Clone)]
pub(super) struct ProductionState(Rc<RefCell<ProductionSnapshot>>);
impl ProductionState {
fn new(app: &crate::SolveApp) -> PanelResult<Self> {
Ok(Self(Rc::new(RefCell::new(ProductionSnapshot {
document: DocumentId::new(app.session.document_id.clone())?,
play_button: app.preferences.history_play_button,
visible: Product::ALL.map(|product| product.visible(app)),
visibility: [None, None, None, None],
panels: Product::ALL.map(|product| PanelSnapshot {
product,
available: product.available(app),
presentation: product.presentation(app, app.preferences.history_play_button),
extent: None,
}),
actions: VecDeque::new(),
}))))
}
}
pub(crate) fn enable_production(app: &mut crate::SolveApp) -> PanelResult<()> {
if app.panel_runtime.is_some() {
return Err(error(Rule::Lifecycle, "application panel runtime is already enabled"));
}
let state = ProductionState::new(app)?;
let registry = registry(app)?;
let workspace = WorkspaceId::new(WORKSPACES[0].0)?;
let layout = default_layout(app, workspace.clone(), &registry)?;
let mut runtime = PanelRuntime::new(
LayoutService::new(registry, layout, HostCapabilities { persistence: true, ..Default::default() })?,
RuntimeEnvironment {
workspace: workspace.clone(),
document: Some(DocumentId::new(app.session.document_id.clone())?),
window: HostId::new(VIEWPORT)?,
viewport: ViewportId::new(VIEWPORT)?,
direction: crate::i18n::panel_direction(&app.preferences.language),
motion: Motion::Animated,
},
)?;
for product in Product::ALL {
let state = state.clone();
runtime.register(
PanelKind::new(product.kind())?,
ControllerBinding {
ownership: Ownership::Document,
create: Box::new(move |_instance, owner| {
let ContentOwner::Document(document) = owner else {
return Err(error(Rule::Placement, "application panel requires its document owner"));
};
Ok(Box::new(ProductionController { product, document: document.clone(), state: state.clone() }))
}),
},
)?;
}
let services = ProductionServices {
models: ProductionModels,
commands: ProductionCommands,
local: ProductionLocal { workspace, document: DocumentId::new(app.session.document_id.clone())?, keyboard: None },
};
let mut integration = AppPanelRuntime::new(runtime, Box::new(services))?;
integration.production = Some(state);
integration.tick(&app.session)?;
let mut options = HostOptions { content_header_height: 0.0, ..Default::default() };
options.metrics.footer_height = 0.0;
integration.enable_host(options)?;
let mut catalogue = commands::InMemoryCatalogue::default();
for (id, name, suffix) in WORKSPACES {
let workspace = WorkspaceId::new(id)?;
let layout = default_layout(app, workspace.clone(), integration.runtime().layout().registry())?;
catalogue.register_builtin(name, &LayoutEnvelope::new(layout), integration.runtime().layout().registry())?;
catalogue.register_workspace(commands::WorkspaceDescriptor {
id: workspace,
title_key: format!("ui.panel_layout.workspace.{suffix}"),
default_preset: name.into(),
})?;
}
let repaired = super::preferences::configure_preferences(&mut integration, &app.panel_layout_preferences, &mut catalogue)?;
app.panel_runtime = Some(integration);
if repaired {
super::preferences::report_notice(app);
}
Ok(())
}
pub(crate) fn refresh_production(app: &crate::SolveApp) -> PanelResult<()> {
let Some(state) = app.panel_runtime.as_ref().and_then(|integration| integration.production.as_ref()) else { return Ok(()) };
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel snapshot is borrowed"))?;
let document = DocumentId::new(app.session.document_id.clone())?;
if snapshot.document != document {
for panel in &mut snapshot.panels {
panel.extent = None;
}
}
snapshot.document = document;
// Keep an explicit view-toggle edge pending while its content context is
// unavailable. Ordinary workspace changes do not create a new toggle edge.
for product in Product::ALL {
let visible = product.visible(app);
if app.panel_runtime.as_ref().is_some_and(|integration| {
integration
.runtime()
.layout()
.layout()
.instances
.iter()
.any(|instance| instance.id.0 == product.id() && instance.requested_open == visible)
}) {
snapshot.visible[product.index()] = visible;
}
}
let play_button = snapshot.play_button;
for panel in &mut snapshot.panels {
panel.available = panel.product.available(app);
panel.presentation = panel.product.presentation(app, play_button);
}
Ok(())
}
pub(crate) fn drain_production_actions(app: &mut crate::SolveApp) -> PanelResult<()> {
let Some(state) = app.panel_runtime.as_ref().and_then(|integration| integration.production.clone()) else { return Ok(()) };
let (actions, visibility, play_button) = {
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel actions are borrowed"))?;
(std::mem::take(&mut snapshot.actions), std::mem::take(&mut snapshot.visibility), snapshot.play_button)
};
for (product, intent) in Product::ALL.into_iter().zip(visibility) {
if let Some(intent) = intent
&& intent.document.0 == app.session.document_id
{
product.set_visible(app, intent.open);
}
}
for action in actions {
if action.document.0 != app.session.document_id || !action.product.available(app) {
continue;
}
let presentation = action.product.presentation(app, play_button);
if !presentation.actions.iter().any(|binding| binding.id == action.action && binding.enabled) {
continue;
}
match action.product {
Product::History => {
if let Some(control) = crate::ui_kit::HistoryTransport::WITH_PLAY.into_iter().find(|control| control.id() == action.action.0) {
crate::timeline::run_transport(app, control);
}
}
Product::Assistant => crate::agent_chat::request_new_chat(app),
Product::RenderBrowser => {
let params = match action.action.0.as_str() {
"by-object" => json!({"by_look":false}),
"by-look" => json!({"by_look":true}),
"search" => json!({"search":!app.render_workspace.view.search_objects}),
_ => continue,
};
app.run("view.render_browser", params).map_err(|reason| error(Rule::Availability, reason))?;
}
Product::Browser => {}
}
}
Ok(())
}
/// Existing view toggles update remembered placement only when their raw flag
/// changes. Context changes and hidden bodies never reopen closed panels.
pub(crate) fn sync_production_visibility(app: &mut crate::SolveApp) -> PanelResult<()> {
let Some(integration) = app.panel_runtime.as_ref() else { return Ok(()) };
let Some(state) = integration.production.as_ref() else { return Ok(()) };
let snapshot = state.0.try_borrow().map_err(|_| error(Rule::Lifecycle, "application panel visibility is borrowed"))?;
if snapshot.document.0 != app.session.document_id {
return Ok(());
}
let layout = integration.runtime().layout().layout();
let mut mutations = Vec::new();
for product in Product::ALL {
let visible = product.visible(app);
if snapshot.visible.get(product.index()).copied() != Some(visible) {
let instance = layout.instance(&InstanceId::new(product.id())?)?;
if instance.requested_open != visible && (!visible || product.available(app)) {
mutations.push(if visible {
Mutation::Open { kind: instance.kind.clone(), instance: instance.id.clone(), destination: None }
} else {
Mutation::Close { instance: instance.id.clone() }
});
}
}
}
let revision = integration.runtime().layout().inspect().revision;
drop(snapshot);
if !mutations.is_empty() {
refresh_production(app)?;
app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: Some(revision), mutations })?;
}
Ok(())
}
fn registry(app: &crate::SolveApp) -> PanelResult<PanelRegistry> {
let mut registry = PanelRegistry::default();
let tokens = crate::theme::Tokens::get();
// The existing strip has a 28-point chip and six-point inset on each side;
// the shared content slot adds its own symmetric two-point inset.
let bottom_height = tokens.form_header_height.max(tokens.space_24 + tokens.space_4 + 2.0 * tokens.space_6 + 2.0 * tokens.space_2);
for product in Product::ALL {
let history = product == Product::History;
let assistant = product == Product::Assistant;
let mut allowed_zones = [Zone::Left, Zone::Right, Zone::Floating].into_iter().collect::<std::collections::BTreeSet<_>>();
if history {
allowed_zones.insert(Zone::Bottom);
}
let mut header_actions = product.header_actions();
if history && !app.preferences.history_play_button {
header_actions.retain(|action| action.id.0 != "play");
}
registry.register(PanelSpec {
kind: PanelKind::new(product.kind())?,
title_key: product.title_key().into(),
icon: "list".into(),
allowed_zones,
default_zone: if history { Zone::Bottom } else { Zone::Left },
min_size: Size { width: 240.0, height: if assistant { 120.0 } else { crate::theme::Tokens::get().control_height } },
default_size: Size { width: crate::prefs::DEFAULT_BROWSER_WIDTH, height: if history { bottom_height } else { 320.0 } },
max_size: Size { width: MAX_LOGICAL, height: MAX_LOGICAL },
edge_rule: if assistant { EdgeRule::PinOuter } else { EdgeRule::None },
surface: PanelSurface::FollowZone,
features: PanelFeatures { popout: false, hot_closable: assistant, ..Default::default() },
side_height_policy: if assistant { SideHeightPolicy::Fill } else { SideHeightPolicy::Content },
fixed_bottom_height: history.then_some(bottom_height),
header_actions,
text_heavy: assistant,
})?;
}
Ok(registry)
}
fn default_layout(app: &crate::SolveApp, workspace: WorkspaceId, registry: &PanelRegistry) -> PanelResult<Layout> {
let mut layout = Layout::empty(workspace);
let vertical = app.ui.show_timeline && app.preferences.timeline_left;
let stacked = vertical && app.preferences.timeline_under_browser;
let share = if app.preferences.browser_share.is_finite() { app.preferences.browser_share.clamp(0.05, 0.95) } else { 0.5 };
for product in Product::ALL {
let id = InstanceId::new(product.id())?;
let requested_open = product.visible(app);
let kind = PanelKind::new(product.kind())?;
let spec = registry.get(&kind)?;
let zone = match product {
Product::History if !app.preferences.timeline_left => Zone::Bottom,
Product::Assistant if app.preferences.agent_chat_side == "right" => Zone::Right,
Product::RenderBrowser if app.preferences.render_browser_side == "right" => Zone::Right,
_ => Zone::Left,
};
let width = match product {
Product::Browser => app.preferences.browser_width,
Product::History if stacked => app.preferences.browser_width,
Product::History => app.preferences.timeline_left_width,
Product::Assistant => app.preferences.agent_chat_width,
Product::RenderBrowser => app.preferences.render_browser_width,
};
let width = if width.is_finite() { width.clamp(spec.min_size.width, 1200.0) } else { spec.default_size.width };
let width = if product == Product::History && stacked {
layout.columns.iter().find(|column| column.id.0 == "panel-column-browser").map_or(width, |column| column.width)
} else {
width
};
let size = Size { width, height: spec.default_size.height };
let section = SectionId::new(format!("panel-{}", product.id()))?;
let location = SectionLocation::Dock { zone };
let column = zone
.side()
.then(|| ColumnId(format!("panel-column-{}", if product == Product::History && stacked { Product::Browser.id() } else { product.id() })));
if requested_open
&& let Some(column) = &column
&& !layout.columns.iter().any(|existing| existing.id == *column)
{
layout.columns.push(Column { id: column.clone(), zone, width });
}
let collapsed = spec.collapsible_in(zone)
&& match product {
Product::Browser if vertical => app.preferences.timeline_left_browser_collapsed,
Product::Browser => app.preferences.browser_collapsed,
Product::History => app.preferences.timeline_collapsed,
Product::Assistant => app.preferences.agent_chat_minimized,
Product::RenderBrowser => false,
};
let weight = match product {
Product::Browser if stacked => share,
Product::History if stacked => 1.0 - share,
_ => 1.0,
};
let section_index = if product == Product::Assistant && !requested_open { 0 } else { layout.sections.len() };
if requested_open {
layout.sections.push(Section {
id: section.clone(),
location: location.clone(),
column: column.clone(),
weight,
preferred_extent: None,
extent_priority: 0,
tabs: vec![id.clone()],
active: id.clone(),
size,
collapsed,
});
}
let column_index = if product == Product::Assistant && !requested_open {
0
} else {
layout
.columns
.iter()
.filter(|existing| existing.zone == zone)
.position(|existing| Some(&existing.id) == column.as_ref())
.unwrap_or_else(|| layout.columns.iter().filter(|existing| existing.zone == zone).count())
};
layout.instances.push(PanelInstance {
id,
kind,
requested_open,
memory: PlacementMemory {
section,
location,
section_index,
tab_index: 0,
size,
collapsed,
column: column.clone(),
column_index,
column_width: column.map(|_| width),
weight,
preferred_extent: None,
extent_priority: 0,
},
});
}
// A pinned Assistant precedes ordinary columns on its chosen logical side.
layout.columns.sort_by_key(|column| (column.zone != Zone::Left, column.id.0 != "panel-column-assistant"));
layout.sections.sort_by_key(|section| {
section.column.as_ref().and_then(|id| layout.columns.iter().position(|column| column.id == *id)).unwrap_or(MAX_SECTIONS)
});
for (section_index, section) in layout.sections.iter().enumerate() {
let column_index = layout
.columns
.iter()
.filter(|column| column.zone == section.location.zone())
.position(|column| Some(&column.id) == section.column.as_ref())
.unwrap_or(0);
if let Some(instance) = layout.instances.iter_mut().find(|instance| instance.id == section.active) {
instance.memory.section_index = section_index;
instance.memory.column_index = column_index;
}
}
validate(&layout, registry)?;
Ok(layout)
}
struct ProductionController {
product: Product,
document: DocumentId,
state: ProductionState,
}
impl PanelController for ProductionController {
fn availability(&self, owner: &ContentOwner, _models: &dyn PanelModels) -> Availability {
if self.state.0.try_borrow().is_ok_and(|snapshot| {
*owner == ContentOwner::Document(snapshot.document.clone())
&& snapshot.panels.iter().any(|panel| panel.product == self.product && panel.available)
}) {
Availability::Available
} else {
Availability::Unavailable { reason: crate::i18n::tr("ui.panel_host.rule.availability").into() }
}
}
fn update(&mut self, _owner: &ContentOwner, _services: &mut ContentServices<'_>) -> PanelResult<()> {
Ok(())
}
fn presentation(&self) -> PanelPresentation {
self.state
.0
.try_borrow()
.ok()
.and_then(|snapshot| snapshot.panels.iter().find(|panel| panel.product == self.product).map(|panel| panel.presentation.clone()))
.unwrap_or_else(|| self.product.empty_presentation())
}
fn measure(&self, _owner: &ContentOwner, _models: &dyn PanelModels, spec: &PanelSpec) -> PanelResult<Arc<dyn HostContentMetrics>> {
let snapshot = self.state.0.try_borrow().map_err(|_| error(Rule::Lifecycle, "content measurement is borrowed"))?;
Ok(Arc::new(ProductionMetrics {
extent: snapshot.panels.iter().find(|panel| panel.product == self.product).and_then(|panel| panel.extent),
inset: crate::theme::Tokens::get().space_2 * 2.0,
policy: spec.side_height_policy,
}))
}
fn prepare_lifecycle(&self, event: &LifecycleEvent) -> PanelResult<()> {
if matches!(event, LifecycleEvent::Close | LifecycleEvent::Reopen) {
let _snapshot = self.state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel visibility is borrowed"))?;
}
Ok(())
}
fn lifecycle(&mut self, event: &LifecycleEvent) {
let open = match event {
LifecycleEvent::Close => false,
LifecycleEvent::Reopen => true,
_ => return,
};
if let Ok(mut snapshot) = self.state.0.try_borrow_mut()
&& snapshot.document == self.document
&& let Some(slot) = snapshot.visibility.get_mut(self.product.index())
{
*slot = Some(VisibilityIntent { document: self.document.clone(), open });
}
}
fn render(&mut self, _slot: ContentSlot, _ui: &mut egui::Ui, _context: &PanelContext, _services: &mut ContentServices<'_>) -> PanelResult<()> {
Err(error(Rule::Lifecycle, "application panel content requires its current frame binding"))
}
fn action(&mut self, owner: &ContentOwner, action: &ControlId, services: &mut ContentServices<'_>) -> PanelResult<()> {
self.availability(owner, services.models).require()?;
self.prepare_action(owner, action, services.models)?;
let mut snapshot = self.state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel actions are borrowed"))?;
if snapshot.actions.len() >= MAX_ACTIONS {
return Err(error(Rule::CollectionLimit, "application panel action queue is full"));
}
let document = snapshot.document.clone();
snapshot.actions.push_back(QueuedAction { document, product: self.product, action: action.clone() });
Ok(())
}
}
#[derive(Debug)]
struct ProductionMetrics {
extent: Option<ContentExtent>,
inset: f32,
policy: SideHeightPolicy,
}
impl HostContentMetrics for ProductionMetrics {
fn measure(&self, request: &ContentMeasureRequest) -> PanelResult<SideContentMeasure> {
// Bootstrap a newly visible width at capacity. Only actual content extent,
// never the allocated viewport, can subsequently shorten the panel.
let needed_height = self
.extent
.filter(|extent| extent.width_independent || (extent.flow == Flow::Tall && (extent.width - request.width).abs() < 0.5))
.map_or(MAX_LOGICAL, |extent| (request.chrome_height + extent.height + self.inset).min(MAX_LOGICAL));
Ok(SideContentMeasure { request: request.clone(), needed_height, policy: self.policy })
}
fn revision(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
self.extent
.filter(|_| self.policy == SideHeightPolicy::Content)
.map(|extent| {
(
if extent.width_independent { 0 } else { extent.width.to_bits() },
extent.height.to_bits(),
extent.width_independent,
if extent.width_independent { 0 } else { extent.flow as u8 },
)
})
.hash(&mut hash);
self.inset.to_bits().hash(&mut hash);
matches!(self.policy, SideHeightPolicy::Fill).hash(&mut hash);
hash.finish()
}
}
struct ProductionModels;
impl PanelModels for ProductionModels {
fn read(&self, _owner: &ContentOwner, _key: &str) -> PanelResult<Value> {
Err(error(Rule::MissingTarget, "application content reads its existing owner directly"))
}
}
struct ProductionCommands;
impl ContentCommandSink for ProductionCommands {
fn execute(&mut self, _owner: &ContentOwner, _command: &str, _input: Value) -> PanelResult<Value> {
Err(error(Rule::Capability, "application content uses its existing command path"))
}
}
struct ProductionServices {
models: ProductionModels,
commands: ProductionCommands,
local: ProductionLocal,
}
impl AppPanelServices for ProductionServices {
fn refresh_workspace(&mut self, workspace: &WorkspaceId) -> PanelResult<()> {
if !WORKSPACES.iter().any(|(id, _, _)| *id == workspace.0) {
return Err(error(Rule::Placement, "unregistered application layout workspace"));
}
self.local.workspace = workspace.clone();
Ok(())
}
fn refresh_read_only(&mut self, session: &Session) -> PanelResult<()> {
self.local.document = DocumentId::new(session.document_id.clone())?;
Ok(())
}
fn services(&mut self) -> ContentServices<'_> {
ContentServices { models: &self.models, commands: &mut self.commands, local: &mut self.local }
}
}
struct ProductionLocal {
workspace: WorkspaceId,
document: DocumentId,
keyboard: Option<(LocalScope, InstanceId)>,
}
impl ProductionLocal {
fn validate(&self, scope: &LocalScope, instance: &InstanceId) -> PanelResult<()> {
scope.validate()?;
Product::from_instance(instance)?;
if scope.workspace != self.workspace || scope.document.as_ref() != Some(&self.document) || scope.viewport.0 != VIEWPORT {
return Err(error(Rule::Placement, "application panel service has a different owner scope"));
}
Ok(())
}
}
impl PanelLocalServices for ProductionLocal {
fn request(&mut self, scope: &LocalScope, request: LocalRequest) -> PanelResult<()> {
request.validate()?;
self.validate(scope, request.instance())?;
match request {
LocalRequest::KeyboardOwner { instance, .. } => {
if self.keyboard.as_ref().is_some_and(|(owner_scope, owner)| owner_scope == scope && *owner != instance) {
return Err(error(Rule::Lifecycle, "another application panel owns the keyboard"));
}
self.keyboard = Some((scope.clone(), instance));
}
LocalRequest::ReleaseKeyboard { instance } => self.release(scope, &instance),
LocalRequest::Overlay { .. } | LocalRequest::ClearOverlays { .. } => {}
LocalRequest::Focus { .. }
| LocalRequest::FileDrop { .. }
| LocalRequest::PointerDrop { .. }
| LocalRequest::Clipboard { .. }
| LocalRequest::Pick { .. } => {
return Err(error(Rule::Capability, "application content uses its current viewport input service"));
}
}
Ok(())
}
fn register_focus_response(&mut self, scope: &LocalScope, instance: &InstanceId, _response: &egui::Response) -> PanelResult<()> {
self.validate(scope, instance)
}
fn release(&mut self, scope: &LocalScope, instance: &InstanceId) {
if self.keyboard.as_ref().is_some_and(|(owner_scope, owner)| owner_scope == scope && owner == instance) {
self.keyboard = None;
}
}
}
#[derive(Clone)]
struct FocusCapture {
layer: egui::LayerId,
responses: Vec<egui::Response>,
top: f32,
extent: Option<ContentExtent>,
composer: Option<f32>,
}
fn focus_capture_id() -> egui::Id {
egui::Id::unique("shared-panel-current-content-focus")
}
pub(crate) fn shared_content_measurement(ctx: &egui::Context) -> bool {
ctx.data(|data| data.get_temp::<Option<FocusCapture>>(focus_capture_id()).flatten().is_some())
}
pub(crate) fn shared_content_composer_height(ctx: &egui::Context) -> Option<f32> {
ctx.data(|data| data.get_temp::<Option<FocusCapture>>(focus_capture_id()).flatten().and_then(|capture| capture.composer))
}
pub(crate) fn report_content_composer_height(ctx: &egui::Context, height: f32) {
if !height.is_finite() || height < 0.0 {
return;
}
ctx.data_mut(|data| {
if let Some(Some(mut capture)) = data.remove_temp::<Option<FocusCapture>>(focus_capture_id()) {
capture.composer = Some(height.min(MAX_LOGICAL));
data.insert_temp(focus_capture_id(), Some(capture));
}
});
}
/// Natural scroll content and fixed bands from the current visible body only.
pub(crate) fn report_content_extent(ui: &egui::Ui, top: f32, height: f32, width_independent: bool) {
if !top.is_finite() || !height.is_finite() || height < 0.0 {
return;
}
ui.ctx().data_mut(|data| {
if let Some(Some(mut capture)) = data.remove_temp::<Option<FocusCapture>>(focus_capture_id()) {
if capture.layer == ui.layer_id() {
capture.extent = Some(ContentExtent {
width: 0.0,
flow: Flow::Tall,
composer: None,
height: ((top - capture.top).max(0.0) + height).min(MAX_LOGICAL),
width_independent,
});
}
data.insert_temp(focus_capture_id(), Some(capture));
}
});
}
/// Report the actual editor response while its existing body is painting.
pub(crate) fn register_content_focus(ctx: &egui::Context, response: &egui::Response) {
if !response.enabled() || !response.sense.is_focusable() {
return;
}
ctx.data_mut(|data| {
if let Some(Some(mut capture)) = data.remove_temp::<Option<FocusCapture>>(focus_capture_id()) {
if response.layer_id == capture.layer && capture.responses.len() < 64 {
capture.responses.push(response.clone());
}
data.insert_temp(focus_capture_id(), Some(capture));
}
});
}
pub(crate) struct ProductionFrame<'a> {
pub app: &'a mut crate::SolveApp,
pub(super) state: ProductionState,
}
impl FramePanelContent for ProductionFrame<'_> {
fn render(
&mut self,
slot: ContentSlot,
ui: &mut egui::Ui,
context: &PanelContext,
_content: &mut dyn PanelController,
services: &mut ContentServices<'_>,
) -> PanelResult<()> {
if !matches!(slot, ContentSlot::Body | ContentSlot::Inline) {
return Ok(());
}
context.validate()?;
let product = Product::from_instance(&context.instance)?;
if context.owner != ContentOwner::Document(DocumentId::new(self.app.session.document_id.clone())?) || !product.available(self.app) {
return Err(error(Rule::Availability, "application panel content owner changed"));
}
match product {
Product::Browser | Product::RenderBrowser => self.app.shell.browser = Some(super::workspace::egui_rect(context.outer)),
Product::History => self.app.shell.history = Some(super::workspace::egui_rect(context.outer)),
Product::Assistant => {}
}
let previous = self
.state
.0
.try_borrow()
.map_err(|_| error(Rule::Lifecycle, "content measurement is borrowed"))?
.panels
.iter()
.find(|panel| panel.product == product)
.and_then(|panel| panel.extent)
.filter(|extent| extent.flow == context.flow && (extent.width - context.outer.size.width).abs() < 0.5);
ui.ctx().data_mut(|data| {
data.insert_temp(
focus_capture_id(),
Some(FocusCapture {
layer: ui.layer_id(),
responses: Vec::new(),
top: ui.cursor().top(),
extent: None,
composer: previous.and_then(|extent| extent.composer),
}),
)
});
match product {
Product::Browser | Product::RenderBrowser => crate::browser::panel_contents(self.app, ui),
Product::History if context.flow == Flow::Wide => crate::timeline::panel_inline(self.app, ui),
Product::History => crate::timeline_left::panel_contents(self.app, ui),
Product::Assistant => crate::agent_chat::shared_content(self.app, ui),
}
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
return Err(error(Rule::Availability, "application panel document changed while painting"));
}
let scope = LocalScope { workspace: context.workspace.clone(), document: context.document.clone(), viewport: context.viewport.clone() };
if let Some(capture) = capture {
if let Some(mut extent) = capture.extent {
extent.width = context.outer.size.width;
extent.flow = context.flow;
extent.composer = capture.composer;
let mut snapshot = self.state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "content measurement is borrowed"))?;
if let Some(panel) = snapshot.panels.iter_mut().find(|panel| panel.product == product)
&& panel.extent != Some(extent)
{
panel.extent = Some(extent);
ui.ctx().request_repaint();
}
}
for response in capture.responses {
services.local.register_focus_response(&scope, &context.instance, &response)?;
}
}
Ok(())
}
}
+51 -11
View File
@@ -41,7 +41,7 @@ fn enable_host_specimen_remembered(app: &mut crate::SolveApp, name: &str, rememb
document: Some(DocumentId::new(app.session.document_id.clone())?),
window: HostId::new("panel_fixture.window")?,
viewport: ViewportId::new("panel_fixture.viewport")?,
direction: if crate::bidi::has_rtl(app.tr("ui.panel_fixture.caption")) { Direction::Rtl } else { Direction::Ltr },
direction: crate::i18n::panel_direction(&app.preferences.language),
motion: Motion::Animated,
},
)?;
@@ -170,6 +170,7 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
},
features: PanelFeatures { multi_instance: !editor, popout: false, hot_closable: editor, ..PanelFeatures::default() },
side_height_policy: if fill { SideHeightPolicy::Fill } else { SideHeightPolicy::Content },
fixed_bottom_height: None,
header_actions: if editor {
vec![HeaderActionSpec { id: ControlId::new("apply")?, label_key: "ui.panel_fixture.apply".into(), icon: "list".into() }]
} else {
@@ -356,6 +357,7 @@ impl PanelController for VisibleSpecimen {
[("apply", "ui.panel_fixture.apply"), ("cancel", "ui.panel_fixture.cancel"), ("finish", "ui.panel_fixture.finish")]
.into_iter()
.map(|(id, key)| RuntimeAction {
pressed: None,
id: ControlId(id.into()),
label: crate::i18n::tr(key).into(),
enabled: true,
@@ -392,6 +394,7 @@ impl PanelController for VisibleSpecimen {
ContentSlot::Body => {
// Content owns its scrolling; the host still clips all three slots.
let mut result = Ok(());
let mut focus_result = Ok(());
egui::ScrollArea::vertical().id_salt(("specimen-body", &self.key.instance.0)).show(ui, |ui| {
result = self.probe.edit(&self.key, |state| {
let t = crate::theme::Tokens::get();
@@ -406,9 +409,29 @@ impl PanelController for VisibleSpecimen {
let label = ui.add_sized([ui.available_width(), t.control_height], egui::Label::new(draft_label).truncate());
let draft_id = egui::Id::unique(("specimen-draft", &context.instance, &context.owner));
let edit = crate::ui_kit::text_area(ui, draft_label, draft_id, &mut state.draft, 3, 1024);
focus_result = services.local.register_focus_response(
&LocalScope {
workspace: context.workspace.clone(),
document: context.document.clone(),
viewport: context.viewport.clone(),
},
&context.instance,
&edit,
);
let _ = edit.labelled_by(label.id);
if self.editor {
crate::ui_kit::checkbox(ui, crate::i18n::tr("ui.panel_fixture.guard"), &mut state.reject_cancel);
let guard = crate::ui_kit::checkbox(ui, crate::i18n::tr("ui.panel_fixture.guard"), &mut state.reject_cancel);
if focus_result.is_ok() {
focus_result = services.local.register_focus_response(
&LocalScope {
workspace: context.workspace.clone(),
document: context.document.clone(),
viewport: context.viewport.clone(),
},
&context.instance,
&guard,
);
}
ui.add_sized(
[ui.available_width(), t.control_height],
egui::Label::new(crate::i18n::tr(if state.transaction_live {
@@ -422,6 +445,7 @@ impl PanelController for VisibleSpecimen {
});
});
result?;
focus_result?;
}
ContentSlot::Footer => {
ui.label(crate::i18n::tr("ui.panel_fixture.footer"));
@@ -437,15 +461,22 @@ impl PanelController for VisibleSpecimen {
}
fn render_inline(&mut self, ui: &mut egui::Ui, context: &PanelContext, services: &mut ContentServices<'_>) -> PanelResult<()> {
self.inner.render(ContentSlot::Body, ui, context, services)?;
let mut focus_result = Ok(());
self.probe.edit(&self.key, |state| {
// A real editable synthetic value occupies the same total row as
// shared actions. This is not a production History timeline.
ui.horizontal_centered(|ui| {
ui.add(egui::Label::new(crate::i18n::tr("ui.panel_fixture.inline")).truncate());
let id = egui::Id::unique(("specimen-draft", &context.instance, &context.owner));
crate::ui_kit::cell_editor(ui, id, &mut state.draft, crate::i18n::tr("ui.panel_fixture.draft"), false);
let response = crate::ui_kit::cell_editor(ui, id, &mut state.draft, crate::i18n::tr("ui.panel_fixture.draft"), false);
focus_result = services.local.register_focus_response(
&LocalScope { workspace: context.workspace.clone(), document: context.document.clone(), viewport: context.viewport.clone() },
&context.instance,
&response,
);
});
})
})?;
focus_result
}
}
@@ -563,12 +594,13 @@ fn session_observation(app: &crate::SolveApp) -> Value {
/// An empty object inspects without ticking services or dispatching layout/model actions.
pub(crate) fn control_specimen(app: &mut crate::SolveApp, params: &Value) -> Result<Value, String> {
let object = params.as_object().ok_or("panel_fixture params must be an object")?;
if object.keys().any(|key| !matches!(key.as_str(), "enabled" | "preset" | "remember" | "translucency_percent")) {
if object.keys().any(|key| !matches!(key.as_str(), "enabled" | "preset" | "remember" | "translucency_percent" | "reduced_motion")) {
return Err("unknown panel_fixture parameter".into());
}
let enabled = object.get("enabled").map(|v| v.as_bool().ok_or("enabled must be a boolean")).transpose()?;
let name = object.get("preset").map(|v| v.as_str().ok_or("preset must be a string")).transpose()?;
let remember = object.get("remember").map(|v| v.as_bool().ok_or("remember must be a boolean")).transpose()?;
let reduced_motion = object.get("reduced_motion").map(|v| v.as_bool().ok_or("reduced_motion must be a boolean")).transpose()?;
let requested_opacity = object
.get("translucency_percent")
.map(|value| {
@@ -582,23 +614,31 @@ pub(crate) fn control_specimen(app: &mut crate::SolveApp, params: &Value) -> Res
if let Some(name) = name {
preset(name).map_err(|e| e.to_string())?;
}
if enabled == Some(false) && (name.is_some() || remember.is_some() || requested_opacity.is_some()) {
return Err("preset, remember or translucency_percent cannot be combined with enabled=false".into());
if enabled == Some(false) && (name.is_some() || remember.is_some() || requested_opacity.is_some() || reduced_motion.is_some()) {
return Err("preset, remember, translucency_percent or reduced_motion cannot be combined with enabled=false".into());
}
if remember.is_some() && enabled != Some(true) && name.is_none() {
return Err("remember requires enabling a panel specimen".into());
}
if enabled == Some(true) || name.is_some() {
enable_host_specimen_remembered(app, name.unwrap_or("docks"), remember.unwrap_or(false), requested_opacity).map_err(|e| e.to_string())?;
} else if let Some(opacity) = requested_opacity {
} else if requested_opacity.is_some() || reduced_motion.is_some() {
if !app.universal_panel_host_enabled() || !app.panel_runtime().is_some_and(|runtime| specimen_workspace(&runtime.environment().workspace)) {
return Err("translucency_percent requires an enabled panel specimen host".into());
return Err("presentation options require an enabled panel specimen host".into());
}
let integration = app.panel_runtime_mut().ok_or("panel specimen runtime disappeared")?;
// Confirm the provider as well as the workspace marker before changing
// an existing host. Updating presentation retains controllers and drafts.
integration.with_services(|_, services| services.models.read(&ContentOwner::Application, INSPECT)).map_err(|e| e.to_string())?;
integration.set_requested_opacity(Some(opacity)).map_err(|e| e.to_string())?;
if let Some(opacity) = requested_opacity {
integration.set_requested_opacity(Some(opacity)).map_err(|e| e.to_string())?;
}
}
if let Some(reduced) = reduced_motion {
app.panel_runtime_mut()
.ok_or("panel specimen runtime disappeared")?
.set_motion(if reduced { Motion::Reduced } else { Motion::Animated })
.map_err(|e| e.to_string())?;
}
let owns_runtime = app.panel_runtime().is_some_and(|runtime| specimen_workspace(&runtime.environment().workspace));
if enabled == Some(false) && app.panel_runtime().is_some() {
@@ -629,7 +669,7 @@ pub fn inspect_host_specimen(app: &mut crate::SolveApp) -> PanelResult<Value> {
let integration = app.panel_runtime_mut().ok_or_else(|| error(Rule::Lifecycle, "panel specimen runtime disappeared"))?;
let probe = integration.with_services(|runtime, services| {
let probe = services.models.read(&ContentOwner::Application, INSPECT)?;
Ok(json!({"probe":probe,"layout":runtime.layout().inspect(),"controller_count":runtime.controller_count()}))
Ok(json!({"probe":probe,"layout":runtime.layout().inspect(),"controller_count":runtime.controller_count(),"reduced_motion":runtime.environment().motion == Motion::Reduced}))
})?;
reply["enabled"] = json!(app.universal_panel_host_enabled());
reply["runtime"] = probe;
+894 -5
View File
@@ -2127,7 +2127,7 @@ fn allocation_full_domain_and_short_wide_inline_roles_keep_real_usable_contours_
let card = &host.sections[0];
assert!(card.inline && card.body.is_none() && card.footer.is_none());
assert_eq!(card.header, card.outer, "one total row is shared by controls and inline content");
let controls = [card.actions[0].1, card.menu, card.collapse, card.close.unwrap()];
let controls = [card.actions[0].1, card.menu, card.collapse.unwrap(), card.close.unwrap()];
for pair in controls.windows(2) {
assert!(if direction == Direction::Ltr { pair[0].origin.x < pair[1].origin.x } else { pair[0].origin.x > pair[1].origin.x });
}
@@ -2291,7 +2291,7 @@ fn assert_host_geometry(h: &PanelMountHarness, host: &HostLayout) {
.chain(card.tabs.iter().map(|tab| tab.bounds))
.chain(card.tab_overflow)
.chain(card.actions.iter().map(|(_, bounds)| *bounds))
.chain([card.menu, card.collapse])
.chain([card.menu, card.collapse.unwrap()])
.chain(card.close)
.chain(card.inline_content)
.collect();
@@ -2505,9 +2505,10 @@ impl RawPanelHost {
let ctx = crate::fonts::test_context();
ctx.enable_accesskit();
ctx.set_pixels_per_point(scale);
let host = WorkspacePanelHost::new(HostOptions::default()).unwrap();
Self {
content,
host: WorkspacePanelHost::new(HostOptions::default()).unwrap(),
host,
ctx,
time: 0.0,
capacity: capacity(),
@@ -4719,7 +4720,7 @@ fn raw_host_inactive_tab_widget_drag_obeys_threshold_and_moves_only_its_retained
.unwrap()
.nodes
.iter()
.any(|(_, node)| { node.role() == egui::accesskit::Role::Button && node.label() == Some(widget.accessible_name.as_str()) }),
.any(|(_, node)| { node.role() == egui::accesskit::Role::Tab && node.label() == Some(widget.accessible_name.as_str()) }),
"published Tab metadata must correspond to an actual egui widget"
);
let start = raw.at(target);
@@ -9347,6 +9348,7 @@ struct ReviewCohortTrace {
editors: Vec<(egui::Id, ContentSlot, egui::Rect, String)>,
wrap_rows: Vec<usize>,
consume_wheel: bool,
omit_editor: bool,
child_scroll: std::collections::BTreeMap<InstanceId, (egui::Rect, f32)>,
}
type ReviewTrace = std::rc::Rc<std::cell::RefCell<ReviewCohortTrace>>;
@@ -9404,6 +9406,7 @@ impl PanelController for ReviewController {
fn presentation(&self) -> PanelPresentation {
let mut presentation = self.inner.presentation();
presentation.actions.push(RuntimeAction {
pressed: Some(!self.trace.borrow().disabled),
id: ControlId::new("pulse").unwrap(),
label: "review pulse".into(),
enabled: !self.trace.borrow().disabled,
@@ -9422,6 +9425,9 @@ impl PanelController for ReviewController {
self.inner.render(slot, ui, context, services)?;
let consume_wheel = self.trace.borrow().consume_wheel;
if slot == ContentSlot::Body {
if self.trace.borrow().omit_editor {
return Ok(());
}
if self.key.instance.0 == "inline-editor" || self.key.instance.0 == "wrapping-editor" {
let editor_id = egui::Id::unique(("review-inline-editor", &self.key));
let response = if self.key.instance.0 == "wrapping-editor" {
@@ -9436,6 +9442,11 @@ impl PanelController for ReviewController {
ui.fonts_mut(|fonts| fonts.layout(self.draft.clone(), egui::FontId::monospace(tokens.font_label), tokens.form_ink, width));
self.trace.borrow_mut().wrap_rows.push(galley.rows.len());
}
services.local.register_focus_response(
&LocalScope { workspace: context.workspace.clone(), document: context.document.clone(), viewport: context.viewport.clone() },
&context.instance,
&response,
)?;
self.trace.borrow_mut().editors.push((response.id, slot, response.rect, self.draft.clone()));
} else if consume_wheel {
let child = egui::ScrollArea::vertical()
@@ -10137,7 +10148,7 @@ fn raw_inline_title_actions_editor_and_system_slots_mirror_and_keep_editor_ident
assert!(card.inline && card.body.is_none() && card.footer.is_none());
assert_eq!(card.outer.size.height, 64.0);
let shared_title = card.title.expect("inline composition retains the actual shared title/grip");
let slots = [shared_title, card.actions[0].1, card.inline_content.unwrap(), card.menu, card.collapse, card.close.unwrap()];
let slots = [shared_title, card.actions[0].1, card.inline_content.unwrap(), card.menu, card.collapse.unwrap(), card.close.unwrap()];
for pair in slots.windows(2) {
if direction == Direction::Ltr {
assert!(pair[0].origin.x + pair[0].size.width <= pair[1].origin.x);
@@ -12506,3 +12517,881 @@ fn captured_renderer_alpha_reaches_new_sections_and_alpha_only_drift_invalidates
assert_eq!(h.app.session.is_dirty(), dirty);
}
}
#[test]
fn shared_host_accessibility_exposes_tabs_disclosure_and_oriented_splits() {
for scale in [1.0, 2.0] {
let mut content = host_logs(2, Zone::Left);
content
.dispatch(vec![Mutation::Open {
kind: PanelKind::new("fixture.log").unwrap(),
instance: id("extra-tab"),
destination: Some(Destination::JoinTab { section: section("section-0"), index: 1 }),
}])
.unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
let (frame, output) = raw.frame(Vec::new(), false);
let nodes = &output.platform_output.accesskit_update.as_ref().unwrap().nodes;
let active = frame.layout.sections.iter().find(|card| card.section == section("section-0")).unwrap().instance.clone();
let tabs: Vec<_> =
frame.controls.iter().filter(|control| matches!(control.target, HostControlTarget::Pointer(HostPointerTarget::Tab { .. }))).collect();
assert_eq!(tabs.len(), 2);
for tab in tabs {
let node = &nodes.iter().find(|(id, _)| *id == tab.id.accesskit_id()).unwrap().1;
assert_eq!(node.role(), egui::accesskit::Role::Tab);
assert_eq!(
node.is_selected(),
Some(matches!(&tab.target, HostControlTarget::Pointer(HostPointerTarget::Tab { instance }) if *instance == active))
);
}
let tablist = nodes.iter().find(|(_, node)| node.role() == egui::accesskit::Role::TabList).unwrap();
let mut descendants = vec![tablist.0];
let mut next = 0;
while next < descendants.len() && next < nodes.len() {
if let Some((_, node)) = nodes.iter().find(|(id, _)| *id == descendants[next]) {
for child in node.children() {
if !descendants.contains(child) {
descendants.push(*child);
}
}
}
next += 1;
}
for tab in frame.controls.iter().filter(|control| matches!(control.target, HostControlTarget::Pointer(HostPointerTarget::Tab { .. }))) {
assert!(descendants.contains(&tab.id.accesskit_id()), "the actual Tab must descend from the actual TabList");
}
let collapse = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Collapse { instance: active }));
assert_eq!(nodes.iter().find(|(id, _)| *id == collapse.id.accesskit_id()).unwrap().1.is_expanded(), Some(true));
assert!(
nodes
.iter()
.any(|(_, node)| node.role() == egui::accesskit::Role::Splitter && node.orientation().is_some() && node.numeric_value().is_some())
);
}
}
fn sash_numeric_value(frame: &HostFrame, output: &egui::FullOutput, target: &HostControlTarget) -> f64 {
let control = frame.controls.iter().find(|control| control.target == *target).unwrap();
output
.platform_output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.find(|(id, _)| *id == control.id.accesskit_id())
.unwrap()
.1
.numeric_value()
.unwrap()
}
#[test]
fn contextual_keyboard_resize_reuses_candidates_and_rejects_foreign_focus() {
for scale in [1.0, 2.0] {
for direction in [Direction::Ltr, Direction::Rtl] {
let mut content = host_logs(2, Zone::Left);
let mut environment = content.runtime.environment().clone();
environment.direction = direction;
content.runtime.set_environment(environment, &mut content.local).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
let (frame, output) = raw.frame(Vec::new(), false);
let sash =
frame.controls.iter().find(|control| matches!(control.target, HostControlTarget::Pointer(HostPointerTarget::Sash { .. }))).unwrap();
let target = sash.target.clone();
let before_value = sash_numeric_value(&frame, &output, &target);
raw.ctx.memory_mut(|memory| memory.request_focus(sash.id));
raw.frame(Vec::new(), false);
let candidate = raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::ResizeDown).unwrap();
let before = raw.content.runtime.layout().inspect();
raw.frame(vec![host_key(egui::Key::ArrowDown, true)], false);
let (after_frame, output) = raw.frame(vec![host_key(egui::Key::ArrowDown, false)], false);
assert_ne!(raw.content.runtime.layout().inspect().revision, before.revision);
assert!(matches!(candidate, LayoutAction::Apply { .. }));
let value = sash_numeric_value(&after_frame, &output, &target);
assert_eq!(value, f64::from(after_frame.layout.sections[0].outer.size.height));
assert!(value > before_value, "the default standalone arrow changes the controlled height and accessible value");
raw.host.set_keyboard_shortcuts(Vec::new());
let unbound = raw.content.runtime.layout().inspect();
raw.frame(vec![host_key(egui::Key::ArrowDown, true)], false);
raw.frame(vec![host_key(egui::Key::ArrowDown, false)], false);
assert_eq!(raw.content.runtime.layout().inspect(), unbound, "application bindings replace rather than extend standalone defaults");
raw.foreign_cover = Some(egui_rect(raw.capacity));
raw.foreign_takes_focus = true;
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
assert!(raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::ResizeDown).is_err());
}
}
}
#[test]
fn splitter_accessible_extent_keeps_its_axis_and_value_when_the_host_scrolls() {
for scale in [1.0, 2.0] {
for direction in [Direction::Ltr, Direction::Rtl] {
for zone in [Zone::Left, Zone::Bottom] {
let mut content = host_logs(2, zone);
let mut environment = content.runtime.environment().clone();
environment.direction = direction;
content.runtime.set_environment(environment, &mut content.local).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.capacity.size = if zone == Zone::Bottom { Size { width: 250.0, height: 400.0 } } else { Size { width: 800.0, height: 200.0 } };
raw.frame(Vec::new(), false);
let (before_frame, output) = raw.frame(Vec::new(), false);
let target = HostControlTarget::Pointer(HostPointerTarget::Sash { first: section("section-0"), second: section("section-1") });
let value = sash_numeric_value(&before_frame, &output, &target);
let before_control = raw_control(&raw, &target);
let horizontal_scroll = zone == Zone::Bottom;
let track = raw_control(&raw, &HostControlTarget::Scroll { horizontal: horizontal_scroll });
let bounds = egui_rect(track.bounds);
let at = if horizontal_scroll { bounds.right_center() - egui::vec2(1.0, 0.0) } else { bounds.center_bottom() - egui::vec2(0.0, 1.0) };
let before = raw.content.runtime.layout().inspect();
raw.frame(vec![egui::Event::PointerMoved(at)], false);
raw.pointer(at, true, false);
raw.pointer(at, false, false);
let (after_frame, output) = raw.frame(Vec::new(), false);
let after_control = raw_control(&raw, &target);
assert_ne!(after_control.bounds.origin, before_control.bounds.origin, "the actual sash moved with presentation scrolling");
assert_eq!(sash_numeric_value(&after_frame, &output, &target), value, "scrolling cannot announce a panel resize");
let node = &output
.platform_output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.find(|(id, _)| *id == after_control.id.accesskit_id())
.unwrap()
.1;
assert_eq!(
node.orientation(),
Some(if horizontal_scroll { egui::accesskit::Orientation::Vertical } else { egui::accesskit::Orientation::Horizontal })
);
assert_eq!(raw.content.runtime.layout().inspect(), before);
}
}
}
}
#[test]
fn contextual_panel_focus_reveals_last_tiny_header_without_a_canvas_shield() {
for scale in [1.0, 2.0] {
let mut content = host_logs(24, Zone::Left);
content
.dispatch((0..24).map(|index| Mutation::Collapse { section: section(&format!("section-{index}")), collapsed: true }).collect())
.unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.capacity.size = Size { width: 480.0, height: 320.0 };
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let first = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Header { instance: id("log-0") }));
raw.ctx.memory_mut(|memory| memory.request_focus(first.id));
raw.frame(Vec::new(), false);
let action = raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::FocusPrevious).unwrap();
let reply = raw
.content
.dispatch(match action {
LayoutAction::Apply { mutations, .. } => mutations,
_ => panic!("focus must be a typed mutation"),
})
.unwrap();
raw.host.note_reply(&reply);
raw.frame(Vec::new(), false);
let frame = raw.frame(Vec::new(), false).0;
let last = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Header { instance: id("log-23") }));
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(last.id));
assert!(raw.capacity.contains_rect(last.bounds));
assert!(frame.layout.input.iter().all(|bounds| frame.layout.painted.contains(bounds)));
assert!(frame.layout.sections.iter().filter(|card| card.instance != id("log-23")).all(|card| card.collapsed && card.body.is_none()));
let target = frame.layout.sections.iter().find(|card| card.instance == id("log-23")).unwrap();
assert!(!target.collapsed && target.body.is_some(), "Focus deliberately expands only its target");
}
}
#[test]
fn shared_menu_keyboard_entry_end_and_escape_restore_actual_opener() {
for scale in [1.0, 2.0] {
let mut raw = RawPanelHost::new(host_logs(1, Zone::Left), scale);
raw.ctx.global_style_mut(|style| style.scroll_animation = egui::style::ScrollAnimation::none());
raw.capacity.size = Size { width: 480.0, height: 100.0 };
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let opener = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Menu { instance: id("log-0") }));
raw.ctx.memory_mut(|memory| memory.request_focus(opener.id));
raw.frame(vec![host_key(egui::Key::Enter, true)], false);
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
raw.frame(Vec::new(), false);
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(opener.id));
// Settle the entry reveal, then prove a retained first-row focus does
// not undo manual wheel scrolling on subsequent ordinary paints.
raw.frame(Vec::new(), false);
let first = raw.focused_response.as_ref().unwrap().clone();
scroll_real_host_popup_to_end(&mut raw);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(first.id));
let scrolled = raw.focused_response.as_ref().unwrap().clone();
let inner = egui_rect(raw.host.frame().unwrap().menu_bounds.unwrap()).shrink(crate::theme::Tokens::get().space_6);
assert!(scrolled.rect.bottom() < inner.top(), "manual scrolling can leave the focused first item outside the visible menu");
raw.frame(Vec::new(), false);
assert_eq!(raw.focused_response.as_ref().unwrap().rect, scrolled.rect, "idle repaint must not pull manual scroll back to the focused row");
let (_, output) = raw.frame(vec![host_key(egui::Key::End, true)], false);
assert!(output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().any(|(_, node)| node.role() == egui::accesskit::Role::Menu));
raw.frame(vec![host_key(egui::Key::End, false)], false);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let focused = raw.ctx.memory(|memory| memory.focused()).unwrap();
// read_response after run_ui can return the preceding pass: egui swaps
// its current/previous widget stores at end_pass. Use the actual
// response already captured inside this frame after menu painting.
let response = raw.focused_response.as_ref().unwrap();
assert_eq!(response.id, focused);
let menu = egui_rect(raw.host.frame().unwrap().menu_bounds.unwrap());
let inner = menu.shrink(crate::theme::Tokens::get().space_6);
assert!(inner.contains_rect(response.rect), "the final enabled action scrolls fully into the tiny menu's visible inner rectangle");
assert!(response.interact_rect.is_positive());
assert_eq!(response.interact_rect, response.rect, "the focused row must be fully interactable too");
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
raw.frame(vec![host_key(egui::Key::Escape, false)], false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(opener.id));
assert!(raw.host.frame().unwrap().menu_bounds.is_none());
}
}
#[test]
fn nested_menu_keyboard_entry_and_escape_keep_the_live_root_opener() {
for scale in [1.0, 2.0] {
for fixture in [PopupFixture::Tabs, PopupFixture::CompactTabs] {
let (mut raw, source, button) = raw_popup_fixture(scale, fixture);
let root = raw_control(&raw, &source);
open_root_popup(&mut raw, &source, button, false);
raw.frame(Vec::new(), false);
let nested = raw_control(&raw, &HostControlTarget::PanelMenu { instance: id("log-0") });
raw.ctx.memory_mut(|memory| memory.request_focus(nested.id));
raw.frame(Vec::new(), false);
raw.frame(vec![host_key(egui::Key::Enter, true)], false);
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
raw.frame(Vec::new(), false);
let first = raw_control(&raw, &HostControlTarget::Shared { instance: id("log-0"), action: HostSharedAction::Focus });
assert_eq!(
raw.ctx.memory(|memory| memory.focused()),
Some(first.id),
"the replacement panel menu takes keyboard focus from its immediate submenu source"
);
assert!(
!raw.host.frame().unwrap().controls.iter().any(|control| control.target == HostControlTarget::PanelMenu { instance: id("log-0") })
);
raw.frame(vec![host_key(egui::Key::ArrowDown, true)], false);
raw.frame(vec![host_key(egui::Key::ArrowDown, false)], false);
let second = raw_control(&raw, &HostControlTarget::Shared { instance: id("log-0"), action: HostSharedAction::Collapse });
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(second.id), "the entered submenu is immediately keyboard navigable");
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
raw.frame(vec![host_key(egui::Key::Escape, false)], false);
assert_no_popup(raw.host.frame().unwrap());
assert_eq!(
raw.ctx.memory(|memory| memory.focused()),
Some(root.id),
"Escape restores the live overflow launcher, not the vanished submenu row"
);
// A separate fresh menu must establish a new root launcher.
let fresh = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Menu { instance: id("log-0") }));
raw.ctx.memory_mut(|memory| memory.request_focus(fresh.id));
raw.frame(Vec::new(), false);
raw.frame(vec![host_key(egui::Key::Enter, true)], false);
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
raw.frame(Vec::new(), false);
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
raw.frame(vec![host_key(egui::Key::Escape, false)], false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(fresh.id));
}
}
}
#[test]
fn focused_editor_keeps_automatic_keys_and_explicit_panel_intents_are_observational() {
for scale in [1.0, 2.0] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let editor = trace.borrow().editors.last().unwrap().clone();
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
raw.frame(Vec::new(), false);
raw.host.set_keyboard_shortcuts(vec![
(ContextualPanelAction::Toggle, egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::T)),
(ContextualPanelAction::FocusNext, egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::ArrowLeft)),
]);
let before = raw.content.runtime.layout().inspect();
let (invalidated, _) = raw.frame_late(vec![host_key(egui::Key::T, true), egui::Event::Text("typed text".into())], false, |_, _| {});
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
raw.frame(Vec::new(), false);
raw.frame(vec![host_key(egui::Key::T, false), host_key(egui::Key::ArrowLeft, true)], false);
raw.frame(vec![host_key(egui::Key::ArrowLeft, false)], false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
assert!(trace.borrow().editors.last().unwrap().3.contains("typed text"));
assert_eq!(raw.content.runtime.layout().inspect(), before);
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
let explicit = raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::Toggle).unwrap();
assert!(matches!(explicit, LayoutAction::Apply { .. }));
assert_eq!(raw.content.runtime.layout().inspect(), before, "resolving a command must not dispatch it");
assert!(raw.host.contextual_action(&raw.content.runtime, ContextualPanelAction::ResizeLeft).is_err());
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
assert_eq!(raw.content.commands.calls, 0);
}
}
#[test]
fn content_focus_bookmark_restores_caret_after_explicit_collapse_and_focus() {
for scale in [1.0, 2.0] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let editor = trace.borrow().editors.last().unwrap().clone();
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
raw.frame(Vec::new(), false);
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text(" retained selection".into())], false, |_, _| {});
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
raw.frame(Vec::new(), false);
let before = trace.borrow().editors.last().unwrap().3.clone();
let expected: String = before.chars().take(1).chain(std::iter::once('!')).chain(before.chars().skip(4)).collect();
let mut state = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
state.store(&raw.ctx, editor.0);
raw.frame(Vec::new(), false);
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
assert!(!caret.unwrap().is_empty(), "exercise an actual nonempty selection before hiding");
raw.content.dispatch(vec![Mutation::Collapse { section: section("wrapping"), collapsed: true }]).unwrap();
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let reply = raw
.content
.dispatch(vec![
Mutation::Collapse { section: section("wrapping"), collapsed: false },
Mutation::Focus { instance: id("wrapping-editor") },
])
.unwrap();
raw.host.note_reply(&reply);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
assert!(trace.borrow().editors.last().unwrap().3.contains("retained selection"));
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text("!".into())], false, |_, _| {});
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
raw.frame(Vec::new(), false);
assert_eq!(trace.borrow().editors.last().unwrap().3, expected, "typing must replace the restored selection, not insert beside it");
}
}
#[test]
fn custom_header_action_reports_enabled_and_pressed_state_from_retained_presentation() {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.log", "log", "log-section", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, 1.0);
raw.frame(Vec::new(), false);
let (frame, output) = raw.frame(Vec::new(), false);
let target = HostControlTarget::Pointer(HostPointerTarget::HeaderAction { instance: id("log"), action: ControlId::new("pulse").unwrap() });
let action = frame.controls.iter().find(|control| control.target == target).unwrap();
let node = &output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().find(|(id, _)| *id == action.id.accesskit_id()).unwrap().1;
assert_eq!(node.role(), egui::accesskit::Role::Button);
assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True));
assert!(!node.is_disabled());
trace.borrow_mut().disabled = true;
let (frame, output) = raw.frame(Vec::new(), false);
let action = frame.controls.iter().find(|control| control.target == target).unwrap();
assert!(!action.enabled);
let node = &output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().find(|(id, _)| *id == action.id.accesskit_id()).unwrap().1;
assert!(node.is_disabled());
assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::False));
}
#[test]
fn absent_content_response_cannot_restore_old_focus_but_live_response_keeps_its_caret() {
for scale in [1.0, 2.0] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
let editor = trace.borrow().editors.last().unwrap().clone();
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
raw.frame(Vec::new(), false);
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text(" keep caret".into())], false, |_, _| {});
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
raw.frame(Vec::new(), false);
let mut state = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
state.store(&raw.ctx, editor.0);
raw.frame(Vec::new(), false);
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
trace.borrow_mut().omit_editor = true;
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
raw.host.note_reply(&reply);
raw.frame_late(Vec::new(), false, |_, ctx| {
assert!(ctx.read_response(editor.0).is_some(), "egui still exposes the omitted previous-pass widget");
});
let header = raw_control(&raw, &HostControlTarget::Pointer(HostPointerTarget::Header { instance: id("wrapping-editor") }));
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(header.id));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
trace.borrow_mut().omit_editor = false;
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
raw.host.note_reply(&reply);
raw.frame(Vec::new(), false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
}
}
#[test]
fn modal_dismissal_keys_preserve_passive_content_restore_but_foreign_focus_wins() {
for scale in [1.0, 2.0] {
for foreign in [false, true] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
raw.prime_modal();
let editor = trace.borrow().editors.last().unwrap().clone();
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
raw.frame(Vec::new(), false);
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text(" before modal".into())], false, |_, _| {});
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
raw.frame(Vec::new(), false);
let before = trace.borrow().editors.last().unwrap().3.clone();
let expected: String = before.chars().take(1).chain(std::iter::once('!')).chain(before.chars().skip(4)).collect();
let mut state = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
state.store(&raw.ctx, editor.0);
raw.frame(Vec::new(), false);
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
raw.late_modal = true;
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
raw.frame(vec![host_key(egui::Key::Escape, true)], false);
raw.late_modal = false;
if foreign {
raw.foreign_cover = Some(egui::Rect::from_min_size(egui::pos2(900.0, 600.0), egui::vec2(200.0, 100.0)));
raw.foreign_takes_focus = true;
}
raw.frame(vec![host_key(egui::Key::Escape, false), host_key(egui::Key::Enter, true)], false);
raw.frame(vec![host_key(egui::Key::Enter, false)], false);
raw.frame(Vec::new(), false);
if foreign {
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
raw.foreign_takes_focus = false;
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
raw.host.note_reply(&reply);
raw.frame(Vec::new(), false);
}
assert_eq!(
raw.ctx.memory(|memory| memory.focused()),
Some(editor.0),
"an explicit panel focus request can intentionally leave foreign focus"
);
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
assert!(!caret.unwrap().is_empty());
let (invalidated, _) = raw.frame_late(vec![egui::Event::Text("!".into())], false, |_, _| {});
assert!(invalidated.is_none(), "changed editor text invalidates its captured metrics");
raw.frame(Vec::new(), false);
assert_eq!(trace.borrow().editors.last().unwrap().3, expected, "modal return must retain replacement selection");
}
}
}
#[test]
fn late_modal_reentry_surrenders_content_focused_after_mount() {
for scale in [1.0, 2.0] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
raw.prime_modal();
let editor = trace.borrow().editors.last().unwrap().clone();
raw.ctx.memory_mut(|memory| memory.request_focus(editor.0));
raw.frame(Vec::new(), false);
let caret = egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range();
raw.late_modal = true;
raw.frame(Vec::new(), false);
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0));
raw.late_modal = false;
raw.frame(Vec::new(), false);
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
raw.host.note_reply(&reply);
raw.late_modal = true;
raw.frame_late(Vec::new(), false, |_, ctx| {
assert_eq!(
ctx.memory(|memory| memory.focused()),
Some(editor.0),
"the actual content target receives focus after mount before the late modal is finalized"
);
});
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor.0), "the late modal must surrender newly restored content focus too");
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor.0).unwrap().cursor.char_range(), caret);
assert_eq!(raw.content.commands.calls, 0);
}
}
fn production_content_harness(combined_history: bool, assistant: bool) -> crate::scenario::Harness {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.ui.show_browser = true;
h.app.ui.show_timeline = true;
h.app.preferences.timeline_left = false;
h.app.preferences.history_in_browser = combined_history;
h.app.preferences.ai_features = assistant;
h.app.agent_chat.open = assistant;
for name in ["First", "Second"] {
h.app.run("sketch.create", serde_json::json!({"plane":"XY","name":name})).unwrap();
h.app.run("sketch.finish", serde_json::json!({})).unwrap();
}
h.app.preferences.shared_panel_layout = true;
super::production::enable_production(&mut h.app).unwrap();
h.frames(3);
h
}
fn production_controller_keys(app: &crate::SolveApp) -> Vec<ControllerKey> {
let runtime = app.panel_runtime().unwrap();
["browser", "history", "assistant", "render-browser"]
.into_iter()
.filter_map(|name| {
let instance = id(name);
let owner = runtime.instance_owner(&instance).unwrap();
let key = ControllerKey { instance, owner };
runtime.state(&key).map(|_| key)
})
.collect()
}
#[test]
fn production_browser_and_history_mount_existing_content_once_and_preserve_combined_history() {
for combined in [false, true] {
let h = production_content_harness(combined, false);
let frame = h.app.universal_panel_frame().expect("real content uses the shared host");
assert!(frame.layout.sections.iter().any(|card| card.instance == id("browser")));
assert_eq!(frame.layout.sections.iter().any(|card| card.instance == id("history")), !combined);
let rows = crate::browser::drawn_rows();
for name in ["First", "Second"] {
assert_eq!(rows.iter().filter(|row| row.0 == name).count(), 1, "{name} has one canonical Browser presentation");
assert_eq!(crate::scenario::handle_at(&format!("timeline:{name}")).is_some(), !combined);
}
if combined {
assert!(rows.iter().any(|row| row.0 == crate::i18n::tr("ui.history_title")), "combined History keeps its real Browser folder");
} else {
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
assert!(history.inline, "Bottom History uses the generic one-row composition");
let body = egui_rect(history.inline_content.unwrap());
let first = crate::scenario::handle_at("timeline:First").unwrap();
assert!(body.contains(first), "the real feature chips are inside the shared inline slot");
}
assert!(h.app.panel_runtime().unwrap().update_errors().is_empty());
}
}
#[test]
fn production_tab_collapse_and_move_keep_real_selection_document_camera_and_retained_owners() {
let mut h = production_content_harness(false, false);
let feature = h.app.session.doc.features[0].id;
h.app.run("select.set", serde_json::json!({"items":[solidcraft_engine::Sel::Feature { id: feature }]})).unwrap();
h.frames(2);
let session = h.app.session.uid;
let document = h.app.session.doc.clone();
let model = h.app.session.model.state();
let camera = h.app.cam;
let selection = h.app.session.selection.clone();
let revision = h.app.session.revision;
let undo = h.app.session.undo.len();
let dirty = h.app.session.is_dirty();
let keys = production_controller_keys(&h.app);
let controllers = h.app.panel_runtime().unwrap().controller_count();
let browser_section = h.app.panel_runtime().unwrap().layout().layout().containing(&id("browser")).unwrap().id.clone();
h.app
.dispatch_panel_layout(transaction(vec![
Mutation::Move { instance: id("history"), destination: Destination::JoinTab { section: browser_section.clone(), index: 1 } },
Mutation::Activate { instance: id("history") },
]))
.unwrap();
h.frames(3);
assert!(crate::browser::drawn_rows().is_empty(), "an inactive Browser does not render a hidden body");
assert!(crate::scenario::handle_at("timeline:First").is_some(), "History switches to its existing side rows");
h.app.dispatch_panel_layout(transaction(vec![Mutation::Activate { instance: id("browser") }])).unwrap();
h.frames(2);
assert!(crate::browser::drawn_rows().iter().any(|row| row.0 == "First"));
for collapsed in [true, false] {
h.app.dispatch_panel_layout(transaction(vec![Mutation::Collapse { section: browser_section.clone(), collapsed }])).unwrap();
h.frames(2);
assert_eq!(crate::browser::drawn_rows().is_empty(), collapsed, "collapse controls real body work");
}
h.app
.dispatch_panel_layout(transaction(vec![Mutation::Move {
instance: id("browser"),
destination: Destination::NewSection {
section: section("moved-browser"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: Size { width: 300.0, height: 320.0 },
},
}]))
.unwrap();
h.frames(3);
let card = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.instance == id("browser")).unwrap();
assert_eq!(card.zone, Zone::Right);
assert!(crate::browser::drawn_rows().iter().any(|row| row.0 == "First"));
assert_eq!(production_controller_keys(&h.app), keys);
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
assert_eq!(h.app.session.uid, session);
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
assert!(std::sync::Arc::ptr_eq(&h.app.session.model.state(), &model));
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.session.selection, selection);
assert_eq!(h.app.session.revision, revision);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.is_dirty(), dirty);
}
#[test]
fn production_browser_and_assistant_refuse_bottom_without_changing_live_content() {
let mut h = production_content_harness(false, true);
h.app.agent_chat.input = "keep this unsent draft".into();
let document = h.app.session.doc.clone();
let camera = h.app.cam;
let controllers = h.app.panel_runtime().unwrap().controller_count();
for instance in ["browser", "assistant"] {
let before = h.app.panel_runtime().unwrap().layout().inspect();
let result = h.app.dispatch_panel_layout(transaction(vec![Mutation::Move {
instance: id(instance),
destination: Destination::NewSection {
section: section("forbidden-bottom"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Bottom },
size: Size { width: 400.0, height: 120.0 },
},
}]));
assert_eq!(result.unwrap_err().rule, Rule::Placement, "only product History permits Bottom");
assert_eq!(h.app.panel_runtime().unwrap().layout().inspect(), before);
h.frames(2);
assert_eq!(h.app.agent_chat.input, "keep this unsent draft");
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
}
}
#[test]
fn production_setting_cancel_preserves_classic_and_apply_enables_the_real_host() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
assert!(!h.app.preferences.shared_panel_layout);
assert!(h.app.panel_runtime().is_none());
crate::prefs::control(&mut h.app, &serde_json::json!({"open":"Layout"})).unwrap();
h.app.preferences.shared_panel_layout = true;
h.frames(2);
assert!(h.app.panel_runtime().is_none(), "an unapplied checkbox cannot replace Classic during the Preferences preview");
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"cancel"})).unwrap();
h.frames(2);
assert!(!h.app.preferences.shared_panel_layout);
assert!(h.app.panel_runtime().is_none());
crate::prefs::control(&mut h.app, &serde_json::json!({"open":"Layout"})).unwrap();
h.app.preferences.shared_panel_layout = true;
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"apply"})).unwrap();
assert!(h.app.prefs_window.open, "Apply keeps Preferences open");
assert!(h.app.panel_runtime().is_some(), "Apply accepts the production-host preference");
h.app.preferences.shared_panel_layout = false;
crate::prefs::control(&mut h.app, &serde_json::json!({"button":"cancel"})).unwrap();
h.frames(3);
assert!(h.app.preferences.shared_panel_layout, "Cancel restores the last applied setting");
assert!(h.app.universal_panel_frame().is_some());
let saved = h.app.prefs();
let mut restored = crate::SolveApp::new(Default::default(), Default::default());
restored.load_prefs(&saved);
assert!(restored.preferences.shared_panel_layout, "the opt-in setting survives the existing preference envelope");
}
#[test]
fn production_assistant_actions_use_the_existing_confirmation_and_close_retains_its_draft_and_queue() {
let mut h = production_content_harness(false, true);
h.app.agent_chat.input = "unsent composer draft".into();
h.app.agent_chat.items.push(crate::agent_chat::Item::Agent { thought: false, text: "retained transcript".into() });
h.app.agent_chat.pending.push(crate::agent_chat::Pending {
text: "queued instruction".into(),
image: false,
context: Default::default(),
files: Vec::new(),
});
let pending = h.app.agent_chat.pending.clone();
let state = h.app.agent_chat.state();
let keys = production_controller_keys(&h.app);
let controllers = h.app.panel_runtime().unwrap().controller_count();
h.app.panel_content_action(&id("assistant"), &ControlId::new("new-chat").unwrap()).unwrap();
super::production::drain_production_actions(&mut h.app).unwrap();
assert!(h.app.agent_chat.confirm_new, "the real New Chat confirmation protects queued instructions");
assert_eq!(h.app.agent_chat.items.len(), 1);
assert_eq!(h.app.agent_chat.pending, pending);
h.app.agent_chat.confirm_new = false;
h.frames(2);
h.app.dispatch_panel_layout(transaction(vec![Mutation::Close { instance: id("assistant") }])).unwrap();
h.frames(2);
assert!(!h.app.universal_panel_frame().unwrap().layout.sections.iter().any(|card| card.instance == id("assistant")));
assert_eq!(h.app.agent_chat.input, "unsent composer draft");
assert_eq!(h.app.agent_chat.pending, pending);
assert_eq!(h.app.agent_chat.items.len(), 1);
assert_eq!(h.app.agent_chat.state(), state, "closing presentation does not restart or cancel the retained chat service");
h.app.start("ui.assistant");
h.frames(2);
assert!(h.app.agent_chat.panel_rect.is_some(), "the existing Assistant button reopens the shared body after quick close");
let section = h.app.panel_runtime().unwrap().layout().layout().containing(&id("assistant")).unwrap().id.clone();
h.app.dispatch_panel_layout(transaction(vec![Mutation::Collapse { section: section.clone(), collapsed: true }])).unwrap();
h.frames(2);
assert!(h.app.agent_chat.panel_rect.is_none());
crate::agent_chat::toggle(&mut h.app);
h.frames(2);
assert!(h.app.agent_chat.panel_rect.is_some(), "the keyboard toggle expands a shared collapsed Assistant");
h.app.dispatch_panel_layout(transaction(vec![Mutation::Collapse { section, collapsed: true }])).unwrap();
h.app.dispatch_panel_layout(transaction(vec![Mutation::Close { instance: id("assistant") }])).unwrap();
h.frames(2);
h.app.start("ui.assistant");
h.frames(2);
assert!(h.app.agent_chat.panel_rect.is_some(), "opening a panel remembered as collapsed expands it atomically");
h.app.start("ui.assistant");
h.frames(2);
assert!(h.app.agent_chat.panel_rect.is_none(), "the existing Assistant button closes an expanded shared Assistant");
crate::agent_chat::toggle(&mut h.app);
h.frames(2);
assert!(h.app.agent_chat.panel_rect.is_some());
assert_eq!(h.app.agent_chat.input, "unsent composer draft");
assert_eq!(h.app.agent_chat.pending, pending);
assert_eq!(production_controller_keys(&h.app), keys);
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
h.app.agent_chat.pending.clear();
h.app.panel_content_action(&id("assistant"), &ControlId::new("new-chat").unwrap()).unwrap();
super::production::drain_production_actions(&mut h.app).unwrap();
assert!(!h.app.agent_chat.confirm_new);
assert!(h.app.agent_chat.items.is_empty(), "the same New Chat action clears the real transcript when no confirmation is needed");
assert_eq!(h.app.agent_chat.input, "unsent composer draft");
}
#[test]
fn production_render_browser_reuses_real_lens_search_and_retains_explicit_layout_workspace() {
let mut h = production_content_harness(false, false);
let workspace = h.app.panel_runtime().unwrap().layout().layout().workspace.clone();
let document = h.app.session.doc.clone();
let camera = h.app.cam;
h.app.ui.tab = "RENDER".into();
h.frames(3);
let frame = h.app.universal_panel_frame().unwrap();
assert!(frame.layout.sections.iter().any(|card| card.instance == id("render-browser")));
assert!(!frame.layout.sections.iter().any(|card| card.instance == id("browser")));
h.app.panel_content_action(&id("render-browser"), &ControlId::new("by-look").unwrap()).unwrap();
super::production::drain_production_actions(&mut h.app).unwrap();
h.frames(2);
assert!(h.app.render_workspace.view.by_look);
h.app.panel_content_action(&id("render-browser"), &ControlId::new("search").unwrap()).unwrap();
super::production::drain_production_actions(&mut h.app).unwrap();
h.frames(2);
assert!(h.app.render_workspace.view.search_objects);
let search = h.app.panel_runtime().unwrap().instance_presentation(&id("render-browser")).unwrap();
assert_eq!(search.actions.iter().find(|action| action.id.0 == "search").unwrap().pressed, Some(true));
assert_eq!(h.app.panel_runtime().unwrap().layout().layout().workspace, workspace, "changing ribbon tabs never selects a layout workspace");
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
assert_eq!(h.app.cam, camera);
h.app.ui.tab = "SOLID".into();
h.frames(2);
assert!(h.app.universal_panel_frame().unwrap().layout.sections.iter().any(|card| card.instance == id("browser")));
h.app.ui.tab = "RENDER".into();
h.frames(2);
assert!(h.app.render_workspace.view.by_look && h.app.render_workspace.view.search_objects);
}
#[test]
fn production_short_render_assistant_keeps_conversation_above_the_pinned_composer() {
let mut h = production_content_harness(false, true);
h.call("ui.resize", serde_json::json!({"width":1600,"height":720}));
h.app.ui.tab = "RENDER".into();
for draft in [String::new(), "Retained panel draft\n".repeat(40)] {
h.app.agent_chat.input = draft.clone();
h.frames(8);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(conversation.height() < 64.0, "exercise the short viewport that formerly expanded over the composer");
assert!(
!conversation.is_positive() || conversation.bottom() <= composer.top() + 1.0,
"a short transcript viewport must not paint or scroll over the composer: draft bytes={}, conversation={conversation:?}, composer={composer:?}",
draft.len()
);
let body = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.instance == id("assistant")).unwrap().body.unwrap();
assert!(composer.bottom() <= body.origin.y + body.size.height + 1.0, "the full footer remains inside the shared content slot");
assert!(crate::scenario::rect_of("agent_chat:input").is_some());
assert_eq!(h.app.agent_chat.input, draft);
}
}
#[test]
fn production_sole_content_tracks_real_tree_folds_and_stops_at_column_capacity() {
let mut h = production_content_harness(false, true);
h.frames(6);
let frame = h.app.universal_panel_frame().unwrap();
let browser = frame.layout.sections.iter().find(|card| card.instance == id("browser")).unwrap();
let original_height = browser.outer.size.height;
assert!(original_height < frame.layout.capacity.size.height - 20.0);
let assistant = frame.layout.sections.iter().find(|card| card.instance == id("assistant")).unwrap();
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
let available = history.outer.origin.y - frame.layout.metrics.gap - assistant.outer.origin.y;
assert!((assistant.outer.size.height - available).abs() < 1.0, "Assistant fills its available column height even with an empty conversation");
let root = crate::browser::drawn_rows().first().unwrap().0.clone();
let point = h.call("ui.at", serde_json::json!({"chevron":root}));
h.call("ui.click", serde_json::json!({"x":point["result"][0],"y":point["result"][1]}));
h.frames(6);
let folded = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.instance == id("browser")).unwrap().outer.size.height;
assert!(folded < original_height - 20.0, "folding real content releases the unused space");
let point = h.call("ui.at", serde_json::json!({"chevron":root}));
h.call("ui.click", serde_json::json!({"x":point["result"][0],"y":point["result"][1]}));
h.call("ui.resize", serde_json::json!({"width":1600,"height":350}));
h.frames(6);
let frame = h.app.universal_panel_frame().unwrap();
let browser = frame.layout.sections.iter().find(|card| card.instance == id("browser")).unwrap();
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
let available = history.outer.origin.y - frame.layout.metrics.gap - browser.outer.origin.y;
assert!((browser.outer.size.height - available).abs() < 1.0, "long content is capped above Bottom History and its gap");
}
#[test]
fn production_bottom_history_has_fixed_height_without_collapse_or_height_changes() {
let mut h = production_content_harness(false, false);
h.frames(6);
let frame = h.app.universal_panel_frame().unwrap();
let history = frame.layout.sections.iter().find(|card| card.instance == id("history")).unwrap();
let spec = h.app.panel_runtime().unwrap().layout().registry().get(&PanelKind::new("solidcraft.history").unwrap()).unwrap();
assert_eq!(history.outer.size.height, spec.fixed_bottom_height.unwrap());
assert!(history.collapse.is_none());
let before = h.app.panel_runtime().unwrap().layout().inspect();
assert!(h.app.run_panel_command("panel.collapse", &serde_json::json!({"target":{"type":"instance","id":"history"},"collapsed":true})).is_err());
assert!(
h.app
.run_panel_command(
"panel.resize",
&serde_json::json!({"target":{"type":"instance","id":"history"},"size":{"width":760,"height":100},"thickness_only":true})
)
.is_err()
);
assert_eq!(h.app.panel_runtime().unwrap().layout().inspect(), before);
}
#[test]
fn production_fixed_bottom_history_ignores_a_legacy_collapsed_preference() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.ui.show_timeline = true;
h.app.preferences.timeline_left = false;
h.app.preferences.timeline_collapsed = true;
h.app.preferences.shared_panel_layout = true;
super::production::enable_production(&mut h.app).unwrap();
h.frames(6);
let section = h.app.panel_runtime().unwrap().layout().layout().containing(&id("history")).unwrap();
assert_eq!(section.location.zone(), Zone::Bottom);
assert!(!section.collapsed);
}
File diff suppressed because it is too large Load Diff
+37 -2
View File
@@ -1298,6 +1298,8 @@ preference_settings! {
language: String = "system".into(), 0;
/// Shared workspace composition; system windows and popups retain their purpose.
layout: crate::shell::LayoutMode = Default::default(), PAGE_LAYOUT;
/// Mount existing docked content in the shared layout; Classic stays available.
shared_panel_layout: bool = false, PAGE_LAYOUT;
/// Ordered view controls and their appearance.
#[serde(default)]
navigation_bar: crate::navigation_bar::NavigationBar = Default::default(), PAGE_LAYOUT;
@@ -2803,7 +2805,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
match button {
Some("defaults") => defaults(app, app.prefs_window.section),
Some("apply") if app.prefs_window.panel_restore_error.is_none() => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Some("apply") if app.prefs_window.panel_restore_error.is_none() => match crate::panel_runtime::sync_production_setting(app) {
Ok(()) => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Err(reason) => app.prefs_window.panel_restore_error = Some(solidcraft_engine::panels::commands::error_text(reason)),
},
Some("ok") => close(app, false),
Some("cancel") => close(app, true),
_ if closed => close(app, true),
@@ -3317,7 +3322,10 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
match button.as_ref().and_then(|b| b.as_str()) {
Some("ok") => close(app, false),
Some("cancel") => close(app, true),
Some("apply") if app.prefs_window.panel_restore_error.is_none() => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Some("apply") if app.prefs_window.panel_restore_error.is_none() => {
crate::panel_runtime::sync_production_setting(app).map_err(solidcraft_engine::panels::commands::error_text)?;
app.prefs_window.snapshot = Some(Snapshot::take(app));
}
Some("apply") => {}
Some("defaults") => defaults(app, app.prefs_window.section),
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
@@ -4290,6 +4298,33 @@ pub(crate) fn reveal_preference(h: &mut crate::scenario::Harness, handle: &str)
#[cfg(test)]
mod tests {
#[test]
fn invalid_legacy_panel_preferences_recover_before_shared_host_setup() {
let mut app = SolveApp::new(Default::default(), Default::default());
app.load_prefs(
&json!({"preferences":{"browser_width":0.0,"timeline_left_width":-1.0,
"agent_chat_width":1e9,"render_browser_width":0.0,"browser_share":2.0}})
.to_string(),
);
let defaults = Prefs::default();
assert_eq!(app.preferences.browser_width, 0.0);
assert_eq!(crate::browser::browser_width(app.preferences.browser_width), 170.0, "Classic keeps its existing saved-width behavior");
app.preferences.shared_panel_layout = true;
crate::panel_runtime::sync_production_setting(&mut app).unwrap();
assert!(app.panel_runtime().is_some(), "malformed legacy sizes must not leave setup retrying each frame");
let imported = crate::panel_runtime::preferences_value(&app).unwrap();
for (name, expected) in [
("browser_width", defaults.browser_width),
("history_width", defaults.timeline_left_width),
("assistant_width", defaults.agent_chat_width),
("render_width", defaults.render_browser_width),
("browser_share", defaults.browser_share),
] {
assert_eq!(imported["legacy"][name], json!(expected));
}
assert_eq!((app.preferences.browser_width, app.preferences.browser_share), (0.0, 2.0), "shared import never rewrites Classic preferences");
}
#[test]
fn theme_traits_use_resolved_background_and_maximal_contrast() {
use theme_picker::{Classification, theme_traits};
+7
View File
@@ -66,6 +66,7 @@ pub(super) fn closed(ctx: &egui::Context) {
pub(super) fn show(app: &mut SolveApp, ui: &mut Ui) {
if !crate::panel_runtime::preferences_enabled(app) {
ui_kit::settings_section(ui, "panel_layout", app.tr("ui.panel_layout.panel_layout"), |ui| panel_system(app, ui));
return;
}
let key = Id::unique(STATE);
@@ -88,6 +89,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut Ui) {
}
ui_kit::settings_section(ui, "panel_layout", app.tr("ui.panel_layout.panel_layout"), |ui| {
ui.spacing_mut().item_spacing.y = Tokens::get().space_16;
panel_system(app, ui);
workspace_selector(app, ui, &view);
tiles(app, ui, &view, &mut state);
});
@@ -97,6 +99,11 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut Ui) {
}
}
fn panel_system(app: &mut SolveApp, ui: &mut Ui) {
ui_kit::checkbox(ui, crate::i18n::tr("prefs.shared_panel_layout"), &mut app.preferences.shared_panel_layout)
.backing_on_hover_text(crate::i18n::tr("prefs.shared_panel_layout_help"));
}
fn workspace_selector(app: &mut SolveApp, ui: &mut Ui, view: &LayoutPreferencesView) {
let t = Tokens::get();
let mut workspaces: Vec<_> = view.workspaces.iter().collect();
+5
View File
@@ -3483,6 +3483,11 @@ pub(crate) fn environment_overlay(app: &mut SolveApp, ui: &mut egui::Ui, viewpor
pub(crate) fn interactive_command_info(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> solidcraft_engine::CommandInfo {
let mut info = ribbon::command_info(app, command);
if let Some(action) = solidcraft_engine::panels::ContextualPanelAction::from_command_id(command.id) {
info.disabled_reason = app.panel_contextual_disabled_reason(action);
info.enabled = info.disabled_reason.is_none();
return info;
}
// A drawing tool on the SKETCH tab kept shown outside a sketch starts one (#7904).
if crate::workspace::starts_a_sketch(app, command.id) {
info.enabled = true;
+3 -3
View File
@@ -324,7 +324,7 @@ pub fn sanitize(v: &Value) -> Result<Value, String> {
.into_iter()
.flatten()
.filter(|(id, _)| id.chars().count() <= MAX_ID)
.filter_map(|(id, keys)| Some((id.clone(), shortcut_keys(keys)?)))
.filter_map(|(id, keys)| Some((id.clone(), shortcut_keys(id, keys)?)))
.take(MAX_SHORTCUTS)
.collect();
let favourites: Vec<String> = o.get("favourites").and_then(Value::as_array).into_iter().flatten().filter_map(text).take(MAX_FAVOURITES).collect();
@@ -382,8 +382,8 @@ pub fn sanitize(v: &Value) -> Result<Value, String> {
/// A command's keys in a profile, as the keymap saves them (`""`, one key or a list of them), or
/// `None` when they can't be read or a key name is too long.
fn shortcut_keys(v: &Value) -> Option<Value> {
let keys = crate::keymap::saved_keys(v)?;
fn shortcut_keys(id: &str, v: &Value) -> Option<Value> {
let keys = crate::keymap::saved_keys(id, v)?;
keys.iter().all(|k| k.chars().count() <= MAX_ID).then(|| crate::keymap::keys_value(&keys))
}
@@ -14,6 +14,22 @@ fn error(h: &mut Harness, id: &str, params: Value) -> String {
out["error"].as_str().map(str::to_string).unwrap_or_else(|| panic!("{id} should fail: {out}"))
}
#[test]
fn panel_arrow_bindings_and_cleared_defaults_survive_profile_export_import_and_apply() {
let mut h = Harness::new();
crate::keymap::bind(&mut h.app, "panel.sash.left", "Ctrl+Left", false).unwrap();
crate::keymap::bind(&mut h.app, "panel.sash.right", "", false).unwrap();
run(&mut h, "settings.profile.save", json!({"name":"Panels"}));
let profile = run(&mut h, "settings.profile.export", json!({"name":"Panels"}))["profile"].clone();
assert_eq!(profile["shortcuts"]["panel.sash.left"], "Ctrl+Left");
assert_eq!(profile["shortcuts"]["panel.sash.right"], "");
crate::keymap::reset(&mut h.app, None);
run(&mut h, "settings.profile.import", json!({"profile":profile,"name":"Imported panels"}));
run(&mut h, "settings.profile.apply", json!({"name":"Imported panels"}));
assert_eq!(crate::keymap::keys(&h.app, "panel.sash.left"), ["Ctrl+Left"]);
assert!(crate::keymap::keys(&h.app, "panel.sash.right").is_empty());
}
#[test]
fn a_profile_holds_input_and_interface_settings_but_not_the_machine() {
let keys: Vec<&str> = keys().collect();
+5 -8
View File
@@ -534,14 +534,11 @@ fn actual_tabs_preset_raw_widget_drag_retains_the_inactive_editor_identity_and_d
let target = HostControlTarget::Pointer(HostPointerTarget::Tab { instance: editor.clone() });
let control = h.app.universal_panel_frame().unwrap().controls.iter().find(|control| control.target == target).unwrap();
assert!(
h.output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.any(|(_, node)| node.role() == egui::accesskit::Role::Button && node.label() == Some(control.accessible_name.as_str())),
"use an actual accessible Tab widget, not a reducer-only target"
h.output.accesskit_update.as_ref().unwrap().nodes.iter().any(|(id, node)| *id == control.id.accesskit_id()
&& node.role() == egui::accesskit::Role::Tab
&& node.label() == Some(control.accessible_name.as_str())
&& node.is_selected() == Some(false)),
"use the actual inactive accessible Tab widget, not a reducer-only target"
);
let bounds = control.bounds;
let card = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|card| card.section == source_id).unwrap();
+28 -1
View File
@@ -11,6 +11,17 @@ mod palettes;
use egui::{Color32, Stroke};
use serde::{Deserialize, Serialize};
/// Shared paint-only panel timing. State, geometry and focus commit immediately.
pub(crate) fn panel_motion_duration(ctx: &egui::Context, reduced: bool) -> f32 {
let requested = ctx.global_style().animation_time;
if reduced || !requested.is_finite() || requested <= 0.0 { 0.0 } else { requested.clamp(0.12, 0.16) }
}
pub(crate) fn panel_motion_ease(progress: f32) -> f32 {
let progress = if progress.is_finite() { progress.clamp(0.0, 1.0) } else { 1.0 };
1.0 - (1.0 - progress).powi(3)
}
/// Expanded Wide hosts compose controls and content in one total row below
/// this logical height. Independent of pixel density and user UI scaling.
pub const PANEL_INLINE_MAX_HEIGHT: f32 = 88.0;
@@ -140,7 +151,7 @@ impl Surfaces {
if progress >= 1.0 {
return to;
}
let eased = 1.0 - (1.0 - progress).powi(3);
let eased = panel_motion_ease(progress);
Self {
panel: oklab_blend(self.panel, to.panel, eased),
top: oklab_blend(self.top, to.top, eased),
@@ -1774,6 +1785,22 @@ pub fn apply(ctx: &egui::Context) {
#[cfg(test)]
mod tests {
#[test]
fn panel_motion_is_bounded_and_reduced_or_invalid_inputs_finish_immediately() {
let ctx = egui::Context::default();
for (requested, expected) in [(0.01, 0.12), (0.14, 0.14), (0.8, 0.16), (0.0, 0.0), (-1.0, 0.0), (f32::NAN, 0.0), (f32::INFINITY, 0.0)] {
ctx.global_style_mut(|style| style.animation_time = requested);
assert_eq!(super::panel_motion_duration(&ctx, false), expected);
assert_eq!(super::panel_motion_duration(&ctx, true), 0.0);
}
assert_eq!(super::panel_motion_ease(0.0), 0.0);
assert_eq!(super::panel_motion_ease(0.5), 0.875);
for progress in [1.0, 2.0, f32::NAN, f32::INFINITY] {
assert_eq!(super::panel_motion_ease(progress), 1.0);
}
assert_eq!(super::panel_motion_ease(-1.0), 0.0);
}
use super::*;
fn tabs_state(base: &str, overrides: BTreeMap<String, crate::theme_files::OptionValue>) -> TabThemeState {
+29 -4
View File
@@ -39,6 +39,19 @@ pub(crate) fn run_transport(app: &mut SolveApp, control: crate::ui_kit::HistoryT
let _ = app.run_heavy("timeline.roll_to", params);
}
/// Shared-header actions use the same command availability as History playback.
pub(crate) fn transport_availability(app: &SolveApp, control: crate::ui_kit::HistoryTransport) -> Result<(), String> {
if control == crate::ui_kit::HistoryTransport::Play {
if app.timeline_playback.is_some() {
return Ok(());
}
return playback_ready(app);
}
let command = solidcraft_engine::find_command("timeline.roll_to").ok_or_else(|| crate::i18n::tr("cmd.timeline_roll_to").to_owned())?;
let info = command.info(&app.session);
if info.enabled { Ok(()) } else { Err(info.disabled_reason.unwrap_or_else(|| info.label.to_owned())) }
}
/// A click runs a History control; a right-click offers to show or hide the Play button.
pub(crate) fn transport_response(app: &mut SolveApp, control: crate::ui_kit::HistoryTransport, response: &egui::Response) {
if response.clicked() {
@@ -894,12 +907,8 @@ pub(crate) fn edit_holes_item(app: &mut SolveApp, ui: &mut egui::Ui, folder: u64
fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
let t = Tokens::get();
let accent = crate::theme::TabAccent::for_tab(&t, &app.ui.tab);
let r = ui.max_rect();
let p = ui.painter().clone();
let n = app.session.doc.features.len();
let marker = app.session.doc.marker.unwrap_or(n);
// Playback controls.
let mut x = r.left() + t.space_10;
let grip_rect = Rect::from_center_size(pos2(x + t.space_10 / 2.0, r.center().y), vec2(t.space_10, t.space_16 + t.space_2));
@@ -924,6 +933,22 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
x += t.space_24 + t.space_2;
}
x += 14.0;
timeline_strip(app, ui, floating, x);
}
/// The shared host already supplies the title and declared playback controls.
pub(crate) fn panel_inline(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::browser::sync_reveals(app);
timeline_strip(app, ui, true, ui.max_rect().left());
}
fn timeline_strip(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool, x: f32) {
let t = Tokens::get();
let accent = crate::theme::TabAccent::for_tab(&t, &app.ui.tab);
let r = ui.max_rect();
let p = ui.painter().clone();
let n = app.session.doc.features.len();
let marker = app.session.doc.marker.unwrap_or(n);
// The feature that made what is under the cursor or picked in the viewport.
let from_view = [hovered_feature(app), picked_feature(app)];
// Long runs of one component's features show as one chip (`timeline_fold`). The bar shows the
+10 -1
View File
@@ -332,13 +332,21 @@ pub fn left_timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
}
t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top(), t.border);
ui.add_space(t.space_3);
egui::ScrollArea::vertical()
panel_contents(app, ui);
}
/// Side History rows retain their own scroll state without legacy transport chrome.
pub(crate) fn panel_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
let names = names_fit(ui.available_width());
let top = ui.cursor().top();
let output = egui::ScrollArea::vertical()
.id_salt("sc_left_timeline_scroll")
// The animated card owns its height, including the last few closing frames.
.min_scrolled_height(0.0)
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
.show(ui, |ui| vertical_features(app, ui, names));
crate::panel_runtime::report_content_extent(ui, top, output.content_size.y, true);
}
fn timeline_transport(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) {
@@ -1058,6 +1066,7 @@ fn timeline_group_name(
let edit_rect = Rect::from_min_max(pos2(left, rect.top() + 1.0), pos2(rect.right() - 3.0, rect.bottom() - 1.0));
let edit_id = ui.scope_id().with(("timeline_group_rename", group.id));
let response = crate::ui_kit::inline_name(ui, edit_rect, edit_id, &mut rename.text);
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
crate::scenario::publish_handle(&format!("timeline-folder-rename:{}", group.name), edit_rect.center());
if !rename.focused {
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), edit_id) {
+1 -1
View File
@@ -224,7 +224,7 @@ impl HistoryTransport {
}
}
fn label(self, playing: bool) -> &'static str {
pub(crate) fn label(self, playing: bool) -> &'static str {
crate::i18n::tr(match self {
Self::First => "ui.history_first",
Self::Back => "ui.history_back",
+59 -17
View File
@@ -95,9 +95,10 @@ pub(crate) fn panel_host_control_label_opacity(
label_opacity: f32,
) -> egui::Response {
let t = Tokens::get();
let sense = if chrome.enabled { if chrome.drag { egui::Sense::click_and_drag() } else { egui::Sense::click() } } else { egui::Sense::hover() };
let enabled = chrome.enabled && ui.is_enabled();
let sense = if enabled { if chrome.drag { egui::Sense::click_and_drag() } else { egui::Sense::click() } } else { egui::Sense::hover() };
let response = ui.interact(bounds, id, sense);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, chrome.enabled, chrome.label));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, chrome.label));
let fill = if chrome.selected {
Some(t.selected_bg)
} else if response.hovered() && chrome.enabled {
@@ -158,7 +159,12 @@ pub(crate) fn panel_host_control_label_opacity(
};
let icon_rect = egui::Rect::from_center_size(center, egui::vec2(extent, extent));
if chrome.icon_only {
crate::icons::paint_ink(&painter, icon_rect, icon, ink);
if let Some(transport) = icon.strip_prefix("timeline_").filter(|name| matches!(*name, "first" | "back" | "play" | "forward" | "last")) {
let glyph = if transport == "play" && chrome.selected { "pause" } else { transport };
crate::icons::glyphs::transport_glyph(&painter, icon_rect.center(), glyph, ink);
} else {
crate::icons::paint_ink(&painter, icon_rect, icon, ink);
}
} else {
crate::icons::paint_icon(&painter, icon_rect, icon, if chrome.enabled { 1.0 } else { 0.5 });
}
@@ -199,6 +205,7 @@ pub(crate) fn panel_host_control_label_opacity(
/// The same full-header affordance collapses and restores with one click.
/// Reuse the existing chevron master, with the kit's vertical mirror helper.
#[allow(clippy::too_many_arguments)]
pub(crate) fn panel_host_disclosure(
ui: &mut egui::Ui,
id: egui::Id,
@@ -206,6 +213,7 @@ pub(crate) fn panel_host_disclosure(
label: &str,
enabled: bool,
collapsed: bool,
reduced: bool,
) -> egui::Response {
let response = panel_host_control(
ui,
@@ -224,32 +232,53 @@ pub(crate) fn panel_host_disclosure(
rtl: false,
},
);
ui.ctx().accesskit_node_builder(response.id, |node| node.set_expanded(!collapsed));
let t = Tokens::get();
let ink = if enabled { t.form_ink } else { t.form_dim };
let extent = (bounds.width().min(bounds.height()) - t.form_icon_inset * 2.0).max(0.0);
let icon = egui::Rect::from_center_size(bounds.center(), egui::vec2(extent, extent));
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(bounds));
if collapsed {
crate::icons::paint_ink(&painter, icon, "chevron", ink);
} else {
crate::icons::paint_ink_flipped(&painter, icon, "chevron", ink);
// Only the two existing glyphs blend. The response, expanded state and
// allocated body already reflect the committed collapse state this pass.
let duration = crate::theme::panel_motion_duration(ui.ctx(), reduced);
let expanded = ui.ctx().animate_bool_with_time_and_easing(id.with("disclosure_ink"), !collapsed, duration, crate::theme::panel_motion_ease);
if expanded < 1.0 {
crate::icons::paint_ink(&painter, icon, "chevron", ink.linear_multiply(1.0 - expanded));
}
if expanded > 0.0 {
crate::icons::paint_ink_flipped(&painter, icon, "chevron", ink.linear_multiply(expanded));
}
response
}
pub(crate) fn panel_host_sash(ui: &mut egui::Ui, id: egui::Id, bounds: egui::Rect, name: &str) -> egui::Response {
/// The owner supplies the controlled extent and axis; clipping or scrolling
/// the sash must not change its accessible value or orientation.
pub(crate) fn panel_host_sash(ui: &mut egui::Ui, id: egui::Id, bounds: egui::Rect, name: &str, horizontal: bool, extent: f32) -> egui::Response {
let t = Tokens::get();
let response = ui.interact(bounds, id, egui::Sense::click_and_drag());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::SpinButton, ui.is_enabled(), name));
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(
id,
egui::EventFilter {
horizontal_arrows: bounds.height() >= bounds.width(),
vertical_arrows: bounds.width() >= bounds.height(),
..Default::default()
},
)
});
}
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, ui.is_enabled(), name));
ui.ctx().accesskit_node_builder(response.id, |node| {
node.set_role(egui::accesskit::Role::Splitter);
node.set_orientation(if horizontal { egui::accesskit::Orientation::Horizontal } else { egui::accesskit::Orientation::Vertical });
node.set_numeric_value(f64::from(extent));
});
if response.hovered() || response.has_focus() || response.dragged() {
ui.painter().rect_filled(bounds.intersect(ui.clip_rect()), t.radius_sm, t.form_primary);
}
if response.hovered() {
ui.ctx().set_cursor_icon(if bounds.width() >= bounds.height() {
egui::CursorIcon::ResizeVertical
} else {
egui::CursorIcon::ResizeHorizontal
});
ui.ctx().set_cursor_icon(if horizontal { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
}
response.on_hover_text(name)
}
@@ -269,7 +298,19 @@ pub(crate) fn panel_host_scrollbar(
) -> (egui::Response, Option<f32>) {
let t = Tokens::get();
let response = ui.interact(bounds, id, egui::Sense::click_and_drag());
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: horizontal, vertical_arrows: !horizontal, ..Default::default() })
});
}
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::SpinButton, ui.is_enabled(), name));
ui.ctx().accesskit_node_builder(response.id, |node| {
node.set_role(egui::accesskit::Role::ScrollBar);
node.set_orientation(if horizontal { egui::accesskit::Orientation::Horizontal } else { egui::accesskit::Orientation::Vertical });
node.set_numeric_value(f64::from(offset));
node.set_min_numeric_value(0.0);
node.set_max_numeric_value(f64::from((total - visible).max(0.0)));
});
let length = if horizontal { bounds.width() } else { bounds.height() };
let maximum = (total - visible).max(0.0);
if length <= 0.0 || maximum <= 0.0 {
@@ -305,10 +346,11 @@ pub(crate) fn panel_host_scrollbar(
}
}
if response.has_focus() {
let delta = ui.input(|input| {
painter.rect_stroke(bounds.shrink(t.space_2), t.radius_sm, t.control_border_stroke(t.form_primary), egui::StrokeKind::Inside);
let delta = ui.input_mut(|input| {
let back = if horizontal { egui::Key::ArrowLeft } else { egui::Key::ArrowUp };
let forward = if horizontal { egui::Key::ArrowRight } else { egui::Key::ArrowDown };
u8::from(input.key_pressed(forward)) as f32 - u8::from(input.key_pressed(back)) as f32
u8::from(input.consume_key(egui::Modifiers::NONE, forward)) as f32 - u8::from(input.consume_key(egui::Modifiers::NONE, back)) as f32
});
if delta != 0.0 {
next = Some((offset + delta * t.control_height).clamp(0.0, maximum));
@@ -401,7 +443,7 @@ pub(crate) fn panel_host_popup<R>(ui: &mut egui::Ui, id: egui::Id, bounds: egui:
ui.set_min_size(bounds.size());
Container::paint_frame(ui.painter(), frame, bounds - frame.total_margin());
let inner = bounds.shrink(Tokens::get().space_6);
let mut content = ui.new_child(egui::UiBuilder::new().id_salt("content").max_rect(inner));
let mut content = ui.new_child(egui::UiBuilder::new().id_salt("content").max_rect(inner).accessibility_role(egui::accesskit::Role::Menu));
content.set_clip_rect(inner.intersect(clip));
body(&mut content)
})
+69 -2
View File
@@ -373,7 +373,16 @@ fn universal_chrome_controls_have_real_accessible_widgets_and_stable_ids_at_both
.map(|(_, node)| node)
.unwrap_or_else(|| panic!("exported control needs its own actual widget: {control:?}"));
assert_eq!(node.label(), Some(control.accessible_name.as_str()), "actual widget/metadata name parity: {control:?}");
assert!(matches!(node.role(), egui::accesskit::Role::Button | egui::accesskit::Role::SpinButton | egui::accesskit::Role::Slider));
let expected_role = match &control.target {
HostControlTarget::Pointer(HostPointerTarget::Tab { .. }) => egui::accesskit::Role::Tab,
HostControlTarget::Pointer(HostPointerTarget::Sash { .. } | HostPointerTarget::Resize { .. }) => {
egui::accesskit::Role::Splitter
}
HostControlTarget::Scroll { .. } => egui::accesskit::Role::ScrollBar,
HostControlTarget::PanelMenu { .. } | HostControlTarget::Shared { .. } => egui::accesskit::Role::MenuItem,
HostControlTarget::Pointer(_) | HostControlTarget::Content { .. } => egui::accesskit::Role::Button,
};
assert_eq!(node.role(), expected_role, "the actual widget role follows its typed target: {control:?}");
if matches!(&control.target, HostControlTarget::Pointer(HostPointerTarget::Menu { .. })) {
assert_eq!(control.accessible_name, crate::i18n::tr("ui.panel_host.menu"));
}
@@ -439,6 +448,44 @@ fn universal_host_slots_and_menu_button_stay_bounded_at_minimum_and_small_window
}
}
#[test]
fn panel_disclosure_blends_only_ink_and_reduced_motion_finishes_immediately() {
for scale in [1.0, 2.0] {
let ctx = crate::fonts::test_context();
ctx.set_pixels_per_point(scale);
ctx.enable_accesskit();
ctx.global_style_mut(|style| style.animation_time = 0.14);
let id = egui::Id::unique("disclosure-transition");
let bounds = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(28.0, 28.0));
let frame = |time, collapsed, reduced| {
let output = crate::test_support::frame(&ctx, egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
let response = crate::ui_kit::panel_host::panel_host_disclosure(ui, id, bounds, "Toggle panel", true, collapsed, reduced);
assert_eq!(response.rect, bounds);
});
let node =
&output.platform_output.accesskit_update.as_ref().unwrap().nodes.iter().find(|(node_id, _)| *node_id == id.accesskit_id()).unwrap().1;
assert_eq!(node.is_expanded(), Some(!collapsed), "semantics commit before the paint transition");
assert!(output.shapes.iter().all(|shape| shape.clip_rect.contains_rect(bounds)), "paint keeps the inherited clip");
output.shapes.iter().filter(|shape| matches!(shape.shape, egui::Shape::Mesh(_))).count()
};
assert_eq!(frame(0.0, true, false), 1);
frame(1.0 / 60.0, false, false);
assert_eq!(frame(2.0 / 60.0, false, false), 2, "only the existing down/up glyphs crossfade");
for tick in 3..14 {
frame(f64::from(tick) / 60.0, false, false);
}
assert_eq!(frame(14.0 / 60.0, false, false), 1);
assert_eq!(frame(15.0 / 60.0, true, false), 2, "collapse also transitions without delaying expanded state");
for tick in 16..28 {
frame(f64::from(tick) / 60.0, true, false);
}
assert_eq!(frame(28.0 / 60.0, true, false), 1);
assert_eq!(frame(29.0 / 60.0, false, true), 1, "reduced motion paints the committed endpoint immediately");
ctx.global_style_mut(|style| style.animation_time = 0.0);
assert_eq!(frame(30.0 / 60.0, true, false), 1, "disabled animation also paints the endpoint");
}
}
#[test]
fn universal_kit_chrome_uses_one_container_and_preserves_parent_clipping_and_focus_semantics() {
use crate::ui_kit::panel_host::{HostChrome, panel_host_control, panel_host_sash, panel_host_surface};
@@ -487,7 +534,7 @@ fn universal_kit_chrome_uses_one_container_and_preserves_parent_clipping_and_foc
rtl: false,
},
);
let resize = panel_host_sash(ui, egui::Id::unique("synthetic-sash"), sash, "Resize synthetic neighbours");
let resize = panel_host_sash(ui, egui::Id::unique("synthetic-sash"), sash, "Resize synthetic neighbours", true, bounds.height());
assert!(title.sense.is_focusable());
assert!(resize.sense.is_focusable());
assert_eq!(title.rect, header);
@@ -500,6 +547,26 @@ fn universal_kit_chrome_uses_one_container_and_preserves_parent_clipping_and_foc
for label in ["Synthetic long localized panel title", "Resize synthetic neighbours"] {
assert!(tree.nodes.iter().any(|(_, node)| node.label() == Some(label)), "actual focusable widget: {label}");
}
let separator = &tree.nodes.iter().find(|(_, node)| node.label() == Some("Resize synthetic neighbours")).unwrap().1;
assert_eq!(separator.role(), egui::accesskit::Role::Splitter);
assert_eq!(separator.orientation(), Some(egui::accesskit::Orientation::Horizontal));
assert_eq!(separator.numeric_value(), Some(f64::from(bounds.height())));
let translated = sash.translate(egui::vec2(35.0, 27.0));
let moved = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
let response = panel_host_sash(ui, egui::Id::unique("synthetic-sash"), translated, "Resize synthetic neighbours", true, bounds.height());
assert_eq!(response.rect, translated);
});
let moved_separator = &moved
.platform_output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.find(|(_, node)| node.label() == Some("Resize synthetic neighbours"))
.unwrap()
.1;
assert_eq!(moved_separator.numeric_value(), separator.numeric_value(), "moving the same controlled panel cannot announce a resize");
let (mut bevel_edges, mut inset_borders) = (0, 0);
for shape in &output.shapes {
assert!(clip.contains_rect(shape.clip_rect), "chrome paint must not escape its inherited clip");
+19 -1
View File
@@ -48,6 +48,10 @@ Query replies contain no new host effects.
| `panel.float` | Same as move, requiring a `new_section` with `floating` location. |
| `panel.popout` | Same as move, requiring a `new_section` with `window` location and return destination. Desktop/headless without native capability refuse; web explicitly lowers to `floating`. |
| `panel.focus` | `{target:instance,expected_revision?}`. Reopens retained closed state, activates, expands and requests focus. |
| `panel.focus.next`, `panel.focus.previous` | `{expected_revision?}`. Resolve the next or previous eligible panel from the live host's keyboard context. |
| `panel.toggle` | `{expected_revision?}`. Collapse or expand the focused panel's section. |
| `panel.move.next_zone` | `{expected_revision?}`. Move the focused panel to its next permitted zone. |
| `panel.sash.left`, `panel.sash.right`, `panel.sash.up`, `panel.sash.down`, `panel.sash.reset` | `{expected_revision?}`. Resize or reset the focused sash through the same constrained candidate path as pointer resizing. |
| `panel.activate` | `{target:instance,expected_revision?}`. Activates an open tab without expanding or requesting focus. |
| `panel.action` | `{target:instance,action:ControlId,expected_revision?}`. Validates registration, active/open state, availability and owning controller binding, then invokes the real bound action. Returns `LayoutReply`; missing/disabled bindings refuse. |
| `panel.peek` | `{target:instance,expected_revision?}`. Temporary presentation of a collapsed tab, without persistence changes to size/collapse. |
@@ -61,6 +65,19 @@ Query replies contain no new host effects.
| `layout.get` | `{}` → `{state,workspaces:[{id,title_key,default_preset,has_remembered_layout}]}`. Selection is only `state.layout.workspace`. |
| `layout.workspace.select` | `{workspace:WorkspaceId,expected_revision?}` → `{previous_workspace,source:"remembered"\|"default_preset"\|"unchanged",state}`. Registered descriptors only; no implicit creation. |
The nine contextual commands above return `LayoutReply`. Their strict input accepts
only the optional revision, checked before resolving the host context. A headless
sink without a live context refuses rather than guessing a focused panel or sash.
Command metadata reports the live availability and a localized disabled reason.
An atomic batch may contain one contextual command alone; a mixed atomic batch
refuses before applying any action.
Shortcuts use the existing rebindable keymap. The four sash arrow bindings retain
their existing defaults; the other contextual commands start unbound. Automatic
host shortcuts defer to a focused text editor. Explicit contextual command calls
may target its actually owned panel, but cannot infer a sash from an editor.
Modal, document, viewport and stale-presentation guards still apply.
Destinations are distinct foundation operations, not aliases:
```json
@@ -263,7 +280,7 @@ actual current Session without another service tick. Runtime inspection remains
read-only. The shared host adds no production command registration beyond the
application-layout commands above.
For native host evidence, `ui.panel_fixture {enabled?, preset?, remember?, translucency_percent?}` installs bounded
For native host evidence, `ui.panel_fixture {enabled?, preset?, remember?, translucency_percent?, reduced_motion?}` installs bounded
synthetic content in the real `SolveApp` workspace host. The normal connection
authentication applies. It is off by default and ephemeral unless explicitly
enabled with `remember:true`. It adds no design change, production content
@@ -277,6 +294,7 @@ registration or native panel window.
| `{"preset":"tabs","remember":true}` | Enable the specimen and its application preference catalogue. Restore a saved record after valid host dimensions are available. |
| `{"preset":"surfaces","translucency_percent":50}` | Enable declared surface-policy specimens with a runtime-only translucency request (finite 0–100 percent). Readability may raise effective opacity. |
| `{"translucency_percent":100}` | Change the enabled specimen's request without replacing controllers, drafts or layout. Refuse if no specimen owns the host. |
| `{"reduced_motion":true}` | Disable specimen host animation without replacing controllers or changing saved preferences or layout. False restores theme-controlled motion. Refuse if no specimen owns the host. |
| `{"enabled":false}` | Preflight and tear down only the specimen runtime; refusal leaves it intact. |
Presets are `docks` (Left/Right/Bottom, with two Left sections), `tabs` (two groups
+74 -9
View File
@@ -2,14 +2,44 @@
The public contracts are `solidcraft_engine::panels` and
`solidcraft_ui_egui::panel_runtime`. The #11305 host is explicitly enabled and
uses synthetic controllers to exercise shared behaviour. Production Browser,
History, Assistant, Render, Sketch, feature forms and BOM adapters are sibling
work. The shipping shell, content, preferences and Classic behaviour retain
their existing defaults. A specimen is not production migration or visual evidence.
The current migration boundary permits only existing docked Browser, History and
Assistant content and their allowed Render counterparts in future sibling work.
uses synthetic controllers to exercise shared behaviour. Preferences → Layout →
Use shared panel layout mounts the existing Browser, History, Assistant and Render
Scene Browser content in that host. Apply enables the setting; clearing it returns
to the existing Classic placement. Classic remains the shipping default.
Sketch, feature forms, BOM and the other Render panels remain separate work.
A specimen is not production migration or visual evidence.
Draggable tool windows and the existing native Assistant retain their legacy hosts.
## Initial real-content integration
The shared runtime owns placement and small presentation descriptors. Existing
`SolveApp` document state, Browser semantic rows, History playback and `AgentChat`
continue to own content. A frame-scoped callback invokes the existing renderers
inside the shared host's stable content identity and current focus-report service.
It retains no application reference beyond that paint call. Immutable host
geometry remains visible to content commands throughout the call.
Hidden bodies do not render. History playback and Assistant polling stay in their
existing application paths. Closing an Assistant panel does not stop its backend
or clear its draft. Popped Assistant windows keep the legacy path. Combined
Browser/History uses the existing content preference and suppresses the separate
History body. Render Inspector, Shots and appearances keep their existing hosts
and contribute their actual footprints to navigation and camera fitting.
Visible bodies report their natural scroll content and fixed control bands.
A sole production panel hugs that content until column capacity requires scrolling;
Assistant declares Fill to use the full available side height. A newly visible unmeasured
width starts at capacity until its first real paint supplies its natural extent.
No hidden body is rendered to obtain a measurement.
Bottom History declares a fixed, symmetrically padded icon row. It has no collapse
or height-resize control; commands enforce the same bounds. A new side column
inherits the current source column's width, or the registered standard width when
the source has no column. The standard width matches Classic Browser's default.
Existing columns and explicit resizing keep their width.
The full responsive/native acceptance matrix remains roadmap work. This initial
integration does not close the parent rollout issue.
## Ownership
The engine registry owns bounded kind metadata and capabilities. Layout owns
@@ -310,8 +340,40 @@ The renderer resolves surface intent through the shared Container, raising its
fill alpha when canonical text needs more contrast. Captured `paint_alpha` and
`contrast` report the effective paint and any readable fallback separately from
declarative intent. Input and occlusion remain reserved regardless of alpha.
Comprehensive accessibility/keyboard bindings, production workspace rollout and
real content adapters remain their named peers' work.
Remaining panel migrations, native window services and full workspace rollout
stay with their named peers.
### Keyboard, accessibility and motion
The shared host exposes disclosure state, selected tabs and tab lists, action
toggle state, menu items and oriented splitters to accessibility. Disabled actions
retain their reason. Keyboard menu navigation returns focus to its opener on
Escape, and panel focus traversal reveals headers inside bounded scroll extents.
Nine contextual commands use the existing command registry and keymap for panel
focus, collapse, moving to the next permitted zone and sash resize/reset. They
resolve the actual owned host context, then use the existing typed admission and
dispatch path. They do not infer focus from a model or headless session. Automatic
shortcuts defer to focused text editors and foreign or modal UI. Explicit command
calls can resolve an owned editor's panel without borrowing its caret keys.
Controller and egui identities retain editor state. Focus restoration is scoped
to the controller owner, document and viewport; it must not steal deliberate
focus from another control. Hidden bodies are never rendered to restore focus.
Adapters report freshly rendered focusable responses through
`PanelLocalServices::register_focus_response`. The host accepts only eligible
responses from the current content slot, rather than relying on egui's
previous-pass widget lookup. Without a current response, an explicit panel focus
request uses the visible header and leaves retained editor memory intact.
Bookmarks retain a focused text editor's cursor range because egui collapses a
selection during an unfocused render. Eligible restoration replaces only that
range in the existing editor state; undo, IME and scroll state stay in egui.
The native host follows the effective locale's logical direction, including
Arabic and Hebrew, through the existing edge mapping. Saved layout coordinates
and physical floating/native window positions remain unchanged. Theme motion
uses a 120–160 ms cubic-out paint transition. Reduced motion or disabled theme
animation completes immediately; layout, semantics and focus never wait for it.
## Specimens and verification boundary
@@ -327,6 +389,9 @@ specimen with `remember:true`; loading preferences alone never starts it. Saved
restoration waits for valid host dimensions and uses normal runtime admission.
Fixture workspace/preset registrations remain synthetic evidence, not shipping
production registrations.
The optional `reduced_motion` boolean updates the enabled specimen in place.
It changes no saved preference, controller identity, draft or layout and refuses
without a specimen owner. This review seam adds no operating-system motion service.
### Surface review specimen
@@ -367,7 +432,7 @@ controls shield the model. Temporary popup shielding is not a Fit/Centre obstacl
This is shared surface policy and an opt-in review seam, not a production content
migration or a new appearance preset. Classic and shipping defaults remain.
Future approved docked adapters are Browser, History, Assistant and their allowed
The initial approved docked adapters are Browser, History, Assistant and their allowed
RenderSceneBrowser counterpart; other content conversions require a separate
scope decision. Persistent legacy forms retain their existing hosts and continue
to contribute their actual footprints until their own migrations.
+12 -2
View File
@@ -102,8 +102,16 @@ Captured frame alpha is reported separately from declarative surface intent;
the opt-in surface renderer reports contrast outcomes and raises opacity to retain
readable primary and muted text. An invalid authored pair uses an opaque accessible
fallback and reports its reason rather than claiming the original pair complies.
Native windows, comprehensive accessibility/keyboard
bindings, saved workspace rollout and real content migrations remain peer work.
The shared host publishes expanded disclosure, selected tab/tab-list, toggled
action, menu-item and oriented splitter semantics. Keyboard panel commands use
the registered keymap and typed host admission; focused text editors retain their
automatic key handling. Menus support keyboard navigation and Escape focus return.
Focus traversal reveals clipped headers without changing camera geometry.
Owner-scoped focus restoration preserves eligible editor state without rendering
hidden bodies or taking focus from foreign UI. Native logical direction follows
the effective locale. Shared paint transitions use theme timing and cubic-out
easing; reduced motion commits immediately.
Native windows, saved workspace rollout and real content migrations remain peer work.
The authenticated `ui.panel_fixture` seam is off by default and rejects replacing
an existing runtime. Presets are `docks`, `tabs`, `collapsed`, `floating`, `overflow`
@@ -117,6 +125,8 @@ including fallback reasons. See [the surface specimen contract](panel-foundation
An enabled specimen accepts `translucency_percent` alone to update presentation
in place, retaining its controllers, drafts, layout and model. A missing/disabled
specimen host or a runtime owned by another provider refuses this update.
An enabled specimen also accepts the ephemeral `reduced_motion` boolean, which
changes animation without replacing content or writing preferences.
Drafts, controller identities and lifecycle/service/action counters are inspectable.
The native specimen exposes editor Apply/Finish/Cancel actions. Separate
action-binding fixtures retain their declared test actions.
+9
View File
@@ -122,8 +122,11 @@ panel.collapse application panel layout; no geometry (engine/
panel.dismiss application panel layout; no geometry (engine/UI/MCP tests)
panel.float application panel layout; no geometry (engine/UI/MCP tests)
panel.focus application panel layout; no geometry (engine/UI/MCP tests)
panel.focus.next application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.focus.previous application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.list application panel layout; no geometry (engine/UI/MCP tests)
panel.move application panel layout; no geometry (engine/UI/MCP tests)
panel.move.next_zone application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.native_close application panel layout; no geometry (engine/UI/MCP tests)
panel.open application panel layout; no geometry (engine/UI/MCP tests)
panel.peek application panel layout; no geometry (engine/UI/MCP tests)
@@ -131,6 +134,12 @@ panel.popout application panel layout; no geometry (engine/
panel.reset_pair application panel layout; no geometry (engine/UI/MCP tests)
panel.resize application panel layout; no geometry (engine/UI/MCP tests)
panel.resize_pair application panel layout; no geometry (engine/UI/MCP tests)
panel.sash.down application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.sash.left application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.sash.reset application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.sash.right application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.sash.up application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
panel.toggle application panel layout; no geometry (crates/engine/src/panels/tests.rs and crates/ui-egui/src/panel_runtime/tests.rs)
parameters.comment parameter bookkeeping; a value change that rebuilds is parameters.change, which cases use
parameters.complete parameter bookkeeping; a value change that rebuilds is parameters.change, which cases use
parameters.delete parameter bookkeeping; a value change that rebuilds is parameters.change, which cases use