mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 03:01:58 +00:00
* 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:
@@ -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": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})?;
|
||||
|
||||
@@ -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 §ion.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 §ion.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"));
|
||||
}
|
||||
|
||||
@@ -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, §ion)?;
|
||||
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(§ion)?.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, §ion)?;
|
||||
@@ -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 §ion.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())) {
|
||||
|
||||
@@ -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(§ion("bottom")).unwrap();
|
||||
assert_eq!(bottom.size.height, 160.0);
|
||||
assert!(!bottom.collapsed);
|
||||
assert!(!section_collapsible(service.layout(), ®istry, 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(®istry).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, ¶ms).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(§ion("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(§ion("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: ®istry, 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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms).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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, §ion, 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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(), ®istry)?;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user