From a05ca7060f85bec9e6d7df085cc177b7aa2e703a Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Wed, 19 Aug 2026 15:46:54 -0700 Subject: [PATCH 01/39] fix(collab): keep snapshot compression off network workers --- crates/flowstate-collab/src/net/direct.rs | 71 ++++++++++++++++--- .../src/net/wire_compression.rs | 1 + 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/crates/flowstate-collab/src/net/direct.rs b/crates/flowstate-collab/src/net/direct.rs index 924ad585..ceae0136 100644 --- a/crates/flowstate-collab/src/net/direct.rs +++ b/crates/flowstate-collab/src/net/direct.rs @@ -630,6 +630,41 @@ fn link_is_fast(connection: &Connection) -> bool { .is_some_and(|rtt| rtt < FAST_LINK_RTT) } +enum PreparedWirePayload { + Original(Vec), + Shared(Arc<[u8]>), +} + +impl PreparedWirePayload { + fn as_slice(&self) -> &[u8] { + match self { + Self::Original(bytes) => bytes, + Self::Shared(bytes) => bytes, + } + } +} + +async fn prepare_wire_payload(payload: Vec, compressible: bool, link_is_fast: bool) -> Result<(WireCodec, PreparedWirePayload, usize)> { + let uncompressed_len = payload.len(); + if !compressible || link_is_fast || payload.len() < 1024 { + return Ok((WireCodec::None, PreparedWirePayload::Original(payload), uncompressed_len)); + } + + tokio::task::spawn_blocking(move || { + let (codec, wire) = super::wire_compression::compress_for_wire(&payload, false); + let wire = match wire { + super::wire_compression::WireBytes::Borrowed(bytes) => { + debug_assert_eq!(bytes.len(), payload.len()); + PreparedWirePayload::Original(payload) + }, + super::wire_compression::WireBytes::Shared(bytes) => PreparedWirePayload::Shared(bytes), + }; + (codec, wire, uncompressed_len) + }) + .await + .context("collaboration wire compression task failed") +} + async fn write_response(send: &mut SendStream, outcome: ServeOutcome, compressible: bool, link_is_fast: bool) -> Result<()> { tracing::trace!( outcome = outcome.kind(), @@ -639,21 +674,16 @@ async fn write_response(send: &mut SendStream, outcome: ServeOutcome, compressib match outcome { ServeOutcome::Header(header) => write_frame(send, &encode_frame(&header)?).await?, ServeOutcome::Payload(payload) => { - // Snapshots/updates are compressed by size (dictionary for small, long mode - // for big); non-CRDT payloads (assets) and fast links stream verbatim. - // §perf: WireBytes hands the caller the SAME Arc as the cache (or a borrow of - // `payload`), avoiding the 1-2 full compressed-payload memcpys the old Cow path took. - let (codec, wire): (WireCodec, super::wire_compression::WireBytes<'_>) = if compressible { - super::wire_compression::compress_for_wire(&payload, link_is_fast) - } else { - (WireCodec::None, super::wire_compression::WireBytes::Borrowed(payload.as_slice())) - }; + // zstd level 19 is deliberately CPU-heavy. Keep it off this runtime's two + // async workers: concurrent first-time joiners must not starve gossip, + // commands, or the snapshot timeout that is meant to report failures. + let (codec, wire, uncompressed_len) = prepare_wire_payload(payload, compressible, link_is_fast).await?; let header = DirectResponseHeader::Ok { codec, wire_len: wire.as_slice().len() as u64, - uncompressed_len: payload.len() as u64, + uncompressed_len: uncompressed_len as u64, }; - tracing::trace!(codec = ?codec, wire_bytes = wire.as_slice().len(), payload_bytes = payload.len(), "wrote collaboration direct payload for the wire"); + tracing::trace!(codec = ?codec, wire_bytes = wire.as_slice().len(), payload_bytes = uncompressed_len, "wrote collaboration direct payload for the wire"); write_frame(send, &encode_frame(&header)?).await?; write_payload(send, wire.as_slice()).await?; }, @@ -774,6 +804,25 @@ mod tests { use super::*; use iroh::{SecretKey, endpoint::presets}; + #[tokio::test(flavor = "current_thread")] + async fn snapshot_compression_does_not_block_network_executor() -> Result<()> { + let mut payload = vec![0_u8; 4 * 1024 * 1024]; + for (index, byte) in payload.iter_mut().enumerate() { + *byte = ((index.wrapping_mul(31) ^ index.rotate_left(7)) & 0xff) as u8; + } + + let compression = tokio::spawn(prepare_wire_payload(payload, true, false)); + timeout(Duration::from_millis(100), tokio::time::sleep(Duration::from_millis(1))) + .await + .context("snapshot compression blocked the async network executor")?; + let (codec, wire, uncompressed_len) = compression.await??; + + assert_ne!(codec, WireCodec::None); + assert_eq!(uncompressed_len, 4 * 1024 * 1024); + assert!(wire.as_slice().len() < uncompressed_len); + Ok(()) + } + fn asset_request() -> DirectRequest { DirectRequest::Asset { session: SessionId::new(), diff --git a/crates/flowstate-collab/src/net/wire_compression.rs b/crates/flowstate-collab/src/net/wire_compression.rs index 16cbba48..4ac7701d 100644 --- a/crates/flowstate-collab/src/net/wire_compression.rs +++ b/crates/flowstate-collab/src/net/wire_compression.rs @@ -144,6 +144,7 @@ pub enum WireBytes<'a> { Shared(Arc<[u8]>), } +#[cfg(test)] impl WireBytes<'_> { #[must_use] pub fn as_slice(&self) -> &[u8] { From 182c14aeacfdc57487594c766645bcdcce7c326e Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Wed, 19 Aug 2026 15:53:41 -0700 Subject: [PATCH 02/39] fix(collab): build joined snapshots off UI thread --- crates/flowstate/src/collab/session.rs | 98 ++++++++++++++++---------- 1 file changed, 59 insertions(+), 39 deletions(-) diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index a8f03cb3..7f5645e8 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -97,6 +97,12 @@ pub struct JoinedDocument { pub document: DocumentProjection, } +struct JoinedDocumentBuild { + document: DocumentProjection, + authority: Arc, + io: DocIoHandle, +} + #[derive(Clone, Debug)] pub struct SessionRosterEntry { pub name: String, @@ -385,7 +391,8 @@ impl CollabSession { match neighbor_rx.recv().await { Ok(JoinNeighborSignal::NeighborUp) => {}, Ok(JoinNeighborSignal::TimedOut) | Err(_) => { - let detail = "Couldn't reach anyone in this session. Make sure the inviter is online and the invite is current, then try again.".to_string(); + let detail = + "Couldn't reach anyone in this session. Make sure the inviter is online and the invite is current, then try again.".to_string(); let _ = session.update(cx, |session, cx| { session.detach(DetachReason::JoinFailed(detail.clone()), cx); }); @@ -407,20 +414,22 @@ impl CollabSession { let joined = match reply_rx.recv().await { Ok(Ok(bytes)) => { tracing::info!(session = %session_id, snapshot_bytes = bytes.len(), "collaboration join snapshot pulled"); - session - .update(cx, |session, cx| match session.finish_join_snapshot(&bytes, cx) { - Ok(joined) => Ok(joined), - Err(error) => { - tracing::error!(session = %session.session, error = %format_args!("{error:#}"), "building collaboration document from snapshot failed"); - let detail = format!("building collaboration document failed: {error:#}"); - session.detach(DetachReason::JoinFailed(detail), cx); - Err(error.context("building collaboration document failed")) - }, - }) - .unwrap_or_else(|error| { - tracing::error!(error = %error, "collaboration join session disappeared while building snapshot"); - Err(anyhow!("collaboration join session disappeared: {error}")) - }) + match session.update(cx, |session, cx| session.begin_join_snapshot_build(bytes.len(), cx)) { + Err(error) => Err(anyhow!("collaboration join session disappeared before building snapshot: {error}")), + Ok(Err(error)) => Err(error), + Ok(Ok(())) => { + let built = cx + .background_executor() + .spawn(async move { Self::build_join_snapshot(bytes) }) + .await; + session + .update(cx, |session, cx| session.finish_join_snapshot(built, cx)) + .unwrap_or_else(|error| { + tracing::error!(error = %error, "collaboration join session disappeared while installing snapshot"); + Err(anyhow!("collaboration join session disappeared: {error}")) + }) + }, + } }, Ok(Err(error)) => { tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "pulling collaboration join snapshot failed"); @@ -713,12 +722,11 @@ impl CollabSession { tracing::info!(session = %self.session, peer = %peer, peers_present = self.peers_present(), "local collaboration peer established"); } - fn finish_join_snapshot(&mut self, snapshot: &[u8], cx: &mut Context) -> Result { - tracing::info!(session = %self.session, snapshot_bytes = snapshot.len(), "building collaboration document from join snapshot"); + fn begin_join_snapshot_build(&mut self, snapshot_len: usize, cx: &mut Context) -> Result<()> { if matches!(self.phase, SessionPhase::Detached(_)) { bail!("collaboration join is no longer active"); } - let total = snapshot.len() as u64; + let total = snapshot_len as u64; self.phase = SessionPhase::Joining(JoinStage::FetchingSnapshot { got: total, total: Some(total), @@ -726,31 +734,44 @@ impl CollabSession { cx.notify(); self.phase = SessionPhase::Joining(JoinStage::Building); cx.notify(); + Ok(()) + } + fn build_join_snapshot(snapshot: Vec) -> Result { let doc = LoroDoc::new(); flowstate_document::loro_schema::configure_text_styles(&doc); doc - .import_with(snapshot, "remote") + .import_with(&snapshot, "remote") .context("importing collaboration snapshot failed")?; let runtime = CrdtRuntime::from_doc(doc, None, None).context("creating joined collaboration CRDT runtime")?; let mut document = runtime .projection_snapshot() .context("projecting joined Loro-native document")?; - // Loro-first services (spec §3): wrap the imported core in the write gate. - // The session keeps only the transport-side I/O handle; the write - // authority is parked for the workspace handoff (join gate: the editor - // cannot receive it before this point, i.e. before the initial snapshot - // import completed). let (authority, gate) = LocalDocHandle::new(runtime, LocalWriteConfig::default()); let io = DocIoHandle::spawn(gate).context("starting joined collaboration document I/O service")?; - // §15/§31: bind this joiner's durable author identity to the joined - // document so their revisions record an author and `users_by_id` is - // populated. The user-registration op converges to peers via - // anti-entropy. Fire-and-forget and non-fatal: a failure must not break - // the join. `create_document_panel` receives this document through the - // `Attachment` source, which deliberately skips re-binding to avoid a - // redundant second call. - let identity_io = io.clone(); + document.theme = load_document_theme(); + Ok(JoinedDocumentBuild { + document, + authority: Arc::new(authority), + io, + }) + } + + fn finish_join_snapshot(&mut self, built: Result, cx: &mut Context) -> Result { + if matches!(self.phase, SessionPhase::Detached(_)) { + bail!("collaboration join is no longer active"); + } + let built = match built { + Ok(built) => built, + Err(error) => { + tracing::error!(session = %self.session, error = %format_args!("{error:#}"), "building collaboration document from snapshot failed"); + let detail = format!("building collaboration document failed: {error:#}"); + self.detach(DetachReason::JoinFailed(detail), cx); + return Err(error.context("building collaboration document failed")); + }, + }; + + let identity_io = built.io.clone(); cx.spawn(async move |session, cx| { let (user_id, display_name) = cx .background_executor() @@ -769,17 +790,16 @@ impl CollabSession { } }) .detach(); - document.theme = load_document_theme(); tracing::info!( session = %self.session, - paragraphs = document.paragraphs.len(), - blocks = document.blocks.len(), - assets = document.assets.assets.len(), + paragraphs = built.document.paragraphs.len(), + blocks = built.document.blocks.len(), + assets = built.document.assets.assets.len(), "built collaboration document from join snapshot", ); - self.runtime = Some(io); - self.join_authority = Some(Arc::new(authority)); + self.runtime = Some(built.io); + self.join_authority = Some(built.authority); fidelity::event(FidelityClass::Frontier, "runtime-vv-reset", || { format!("session={} source=join-snapshot prior_bytes={}", self.session, self.runtime_vv.len()) }); @@ -787,7 +807,7 @@ impl CollabSession { Ok(JoinedDocument { session: self.session, title: format!("{} (shared)", self.title), - document, + document: built.document, }) } From 464e5ccb10e8410e04d5b0be8215ecdde9d2926b Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Wed, 19 Aug 2026 15:58:45 -0700 Subject: [PATCH 03/39] fix(collab): avoid eager history warmup on join --- crates/flowstate-collab/src/doc_io.rs | 32 ++++++++------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/crates/flowstate-collab/src/doc_io.rs b/crates/flowstate-collab/src/doc_io.rs index 36a3b58a..196dea6e 100644 --- a/crates/flowstate-collab/src/doc_io.rs +++ b/crates/flowstate-collab/src/doc_io.rs @@ -431,12 +431,6 @@ fn io_loop(core: &Arc>, receiver: &Receiver) { // session — part of the receiving peer's 13.1 GB/run import churn. let mut unfreed_import_bytes: usize = 0; const IMPORT_CACHE_FREE_BYTES: usize = 4 * 1024 * 1024; - // §perf-heaven T8.18 (first cold import): warm the retained import calculator's - // trackers ONCE when this doc first shows collaboration activity, so the first - // remote import reuses the built index rather than paying the cold tracker - // build synchronously on the receive path. Gated so a single-user doc never - // pays for it. - let mut import_calc_warmed = false; loop { let request = if let Some(request) = deferred.pop() { request @@ -536,23 +530,15 @@ fn io_loop(core: &Arc>, receiver: &Receiver) { } }, IoRequest::PumpPublish { reply } => { - let result = core - .lock(GateHolder::ExportUpdates) - .map(|mut guard| { - // §perf-heaven T8.18: the first publish marks this doc as an ACTIVE - // collaboration participant. Warm the retained import calculator now - // (off the receive path) so the FIRST remote import reuses the - // id_to_cursor index instead of building it cold. Safe: a stale warmed - // tracker is rebuilt by `start_tracking`'s validity guard, so warming - // can only save work, never change a computed diff. - if !import_calc_warmed { - guard.doc().inner().warm_import_diff_calculator(); - import_calc_warmed = true; - } - guard.take_pending_publish() - }) - .map_err(|poisoned| anyhow::anyhow!(poisoned)); - send_reply(&reply, result); + // Never prebuild Loro's full-history import index here. On a newly + // joined history-heavy document that "warmup" can monopolize this + // service thread for minutes while holding the write gate, making the + // window unable to process editor/layout work. Import builds only the + // state it actually needs when remote updates arrive. + send_reply( + &reply, + gate_call(core, GateHolder::ExportUpdates, |runtime| Ok(runtime.take_pending_publish())), + ); }, IoRequest::ProjectionSnapshot { reply } => send_reply( &reply, From d05cc28200ce7c0b4ebafb17cd864eb5ae4a7598 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Thu, 20 Aug 2026 11:53:19 -0700 Subject: [PATCH 04/39] refactor: migrate GPUI timers to executors --- .../flowstate/src/collab/discovery_runtime.rs | 8 +++-- crates/flowstate/src/collab/session.rs | 14 +++++--- .../flowstate/src/collab/session_presence.rs | 14 +++++--- crates/flowstate/src/collab/session_timers.rs | 8 +++-- .../flowstate/src/workspace/toolkit_panel.rs | 6 ++-- .../src/rich_text/editor/chunk_prefetch.rs | 24 ++++++++++---- .../gpui-flowtext/src/rich_text/editor/mod.rs | 2 +- .../src/rich_text/editor/mouse.rs | 27 +++++++++++++--- .../src/rich_text/editor/recovery.rs | 32 ++++++------------- 9 files changed, 83 insertions(+), 52 deletions(-) diff --git a/crates/flowstate/src/collab/discovery_runtime.rs b/crates/flowstate/src/collab/discovery_runtime.rs index 70fcb35d..b4654506 100644 --- a/crates/flowstate/src/collab/discovery_runtime.rs +++ b/crates/flowstate/src/collab/discovery_runtime.rs @@ -8,7 +8,7 @@ use flowstate_collab::{ identity::{PortableIdentitySecret, SignedProfile}, }; use futures_util::{FutureExt as _, future::Either}; -use gpui::{App, Timer}; +use gpui::{App, BackgroundExecutor}; use iroh::EndpointAddr; use crate::app_settings::{load_app_settings, load_dropbox_collaboration}; @@ -61,8 +61,9 @@ impl DiscoveryRuntime { let bluetooth_enabled = settings.bluetooth_collaboration_discovery_enabled && !settings.collaboration_discovery_paused; let discovery_paused = settings.collaboration_discovery_paused; let (commands, receiver) = async_channel::unbounded(); + let executor = cx.borrow_mut().background_executor().clone(); cx.borrow_mut() - .spawn(async move |_| run_discovery_actor(receiver, dropbox, bluetooth_enabled, discovery_paused)) + .spawn(async move |_| run_discovery_actor(receiver, dropbox, bluetooth_enabled, discovery_paused, executor)) .detach(); Self { commands } } @@ -103,6 +104,7 @@ async fn run_discovery_actor( dropbox: Option<(flowstate_collab::dropbox::DropboxCredentials, String)>, bluetooth_enabled: bool, discovery_paused: bool, + executor: BackgroundExecutor, ) { let mut backends: Vec> = Vec::new(); if !discovery_paused { @@ -121,7 +123,7 @@ async fn run_discovery_actor( let mut bluetooth_cursor = 0_usize; loop { let command = receiver.recv().boxed(); - let refresh = Timer::after(DISCOVERY_REFRESH).boxed(); + let refresh = executor.timer(DISCOVERY_REFRESH).boxed(); match futures_util::future::select(command, refresh).await { Either::Left((Ok(DiscoveryCommand::Upsert(publication)), _)) => { let session = publication.session; diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index 7f5645e8..fc8cc063 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -21,7 +21,7 @@ use flowstate_collab::{ proto_gossip::GossipMsg, }; use flowstate_fidelity::{self as fidelity, FidelityClass}; -use gpui::{Context, Entity, EventEmitter, Subscription, Timer}; +use gpui::{Context, Entity, EventEmitter, Subscription}; use loro::{LoroDoc, Subscription as LoroSubscription}; use uuid::Uuid; @@ -369,8 +369,10 @@ impl CollabSession { let (neighbor_tx, neighbor_rx) = async_channel::bounded(1); self.join_neighbor_tx = Some(neighbor_tx.clone()); self.phase = SessionPhase::Joining(JoinStage::Subscribing); - cx.spawn(async move |_, _| { - Timer::after(JOIN_FIRST_NEIGHBOR_TIMEOUT).await; + cx.spawn(async move |_, cx| { + cx.background_executor() + .timer(JOIN_FIRST_NEIGHBOR_TIMEOUT) + .await; let _ = neighbor_tx.try_send(JoinNeighborSignal::TimedOut); }) .detach(); @@ -503,7 +505,7 @@ impl CollabSession { .detach(); cx.spawn(async move |session, cx| { - Timer::after(JOIN_SNAPSHOT_TIMEOUT).await; + cx.background_executor().timer(JOIN_SNAPSHOT_TIMEOUT).await; let detail = "Joining this session timed out while fetching the document snapshot. Check your connection and try again.".to_string(); let timed_out = session .update(cx, |session, cx| { @@ -1244,7 +1246,9 @@ impl CollabSession { } self.publish_pump_pending = true; cx.spawn(async move |session, cx| { - Timer::after(Duration::from_millis(LOCAL_UPDATE_PUBLISH_DEBOUNCE_MS)).await; + cx.background_executor() + .timer(Duration::from_millis(LOCAL_UPDATE_PUBLISH_DEBOUNCE_MS)) + .await; let _ = session.update(cx, |session, cx| { session.publish_pump_pending = false; session.pump_publish(cx); diff --git a/crates/flowstate/src/collab/session_presence.rs b/crates/flowstate/src/collab/session_presence.rs index 9b814eed..dd152a82 100644 --- a/crates/flowstate/src/collab/session_presence.rs +++ b/crates/flowstate/src/collab/session_presence.rs @@ -6,7 +6,7 @@ use flowstate_collab::{ presence::{PresenceState, PresenceStore}, }; use flowstate_fidelity::{self as fidelity, FidelityClass}; -use gpui::{Context, Timer}; +use gpui::Context; use super::{CollabSession, SessionNotice}; @@ -122,7 +122,9 @@ impl CollabSession { } self.presence_refresh_pending = true; cx.spawn(async move |session, cx| { - Timer::after(PRESENCE_REFRESH_DEBOUNCE).await; + cx.background_executor() + .timer(PRESENCE_REFRESH_DEBOUNCE) + .await; let _ = session.update(cx, |session, cx| { session.presence_refresh_pending = false; session.refresh_own_presence(cx); @@ -146,7 +148,9 @@ impl CollabSession { } self.external_caret_refresh_pending = true; cx.spawn(async move |session, cx| { - Timer::after(EXTERNAL_CARET_REFRESH_DEBOUNCE).await; + cx.background_executor() + .timer(EXTERNAL_CARET_REFRESH_DEBOUNCE) + .await; let _ = session.update(cx, |session, cx| { session.external_caret_refresh_pending = false; session.refresh_external_carets_now(cx); @@ -166,7 +170,9 @@ impl CollabSession { let generation = self.comment_annotation_refresh_generation; let session_id = self.session; cx.spawn(async move |session, cx| { - Timer::after(EXTERNAL_CARET_REFRESH_DEBOUNCE).await; + cx.background_executor() + .timer(EXTERNAL_CARET_REFRESH_DEBOUNCE) + .await; let result = runtime.comments().await; let _ = session.update(cx, |session, cx| { if session.comment_annotation_refresh_generation != generation { diff --git a/crates/flowstate/src/collab/session_timers.rs b/crates/flowstate/src/collab/session_timers.rs index 3f9c75d7..ccf1e6b6 100644 --- a/crates/flowstate/src/collab/session_timers.rs +++ b/crates/flowstate/src/collab/session_timers.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use flowstate_collab::{net::NetCommand, presence::PRESENCE_KEEPALIVE_SECS}; -use gpui::{Context, Timer}; +use gpui::Context; use super::{Attachment, CollabSession, Connectivity, DetachReason, SessionPhase}; @@ -100,7 +100,7 @@ impl CollabSession { Ok(Some(delay)) => delay, Ok(None) | Err(_) => break, }; - Timer::after(delay).await; + cx.background_executor().timer(delay).await; match session.update(cx, |session, cx| session.run_digest_tick(cx)) { Ok(true) => {}, Ok(false) | Err(_) => break, @@ -116,7 +116,9 @@ impl CollabSession { tracing::debug!(session = %session_id, "starting collaboration presence timer"); cx.spawn(async move |session, cx| { loop { - Timer::after(Duration::from_secs(PRESENCE_KEEPALIVE_SECS)).await; + cx.background_executor() + .timer(Duration::from_secs(PRESENCE_KEEPALIVE_SECS)) + .await; match session.update(cx, |session, cx| session.run_presence_tick(cx)) { Ok(true) => {}, Ok(false) | Err(_) => break, diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index af64a4cd..e869eb1e 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -6,7 +6,7 @@ use std::{ time::Duration, }; -use gpui::{App, Context, IntoElement, PathPromptOptions, Pixels, Timer, Window, div, point, prelude::*, px, size}; +use gpui::{App, Context, IntoElement, PathPromptOptions, Pixels, Window, div, point, prelude::*, px, size}; use gpui_component::{ ActiveTheme as _, Icon, IconName, Selectable as _, Sizable, button::{Button, ButtonVariants}, @@ -328,7 +328,9 @@ impl Workspace { self.tub_watch_polling = true; cx.spawn(async move |workspace, cx| { loop { - Timer::after(Duration::from_millis(900)).await; + cx.background_executor() + .timer(Duration::from_millis(900)) + .await; let keep_polling = workspace .update(cx, |workspace, cx| { let Some(watcher) = &workspace.tub_watcher else { diff --git a/crates/gpui-flowtext/src/rich_text/editor/chunk_prefetch.rs b/crates/gpui-flowtext/src/rich_text/editor/chunk_prefetch.rs index c251edc6..21f72f14 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/chunk_prefetch.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/chunk_prefetch.rs @@ -27,7 +27,12 @@ impl RichTextEditor { let mut prep_queue = Vec::new(); let active = self.active_height_range(); let predicted = self.predicted_visible_height_range(width); - let mut candidates = Vec::with_capacity(predicted.len().saturating_add(active.len()).saturating_add(16)); + let mut candidates = Vec::with_capacity( + predicted + .len() + .saturating_add(active.len()) + .saturating_add(16), + ); for range in [ expand_paragraph_range(predicted.clone(), paragraph_count, 4), expand_paragraph_range(active, paragraph_count, 2), @@ -138,13 +143,18 @@ impl RichTextEditor { // (observed on large documents with object blocks). Drop it instead so // the queue can drain; the paragraph renders with the chunks it has. flowstate_fidelity::event(flowstate_fidelity::FidelityClass::Structure, "prefetch-no-progress", || { - format!("paragraph {paragraph_ix} needs prefetch but chunking made no progress ({before} chunks); dropped to break the render loop") + format!( + "paragraph {paragraph_ix} needs prefetch but chunking made no progress ({before} chunks); dropped to break the render loop" + ) }); } } } if start.elapsed() >= budget { - self.layout_runtime_metrics.prefetch_budget_overruns = self.layout_runtime_metrics.prefetch_budget_overruns.saturating_add(1); + self.layout_runtime_metrics.prefetch_budget_overruns = self + .layout_runtime_metrics + .prefetch_budget_overruns + .saturating_add(1); break; } } @@ -156,7 +166,10 @@ impl RichTextEditor { // Patch, don't nuke: the loop above recorded every refined paragraph in // `pending_item_sizes_patch_range` (see `note_item_sizes_patch_paragraph`); // a full O(blocks) rebuild only runs when the patch is inapplicable. - if self.try_patch_item_sizes_cache(width, scroll_anchor.clone(), window, cx).is_none() { + if self + .try_patch_item_sizes_cache(width, scroll_anchor.clone(), window, cx) + .is_none() + { self.item_sizes_cache = None; let _ = self.rebuild_item_sizes_cache_with_prefetch(width, scroll_anchor, false, window, cx); } @@ -236,7 +249,7 @@ impl RichTextEditor { self.pending_typing_prefetch_resume = true; let delay = self.typing_prefetch_resume_delay(); cx.spawn(async move |editor, cx| { - Timer::after(delay).await; + cx.background_executor().timer(delay).await; let _ = editor.update(cx, |editor, cx| { editor.pending_typing_prefetch_resume = false; if editor.disposed { @@ -252,5 +265,4 @@ impl RichTextEditor { }) .detach(); } - } diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index 0312a06a..d5db653f 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -16,7 +16,7 @@ use crop::Rope; use gpui::{ App, Bounds, ClipboardEntry, ClipboardItem, Context, CursorStyle, DragMoveEvent, Entity, EntityInputHandler, ExternalPaths, FocusHandle, Focusable, Image, ImageFormat, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - PathPromptOptions, Pixels, Point, Render, SharedString, Size, Subscription, Task, Timer, UTF16Selection, Window, actions, div, img, point, + PathPromptOptions, Pixels, Point, Render, SharedString, Size, Subscription, Task, UTF16Selection, Window, actions, div, img, point, prelude::*, px, relative, rgb, size, }; use gpui_component::ActiveTheme as _; diff --git a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs index 456bb48d..602aa354 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs @@ -123,7 +123,9 @@ impl RichTextEditor { return; } if event.dragging() - && let Some(BlockSelection::TableCell { block_ix, row_ix, cell_ix, .. }) = self.selected_block + && let Some(BlockSelection::TableCell { + block_ix, row_ix, cell_ix, .. + }) = self.selected_block && let Some(( BlockSelection::TableCell { row_ix: hit_row, @@ -314,8 +316,19 @@ impl RichTextEditor { // anything styled or multi-paragraph moves as a rich fragment intent. let plain_text = (drag.fragment.paragraphs.len() == 1) .then(|| &drag.fragment.paragraphs[0]) - .filter(|paragraph| paragraph.runs.iter().all(|run| run.styles == RunStyles::default())) - .map(|paragraph| paragraph.runs.iter().map(|run| run.text.as_str()).collect::()); + .filter(|paragraph| { + paragraph + .runs + .iter() + .all(|run| run.styles == RunStyles::default()) + }) + .map(|paragraph| { + paragraph + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::() + }); match plain_text { Some(text) => { self.write_insert_text_at_caret(&text, cx); @@ -355,7 +368,9 @@ impl RichTextEditor { self.caret_blink_active = true; cx.spawn(async move |editor, cx| { loop { - Timer::after(Duration::from_millis(530)).await; + cx.background_executor() + .timer(Duration::from_millis(530)) + .await; let keep_running = editor .update(cx, |editor, cx| { if editor.disposed || !editor.caret_blink_active { @@ -438,7 +453,9 @@ impl RichTextEditor { self.autoscroll_active = true; cx.spawn(async move |editor, cx| { loop { - Timer::after(Duration::from_millis(16)).await; + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; let keep_running = editor .update(cx, |editor, cx| { if editor.disposed { diff --git a/crates/gpui-flowtext/src/rich_text/editor/recovery.rs b/crates/gpui-flowtext/src/rich_text/editor/recovery.rs index a39227e3..86756bca 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/recovery.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/recovery.rs @@ -44,28 +44,12 @@ impl RichTextEditor { // into one range can pin nearly the whole cache while scrolling far from // the active paragraph. let layout_keep_ranges = ParagraphCacheRetainRanges { - visible: expand_paragraph_range( - visible.clone(), - paragraph_count, - OFFSCREEN_LAYOUT_CACHE_OVERSCAN_PARAGRAPHS, - ), - active: expand_paragraph_range( - active.clone(), - paragraph_count, - OFFSCREEN_LAYOUT_CACHE_OVERSCAN_PARAGRAPHS, - ), + visible: expand_paragraph_range(visible.clone(), paragraph_count, OFFSCREEN_LAYOUT_CACHE_OVERSCAN_PARAGRAPHS), + active: expand_paragraph_range(active.clone(), paragraph_count, OFFSCREEN_LAYOUT_CACHE_OVERSCAN_PARAGRAPHS), }; let prep_keep_ranges = ParagraphCacheRetainRanges { - visible: expand_paragraph_range( - visible, - paragraph_count, - OFFSCREEN_PREP_CACHE_OVERSCAN_PARAGRAPHS, - ), - active: expand_paragraph_range( - active, - paragraph_count, - OFFSCREEN_PREP_CACHE_OVERSCAN_PARAGRAPHS, - ), + visible: expand_paragraph_range(visible, paragraph_count, OFFSCREEN_PREP_CACHE_OVERSCAN_PARAGRAPHS), + active: expand_paragraph_range(active, paragraph_count, OFFSCREEN_PREP_CACHE_OVERSCAN_PARAGRAPHS), }; self @@ -160,7 +144,7 @@ impl RichTextEditor { let delay = self.recovery_write_delay(); self.recovery_write_in_progress = true; cx.spawn(async move |editor, cx| { - Timer::after(delay).await; + cx.background_executor().timer(delay).await; let snapshot_timing = Instant::now(); let decision = editor .update(cx, |editor, cx| { @@ -173,7 +157,10 @@ impl RichTextEditor { editor.recovery_write_in_progress = false; editor.schedule_recovery_write(cx); RecoveryWriteDecision::Rescheduled - } else if editor.last_text_input_at.is_some_and(|last_input| last_input.elapsed() < RECOVERY_TYPING_IDLE_WINDOW) { + } else if editor + .last_text_input_at + .is_some_and(|last_input| last_input.elapsed() < RECOVERY_TYPING_IDLE_WINDOW) + { editor.recovery_write_in_progress = false; editor.schedule_recovery_write(cx); RecoveryWriteDecision::Rescheduled @@ -249,5 +236,4 @@ impl RichTextEditor { .filter(|delay| *delay > RECOVERY_WRITE_DEBOUNCE) .unwrap_or(RECOVERY_WRITE_DEBOUNCE) } - } From 6bd2a1628e6cf7561e48c848d577a9d0a8daffc2 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Thu, 20 Aug 2026 11:57:16 -0700 Subject: [PATCH 05/39] fix: update flowtext for pinned GPUI --- crates/gpui-flowtext/src/rich_text/editor/commands.rs | 2 +- crates/gpui-flowtext/src/rich_text/editor/media.rs | 2 +- crates/gpui-flowtext/src/rich_text/editor/mouse.rs | 2 +- crates/gpui-flowtext/src/rich_text/editor/object_selection.rs | 4 ++-- crates/gpui-flowtext/src/rich_text/editor/traits.rs | 2 +- crates/gpui-flowtext/src/rich_text/paint.rs | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/commands.rs b/crates/gpui-flowtext/src/rich_text/editor/commands.rs index 30cbdacc..03f40b01 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/commands.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/commands.rs @@ -466,7 +466,7 @@ impl RichTextEditor { } if let Some(image) = item.entries().iter().find_map(|entry| match entry { ClipboardEntry::Image(image) => Some(image.clone()), - ClipboardEntry::String(_) => None, + ClipboardEntry::String(_) | ClipboardEntry::ExternalPaths(_) => None, }) { self.insert_clipboard_image(image, cx); return; diff --git a/crates/gpui-flowtext/src/rich_text/editor/media.rs b/crates/gpui-flowtext/src/rich_text/editor/media.rs index f1f40f55..b7eb17f3 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/media.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/media.rs @@ -86,7 +86,7 @@ impl RichTextEditor { let Some(Block::Image(image)) = self.document.blocks.get(block_ix).cloned() else { return; }; - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); self.selected_block = Some(BlockSelection::Image(block_ix)); self.table_cell_block_ix = 0; self.table_cell_caret = 0; diff --git a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs index 602aa354..66e91caf 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs @@ -1,7 +1,7 @@ #[hotpath::measure_all] impl RichTextEditor { fn on_mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); self.image_resize_drag = None; self.table_column_resize_drag = None; self.clear_drop_preview(); diff --git a/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs b/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs index 66b03e61..e5a13cb3 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs @@ -35,7 +35,7 @@ impl RichTextEditor { window: &mut Window, cx: &mut Context, ) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); if let Some((selection, paragraph_block_ix, byte)) = self.table_cell_selection_at(block_ix, position, window, cx) { self.selected_block = Some(selection); self.table_cell_block_ix = paragraph_block_ix; @@ -137,7 +137,7 @@ impl RichTextEditor { let Some((column_ix, widths, before)) = self.table_column_resize_hit_at(block_ix, position, window, cx) else { return false; }; - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); self.selected_block = Some(BlockSelection::Table(block_ix)); self.table_column_resize_drag = Some(TableColumnResizeDrag { block_ix, diff --git a/crates/gpui-flowtext/src/rich_text/editor/traits.rs b/crates/gpui-flowtext/src/rich_text/editor/traits.rs index 80390957..0ef3a276 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/traits.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/traits.rs @@ -249,7 +249,7 @@ impl Render for RichTextEditor { .bottom_0() .child( Scrollbar::vertical(&self.scroll_handle) - .scrollbar_show(ScrollbarShow::Always) + .mode(ScrollbarShow::Always) ), ) } diff --git a/crates/gpui-flowtext/src/rich_text/paint.rs b/crates/gpui-flowtext/src/rich_text/paint.rs index 7ce83f3a..f19734bc 100644 --- a/crates/gpui-flowtext/src/rich_text/paint.rs +++ b/crates/gpui-flowtext/src/rich_text/paint.rs @@ -568,8 +568,8 @@ pub(super) fn scroll_by(scroll_handle: &ScrollHandle, delta_y: Pixels) -> bool { #[hotpath::measure] pub(super) fn clamp_scroll_offset(scroll_handle: &ScrollHandle, mut offset: Point) -> Point { let max = scroll_handle.max_offset(); - offset.x = offset.x.min(px(0.0)).max(-max.width); - offset.y = offset.y.min(px(0.0)).max(-max.height); + offset.x = offset.x.min(px(0.0)).max(-max.x); + offset.y = offset.y.min(px(0.0)).max(-max.y); offset } From a72459806f54e3f6fc83ad13bdc02ad933b441b4 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Thu, 20 Aug 2026 11:57:27 -0700 Subject: [PATCH 06/39] build: pin WASM-capable GPUI cohort --- Cargo.lock | 3313 +++++++---- Cargo.toml | 32 +- crates/flowstate-corpus/Cargo.toml | 1 + crates/flowstate-document/Cargo.toml | 2 +- crates/flowstate-docx/Cargo.toml | 2 +- crates/flowstate/Cargo.toml | 4 +- vendor/gpui-base/Cargo.toml | 58 + vendor/gpui-base/LICENSE-APACHE | 191 + vendor/gpui-base/README.md | 326 ++ vendor/gpui-base/examples/README.md | 34 + vendor/gpui-base/examples/components.rs | 9 + .../examples/showcase/components/accordion.rs | 69 + .../showcase/components/alert_dialog.rs | 128 + .../examples/showcase/components/avatar.rs | 33 + .../examples/showcase/components/button.rs | 41 + .../examples/showcase/components/calendar.rs | 78 + .../examples/showcase/components/checkbox.rs | 43 + .../showcase/components/collapsible.rs | 59 + .../showcase/components/color_picker.rs | 127 + .../examples/showcase/components/combobox.rs | 103 + .../showcase/components/date_picker.rs | 42 + .../examples/showcase/components/dialog.rs | 141 + .../examples/showcase/components/dock.rs | 478 ++ .../examples/showcase/components/editor.rs | 31 + .../showcase/components/hover_card.rs | 59 + .../examples/showcase/components/input.rs | 31 + .../examples/showcase/components/link.rs | 43 + .../examples/showcase/components/mod.rs | 43 + .../showcase/components/number_input.rs | 86 + .../examples/showcase/components/otp_input.rs | 46 + .../showcase/components/pagination.rs | 54 + .../examples/showcase/components/popover.rs | 62 + .../examples/showcase/components/popup.rs | 52 + .../examples/showcase/components/progress.rs | 63 + .../examples/showcase/components/radio.rs | 49 + .../showcase/components/radio_group.rs | 76 + .../examples/showcase/components/resizable.rs | 73 + .../examples/showcase/components/scrollbar.rs | 29 + .../examples/showcase/components/select.rs | 85 + .../examples/showcase/components/sheet.rs | 119 + .../examples/showcase/components/slider.rs | 65 + .../examples/showcase/components/switch.rs | 51 + .../examples/showcase/components/table.rs | 86 + .../examples/showcase/components/tabs.rs | 72 + .../showcase/components/text_selection.rs | 359 ++ .../examples/showcase/components/textarea.rs | 31 + .../examples/showcase/components/toast.rs | 84 + .../examples/showcase/components/toggle.rs | 29 + .../showcase/components/toggle_group.rs | 69 + .../examples/showcase/components/tooltip.rs | 46 + .../examples/showcase/components/tree.rs | 58 + .../showcase/components/virtual_list.rs | 62 + vendor/gpui-base/examples/showcase/mod.rs | 579 ++ .../examples/showcase/syntect_highlighter.rs | 264 + vendor/gpui-base/examples/wasm/Cargo.toml | 27 + vendor/gpui-base/examples/wasm/Makefile | 11 + .../gpui-base/examples/wasm/scripts/build.sh | 11 + vendor/gpui-base/examples/wasm/src/lib.rs | 35 + vendor/gpui-base/examples/wasm/www/.gitignore | 3 + vendor/gpui-base/examples/wasm/www/bun.lock | 91 + vendor/gpui-base/examples/wasm/www/index.html | 1 + .../gpui-base/examples/wasm/www/package.json | 1 + .../gpui-base/examples/wasm/www/src/main.js | 62 + .../examples/wasm/www/vite.config.js | 2 + vendor/gpui-base/src/accordion.rs | 491 ++ vendor/gpui-base/src/actions.rs | 39 + vendor/gpui-base/src/alert_dialog.rs | 314 ++ vendor/gpui-base/src/animation.rs | 396 ++ vendor/gpui-base/src/async_util.rs | 7 + vendor/gpui-base/src/auto_scroll.rs | 155 + vendor/gpui-base/src/avatar.rs | 187 + vendor/gpui-base/src/bin/components.rs | 10 + vendor/gpui-base/src/button.rs | 494 ++ vendor/gpui-base/src/calendar.rs | 1009 ++++ vendor/gpui-base/src/checkbox.rs | 704 +++ vendor/gpui-base/src/collapsible.rs | 95 + vendor/gpui-base/src/color_picker.rs | 995 ++++ vendor/gpui-base/src/combobox.rs | 246 + vendor/gpui-base/src/component_traits.rs | 61 + vendor/gpui-base/src/date_picker.rs | 106 + vendor/gpui-base/src/dialog.rs | 619 ++ vendor/gpui-base/src/dock/active.rs | 222 + vendor/gpui-base/src/dock/dock_area.rs | 4324 ++++++++++++++ vendor/gpui-base/src/dock/dock_placement.rs | 250 + vendor/gpui-base/src/dock/drag.rs | 372 ++ .../dock/fixtures/bare_tab_panel_root.json | 19 + .../src/dock}/fixtures/layout.json | 2 +- .../dock/fixtures/legacy_empty_tab_group.json | 9 + .../src/dock/fixtures/nested_splits.json | 24 + vendor/gpui-base/src/dock/fixtures/tiles.json | 23 + .../fixtures/tiles_tab_panel_children.json | 36 + .../src/dock/fixtures/unregistered_panel.json | 15 + .../src/dock/fixtures/zero_size_sentinel.json | 10 + vendor/gpui-base/src/dock/layout/builder.rs | 380 ++ vendor/gpui-base/src/dock/layout/edit.rs | 799 +++ vendor/gpui-base/src/dock/layout/mod.rs | 16 + vendor/gpui-base/src/dock/layout/node.rs | 166 + vendor/gpui-base/src/dock/layout/normalize.rs | 522 ++ vendor/gpui-base/src/dock/layout/tree.rs | 334 ++ vendor/gpui-base/src/dock/mod.rs | 218 + vendor/gpui-base/src/dock/panel.rs | 310 + vendor/gpui-base/src/dock/registry.rs | 119 + vendor/gpui-base/src/dock/state.rs | 297 + vendor/gpui-base/src/dock/state_convert.rs | 826 +++ vendor/gpui-base/src/dock/tab_group.rs | 1783 ++++++ vendor/gpui-base/src/dock/test_support.rs | 196 + vendor/gpui-base/src/dock/tiles_geometry.rs | 559 ++ vendor/gpui-base/src/dock/tiles_state.rs | 1048 ++++ vendor/gpui-base/src/element_ext.rs | 31 + vendor/gpui-base/src/event.rs | 60 + vendor/gpui-base/src/focus_trap.rs | 228 + vendor/gpui-base/src/geometry.rs | 151 + vendor/gpui-base/src/global_state.rs | 155 + vendor/gpui-base/src/history.rs | 313 ++ vendor/gpui-base/src/hover_card.rs | 282 + vendor/gpui-base/src/index_path.rs | 102 + vendor/gpui-base/src/input/README.md | 20 + .../src/input/base}/blink_cursor.rs | 29 +- .../src/input/base}/change.rs | 12 +- .../src/input/base}/cursor.rs | 0 vendor/gpui-base/src/input/base/element.rs | 3195 +++++++++++ vendor/gpui-base/src/input/base/kind.rs | 348 ++ vendor/gpui-base/src/input/base/layout.rs | 43 + .../src/input/base}/mask_pattern.rs | 92 +- vendor/gpui-base/src/input/base/mod.rs | 257 + vendor/gpui-base/src/input/base/mode.rs | 371 ++ .../src/input/base}/movement.rs | 63 +- vendor/gpui-base/src/input/base/native.rs | 154 + .../src/input/base}/rope_ext.rs | 70 +- vendor/gpui-base/src/input/base/selection.rs | 145 + vendor/gpui-base/src/input/base/state.rs | 4987 +++++++++++++++++ .../gpui-base/src/input/base/undo_manager.rs | 263 + .../gpui-base/src/input/editor/decorations.rs | 336 ++ .../src/input/editor}/diagnostics.rs | 90 +- .../src/input/editor/display_map/README.md | 46 + .../input/editor/display_map/display_map.rs | 352 ++ .../src/input/editor/display_map/fold_map.rs | 343 ++ .../src/input/editor/display_map/folding.rs | 19 + .../src/input/editor/display_map/mod.rs | 66 + .../input/editor/display_map/text_wrapper.rs | 1363 +++++ .../src/input/editor/display_map/wrap_map.rs | 162 + .../src/input/editor/highlighting.rs | 103 + .../src/input/editor}/indent.rs | 120 +- .../src/input/editor}/lsp/code_actions.rs | 57 +- .../src/input/editor}/lsp/completions.rs | 163 +- .../src/input/editor}/lsp/definitions.rs | 104 +- .../src/input/editor/lsp/document_colors.rs | 179 + .../gpui-base/src/input/editor/lsp/hover.rs | 110 + vendor/gpui-base/src/input/editor/lsp/mod.rs | 129 + .../gpui-base/src/input/editor/lsp/overlay.rs | 217 + .../src/input/editor/lsp/semantic_tokens.rs | 341 ++ vendor/gpui-base/src/input/editor/mod.rs | 245 + vendor/gpui-base/src/input/editor/search.rs | 425 ++ vendor/gpui-base/src/input/input/mod.rs | 33 + vendor/gpui-base/src/input/mod.rs | 99 + vendor/gpui-base/src/input/textarea/mod.rs | 30 + vendor/gpui-base/src/lib.rs | 190 + vendor/gpui-base/src/link.rs | 466 ++ vendor/gpui-base/src/list_settings.rs | 17 + vendor/gpui-base/src/macos_accessibility.rs | 78 + vendor/gpui-base/src/measure.rs | 48 + vendor/gpui-base/src/motion.rs | 398 ++ vendor/gpui-base/src/number_input.rs | 489 ++ vendor/gpui-base/src/otp_input.rs | 228 + vendor/gpui-base/src/pagination.rs | 281 + vendor/gpui-base/src/popover.rs | 402 ++ vendor/gpui-base/src/popup.rs | 200 + vendor/gpui-base/src/positioner.rs | 622 ++ vendor/gpui-base/src/progress.rs | 157 + vendor/gpui-base/src/radio.rs | 413 ++ vendor/gpui-base/src/radio_group.rs | 67 + vendor/gpui-base/src/resizable/mod.rs | 530 ++ .../src/resizable/panel.rs | 190 +- .../src/resizable/resize_handle.rs | 91 +- vendor/gpui-base/src/scrollbar.rs | 2347 ++++++++ vendor/gpui-base/src/select.rs | 395 ++ vendor/gpui-base/src/sheet.rs | 268 + vendor/gpui-base/src/slider.rs | 835 +++ vendor/gpui-base/src/state_style.rs | 103 + vendor/gpui-base/src/styled.rs | 181 + vendor/gpui-base/src/switch.rs | 728 +++ vendor/gpui-base/src/table.rs | 372 ++ vendor/gpui-base/src/tabs.rs | 369 ++ vendor/gpui-base/src/text_boundary.rs | 82 + vendor/gpui-base/src/text_selection.rs | 3427 +++++++++++ vendor/gpui-base/src/theme.rs | 112 + vendor/gpui-base/src/theme_tokens.rs | 165 + vendor/gpui-base/src/toast.rs | 976 ++++ vendor/gpui-base/src/toggle.rs | 402 ++ vendor/gpui-base/src/toggle_group.rs | 67 + vendor/gpui-base/src/tooltip.rs | 308 + vendor/gpui-base/src/tree.rs | 643 +++ vendor/gpui-base/src/virtual_list.rs | 845 +++ vendor/gpui-base/tests/element_ext.rs | 30 + vendor/gpui-component-assets/Cargo.toml | 40 + vendor/gpui-component-assets/README.md | 7 + vendor/gpui-component-assets/assets/.gitkeep | 0 .../assets/icons/a-large-small.svg | 1 + .../assets/icons/arrow-down.svg | 1 + .../assets/icons/arrow-left.svg | 4 + .../assets/icons/arrow-right.svg | 4 + .../assets/icons/arrow-up.svg | 1 + .../assets/icons/asterisk.svg | 1 + .../assets/icons/battery-charging.svg | 16 + .../assets/icons/battery-full.svg | 14 + .../assets/icons/battery-low.svg | 18 + .../assets/icons/battery-medium.svg | 18 + .../assets/icons/battery-warning.svg | 16 + .../assets/icons/battery.svg | 18 + .../assets/icons/bell.svg | 1 + .../assets/icons/book-open.svg | 14 + .../assets/icons/bot.svg | 14 + .../assets/icons/building-2.svg | 16 + .../assets/icons/calendar.svg | 1 + .../assets/icons/case-sensitive.svg | 1 + .../assets/icons/chart-pie.svg | 14 + .../assets/icons/check.svg | 3 + .../assets/icons/chevron-down.svg | 3 + .../assets/icons/chevron-left.svg | 3 + .../assets/icons/chevron-right.svg | 3 + .../assets/icons/chevron-up.svg | 1 + .../assets/icons/chevrons-up-down.svg | 4 + .../assets/icons/circle-check.svg | 1 + .../assets/icons/circle-user.svg | 14 + .../assets/icons/circle-x.svg | 1 + .../assets/icons/close.svg | 1 + .../assets/icons/copy.svg | 1 + .../assets/icons/cpu.svg | 24 + .../assets/icons/dash.svg | 3 + .../assets/icons/delete.svg | 1 + .../assets/icons/ellipsis-vertical.svg | 1 + .../assets/icons/ellipsis.svg | 1 + .../assets/icons/external-link.svg | 14 + .../assets/icons/eye-off.svg | 6 + .../assets/icons/eye.svg | 4 + .../assets/icons/file.svg | 14 + .../assets/icons/folder-closed.svg | 14 + .../assets/icons/folder-open.svg | 14 + .../assets/icons/folder.svg | 14 + .../assets/icons/frame.svg | 22 + .../assets/icons/gallery-vertical-end.svg | 18 + .../assets/icons/github.svg | 1 + .../assets/icons/globe.svg | 1 + .../assets/icons/hard-drive.svg | 19 + .../assets/icons/heart-off.svg | 1 + .../assets/icons/heart.svg | 1 + .../assets/icons/inbox.svg | 1 + .../assets/icons/info.svg | 1 + .../assets/icons/inspector.svg | 18 + .../assets/icons/layout-dashboard.svg | 24 + .../assets/icons/loader-circle.svg | 3 + .../assets/icons/loader.svg | 10 + .../assets/icons/map.svg | 14 + .../assets/icons/maximize.svg | 1 + .../assets/icons/memory-stick.svg | 22 + .../assets/icons/menu.svg | 1 + .../assets/icons/minimize.svg | 6 + .../assets/icons/minus.svg | 3 + .../assets/icons/moon.svg | 1 + .../assets/icons/network.svg | 20 + .../assets/icons/palette.svg | 1 + .../assets/icons/panel-bottom-open.svg | 1 + .../assets/icons/panel-bottom.svg | 1 + .../assets/icons/panel-left-close.svg | 14 + .../assets/icons/panel-left-open.svg | 1 + .../assets/icons/panel-left.svg | 1 + .../assets/icons/panel-right-close.svg | 14 + .../assets/icons/panel-right-open.svg | 1 + .../assets/icons/panel-right.svg | 1 + .../assets/icons/pause.svg | 1 + .../assets/icons/play.svg | 1 + .../assets/icons/plus.svg | 1 + .../assets/icons/redo-2.svg | 14 + .../assets/icons/redo.svg | 14 + .../assets/icons/replace.svg | 16 + .../assets/icons/resize-corner.svg | 9 + .../assets/icons/search.svg | 4 + .../assets/icons/settings-2.svg | 16 + .../assets/icons/settings.svg | 14 + .../assets/icons/sort-ascending.svg | 15 + .../assets/icons/sort-descending.svg | 15 + .../assets/icons/square-terminal.svg | 19 + .../assets/icons/star-fill.svg | 1 + .../assets/icons/star-off.svg | 1 + .../assets/icons/star.svg | 1 + .../assets/icons/sun.svg | 1 + .../assets/icons/thumbs-down.svg | 1 + .../assets/icons/thumbs-up.svg | 1 + .../assets/icons/triangle-alert.svg | 1 + .../assets/icons/undo-2.svg | 14 + .../assets/icons/undo.svg | 14 + .../assets/icons/user.svg | 16 + .../assets/icons/window-close.svg | 9 + .../assets/icons/window-maximize.svg | 9 + .../assets/icons/window-minimize.svg | 9 + .../assets/icons/window-restore.svg | 10 + vendor/gpui-component-assets/build.rs | 38 + vendor/gpui-component-assets/src/lib.rs | 31 + .../src/native_assets.rs | 34 + .../gpui-component-assets/src/wasm_assets.rs | 108 + vendor/gpui-component-macros/Cargo.toml | 21 + vendor/gpui-component-macros/LICENSE-APACHE | 1 + .../src/derive_into_plot.rs | 201 + vendor/gpui-component-macros/src/lib.rs | 239 + vendor/gpui-component/Cargo.toml | 536 +- vendor/gpui-component/build.rs | 33 + vendor/gpui-component/locales/ui.yml | 130 + vendor/gpui-component/src/accordion.rs | 463 +- vendor/gpui-component/src/actions.rs | 11 - vendor/gpui-component/src/alert.rs | 52 +- vendor/gpui-component/src/animation.rs | 19 - vendor/gpui-component/src/async_util.rs | 1 + vendor/gpui-component/src/avatar/avatar.rs | 78 +- .../gpui-component/src/avatar/avatar_group.rs | 30 +- vendor/gpui-component/src/avatar/mod.rs | 8 +- vendor/gpui-component/src/badge.rs | 8 +- vendor/gpui-component/src/breadcrumb.rs | 13 +- vendor/gpui-component/src/button/button.rs | 1329 ++++- .../gpui-component/src/button/button_group.rs | 206 +- .../gpui-component/src/button/button_icon.rs | 168 + .../src/button/dropdown_button.rs | 242 +- vendor/gpui-component/src/button/mod.rs | 2 + vendor/gpui-component/src/button/toggle.rs | 427 +- vendor/gpui-component/src/chart/area_chart.rs | 226 +- vendor/gpui-component/src/chart/bar_chart.rs | 810 ++- .../src/chart/candlestick_chart.rs | 77 +- vendor/gpui-component/src/chart/line_chart.rs | 208 +- vendor/gpui-component/src/chart/mod.rs | 78 + vendor/gpui-component/src/chart/pie_chart.rs | 187 +- .../gpui-component/src/chart/radar_chart.rs | 700 +++ .../gpui-component/src/chart/sankey_chart.rs | 578 ++ vendor/gpui-component/src/checkbox.rs | 405 +- vendor/gpui-component/src/clipboard.rs | 15 +- vendor/gpui-component/src/collapsible.rs | 80 +- vendor/gpui-component/src/color_picker.rs | 717 +-- vendor/gpui-component/src/combobox.rs | 1595 ++++++ vendor/gpui-component/src/command/command.rs | 259 + vendor/gpui-component/src/command/item.rs | 305 + vendor/gpui-component/src/command/mod.rs | 15 + vendor/gpui-component/src/command/state.rs | 2450 ++++++++ vendor/gpui-component/src/component_traits.rs | 2 + vendor/gpui-component/src/description_list.rs | 23 +- vendor/gpui-component/src/dialog.rs | 551 -- .../gpui-component/src/dialog/alert_dialog.rs | 366 ++ vendor/gpui-component/src/dialog/content.rs | 44 + .../gpui-component/src/dialog/description.rs | 54 + vendor/gpui-component/src/dialog/dialog.rs | 691 +++ vendor/gpui-component/src/dialog/footer.rs | 119 + vendor/gpui-component/src/dialog/header.rs | 50 + vendor/gpui-component/src/dialog/mod.rs | 15 + vendor/gpui-component/src/dialog/title.rs | 48 + vendor/gpui-component/src/dock/dock.rs | 547 +- .../gpui-component/src/dock/invalid_panel.rs | 42 +- vendor/gpui-component/src/dock/mod.rs | 1323 +---- vendor/gpui-component/src/dock/panel.rs | 686 ++- vendor/gpui-component/src/dock/stack_panel.rs | 434 -- vendor/gpui-component/src/dock/state.rs | 282 - vendor/gpui-component/src/dock/tab_panel.rs | 2262 ++++---- .../gpui-component/src/dock/test_support.rs | 114 + vendor/gpui-component/src/dock/tiles.rs | 1551 ++--- vendor/gpui-component/src/element_ext.rs | 34 + vendor/gpui-component/src/event.rs | 21 - vendor/gpui-component/src/form/field.rs | 29 +- vendor/gpui-component/src/form/form.rs | 7 +- vendor/gpui-component/src/geometry.rs | 214 - vendor/gpui-component/src/global_state.rs | 30 +- vendor/gpui-component/src/group_box.rs | 10 +- .../src/highlighter/diagnostic_styles.rs | 34 + .../src/highlighter/highlighter.rs | 1518 ++++- .../src/highlighter/input_adapter.rs | 230 + .../src/highlighter/languages.rs | 468 +- .../languages/kotlin/highlights.scm | 345 ++ .../highlighter/languages/lua/highlights.scm | 152 + .../languages/markdown/injections.scm | 4 +- .../languages/markdown_inline/highlights.scm | 10 +- .../highlighter/languages/php/injections.scm | 20 + .../highlighter/languages/rust/highlights.scm | 2 +- vendor/gpui-component/src/highlighter/mod.rs | 33 +- .../src/highlighter/registry.rs | 81 +- .../src/highlighter/wasm_stub.rs | 478 ++ vendor/gpui-component/src/history.rs | 306 +- vendor/gpui-component/src/hover_card.rs | 151 + vendor/gpui-component/src/icon.rs | 202 +- vendor/gpui-component/src/index_path.rs | 98 +- .../gpui-component/src/input/clear_button.rs | 11 +- .../gpui-component/src/input/content_type.rs | 252 + vendor/gpui-component/src/input/editor.rs | 130 + vendor/gpui-component/src/input/element.rs | 1696 ------ vendor/gpui-component/src/input/input.rs | 1038 +++- .../src/input/lsp/document_colors.rs | 91 - vendor/gpui-component/src/input/lsp/hover.rs | 79 - vendor/gpui-component/src/input/lsp/mod.rs | 152 - vendor/gpui-component/src/input/mod.rs | 56 +- vendor/gpui-component/src/input/mode.rs | 348 -- .../gpui-component/src/input/number_input.rs | 322 +- vendor/gpui-component/src/input/otp_input.rs | 274 +- vendor/gpui-component/src/input/overlay.rs | 586 ++ .../src/input/popovers/code_action_menu.rs | 67 +- .../src/input/popovers/completion_menu.rs | 112 +- .../src/input/popovers/context_menu.rs | 148 - .../src/input/popovers/diagnostic_popover.rs | 57 +- .../src/input/popovers/hover_popover.rs | 77 +- .../gpui-component/src/input/popovers/mod.rs | 36 +- vendor/gpui-component/src/input/search.rs | 609 +- vendor/gpui-component/src/input/selection.rs | 170 - vendor/gpui-component/src/input/state.rs | 2322 +------- .../gpui-component/src/input/text_wrapper.rs | 814 --- vendor/gpui-component/src/input/textarea.rs | 129 + vendor/gpui-component/src/inspector.rs | 57 +- vendor/gpui-component/src/kbd.rs | 30 +- vendor/gpui-component/src/label.rs | 4 +- vendor/gpui-component/src/lib.rs | 121 +- vendor/gpui-component/src/link.rs | 91 +- vendor/gpui-component/src/list/cache.rs | 187 +- vendor/gpui-component/src/list/delegate.rs | 20 +- vendor/gpui-component/src/list/list.rs | 121 +- vendor/gpui-component/src/list/list_item.rs | 97 +- vendor/gpui-component/src/list/mod.rs | 1 + .../gpui-component/src/list/separator_item.rs | 6 +- .../gpui-component/src/menu/app_menu_bar.rs | 204 +- .../gpui-component/src/menu/context_menu.rs | 173 +- .../gpui-component/src/menu/dropdown_menu.rs | 16 +- vendor/gpui-component/src/menu/menu_item.rs | 35 +- vendor/gpui-component/src/menu/popup_menu.rs | 361 +- .../src/native_menu/fallback.rs | 146 + .../gpui-component/src/native_menu/macos.rs | 210 + vendor/gpui-component/src/native_menu/mod.rs | 412 ++ .../gpui-component/src/native_menu/windows.rs | 484 ++ vendor/gpui-component/src/notification.rs | 1396 ++++- vendor/gpui-component/src/pagination.rs | 242 + vendor/gpui-component/src/plot/axis.rs | 101 +- vendor/gpui-component/src/plot/grid.rs | 2 +- vendor/gpui-component/src/plot/label.rs | 116 +- vendor/gpui-component/src/plot/mod.rs | 76 +- .../gpui-component/src/plot/scale/linear.rs | 2 +- vendor/gpui-component/src/plot/scale/point.rs | 128 +- vendor/gpui-component/src/plot/shape.rs | 9 +- vendor/gpui-component/src/plot/shape/arc.rs | 4 +- vendor/gpui-component/src/plot/shape/area.rs | 34 +- vendor/gpui-component/src/plot/shape/bar.rs | 253 +- vendor/gpui-component/src/plot/shape/line.rs | 20 +- .../src/plot/shape/radial_line.rs | 292 + .../gpui-component/src/plot/shape/sankey.rs | 1323 +++++ vendor/gpui-component/src/plot/tooltip.rs | 309 +- vendor/gpui-component/src/popover.rs | 529 +- vendor/gpui-component/src/progress.rs | 80 - vendor/gpui-component/src/progress/mod.rs | 38 + .../gpui-component/src/progress/progress.rs | 178 + .../src/progress/progress_circle.rs | 223 + vendor/gpui-component/src/radio.rs | 258 +- vendor/gpui-component/src/rating.rs | 203 + vendor/gpui-component/src/resizable/mod.rs | 352 -- vendor/gpui-component/src/root.rs | 709 ++- vendor/gpui-component/src/scroll/mod.rs | 32 +- .../gpui-component/src/scroll/scrollable.rs | 655 ++- .../src/scroll/scrollable_mask.rs | 666 ++- vendor/gpui-component/src/scroll/scrollbar.rs | 981 ---- vendor/gpui-component/src/searchable_list.rs | 13 + .../src/searchable_list/adapter.rs | 180 + .../src/searchable_list/change.rs | 13 + .../src/searchable_list/delegate.rs | 200 + .../src/searchable_list/item.rs | 138 + .../src/searchable_list/state.rs | 222 + .../gpui-component/src/searchable_list/vec.rs | 239 + vendor/gpui-component/src/select.rs | 1386 ++--- .../src/{divider.rs => separator.rs} | 42 +- .../gpui-component/src/setting/fields/bool.rs | 12 +- .../src/setting/fields/dropdown.rs | 28 +- .../src/setting/fields/element.rs | 2 +- .../gpui-component/src/setting/fields/mod.rs | 98 +- .../src/setting/fields/number.rs | 146 +- .../src/setting/fields/string.rs | 40 +- vendor/gpui-component/src/setting/group.rs | 22 +- vendor/gpui-component/src/setting/item.rs | 220 +- vendor/gpui-component/src/setting/page.rs | 126 +- vendor/gpui-component/src/setting/settings.rs | 217 +- vendor/gpui-component/src/sheet.rs | 280 +- vendor/gpui-component/src/sidebar/footer.rs | 10 +- vendor/gpui-component/src/sidebar/group.rs | 41 +- vendor/gpui-component/src/sidebar/header.rs | 10 +- vendor/gpui-component/src/sidebar/menu.rs | 215 +- vendor/gpui-component/src/sidebar/mod.rs | 625 ++- vendor/gpui-component/src/sizing.rs | 394 ++ vendor/gpui-component/src/skeleton.rs | 8 +- vendor/gpui-component/src/slider.rs | 754 +-- vendor/gpui-component/src/spinner.rs | 17 +- vendor/gpui-component/src/status_bar.rs | 106 + vendor/gpui-component/src/stepper/item.rs | 264 + vendor/gpui-component/src/stepper/mod.rs | 6 + vendor/gpui-component/src/stepper/stepper.rs | 135 + vendor/gpui-component/src/stepper/trigger.rs | 151 + vendor/gpui-component/src/styled.rs | 763 +-- vendor/gpui-component/src/switch.rs | 258 +- vendor/gpui-component/src/tab/tab.rs | 739 ++- vendor/gpui-component/src/tab/tab_bar.rs | 528 +- vendor/gpui-component/src/table/column.rs | 85 +- vendor/gpui-component/src/table/data_table.rs | 173 + vendor/gpui-component/src/table/delegate.rs | 55 +- vendor/gpui-component/src/table/loading.rs | 6 +- vendor/gpui-component/src/table/mod.rs | 128 +- vendor/gpui-component/src/table/state.rs | 1320 ++++- vendor/gpui-component/src/table/table.rs | 636 +++ vendor/gpui-component/src/tag.rs | 8 +- vendor/gpui-component/src/text/document.rs | 238 + vendor/gpui-component/src/text/format/html.rs | 356 +- .../src/text/format/markdown.rs | 551 +- vendor/gpui-component/src/text/inline.rs | 456 +- vendor/gpui-component/src/text/inline_flow.rs | 774 +++ .../gpui-component/src/text/markdown_ext.rs | 305 + vendor/gpui-component/src/text/mod.rs | 94 +- vendor/gpui-component/src/text/node.rs | 3001 ++++++++-- vendor/gpui-component/src/text/selection.rs | 172 + .../src/text/selection_adapter.rs | 236 + vendor/gpui-component/src/text/state.rs | 1087 ++++ vendor/gpui-component/src/text/style.rs | 103 +- vendor/gpui-component/src/text/text_view.rs | 1705 +++--- vendor/gpui-component/src/text/utils.rs | 50 +- .../src/text/window_selection.rs | 2079 +++++++ vendor/gpui-component/src/theme/color.rs | 573 +- .../src/theme/default-colors.json | 246 +- .../src/theme/default-theme.json | 232 +- vendor/gpui-component/src/theme/mod.rs | 482 +- vendor/gpui-component/src/theme/registry.rs | 31 +- vendor/gpui-component/src/theme/schema.rs | 845 ++- .../gpui-component/src/theme/theme_color.rs | 301 +- vendor/gpui-component/src/time/calendar.rs | 1102 +--- vendor/gpui-component/src/time/date_picker.rs | 230 +- vendor/gpui-component/src/time/mod.rs | 1 - vendor/gpui-component/src/time/utils.rs | 33 +- vendor/gpui-component/src/title_bar.rs | 213 +- vendor/gpui-component/src/tooltip.rs | 185 +- vendor/gpui-component/src/tree.rs | 542 +- vendor/gpui-component/src/virtual_list.rs | 758 +-- vendor/gpui-component/src/webview.rs | 230 - vendor/gpui-component/src/window_border.rs | 434 +- vendor/gpui-component/src/window_ext.rs | 245 + vendor/gpui-component/tests/base_compat.rs | 442 ++ .../tests/legacy_button_compat.rs | 172 + .../tests/legacy_controls_compat.rs | 95 + vendor/gpui-component/tests/theme_compat.rs | 19 + vendor/gpui-component/themes/adventure.json | 1 + vendor/gpui-component/themes/asciinema.json | 6 +- vendor/gpui-component/themes/ashen.json | 311 - vendor/gpui-component/themes/aurora.json | 247 + vendor/gpui-component/themes/ayu.json | 4 +- vendor/gpui-component/themes/everforest.json | 1 + vendor/gpui-component/themes/hybrid.json | 1 + .../gpui-component/themes/macos-classic.json | 5 +- vendor/gpui-component/themes/matrix.json | 158 - vendor/gpui-component/themes/mellifluous.json | 1 + 550 files changed, 113645 insertions(+), 27206 deletions(-) create mode 100644 vendor/gpui-base/Cargo.toml create mode 100644 vendor/gpui-base/LICENSE-APACHE create mode 100644 vendor/gpui-base/README.md create mode 100644 vendor/gpui-base/examples/README.md create mode 100644 vendor/gpui-base/examples/components.rs create mode 100644 vendor/gpui-base/examples/showcase/components/accordion.rs create mode 100644 vendor/gpui-base/examples/showcase/components/alert_dialog.rs create mode 100644 vendor/gpui-base/examples/showcase/components/avatar.rs create mode 100644 vendor/gpui-base/examples/showcase/components/button.rs create mode 100644 vendor/gpui-base/examples/showcase/components/calendar.rs create mode 100644 vendor/gpui-base/examples/showcase/components/checkbox.rs create mode 100644 vendor/gpui-base/examples/showcase/components/collapsible.rs create mode 100644 vendor/gpui-base/examples/showcase/components/color_picker.rs create mode 100644 vendor/gpui-base/examples/showcase/components/combobox.rs create mode 100644 vendor/gpui-base/examples/showcase/components/date_picker.rs create mode 100644 vendor/gpui-base/examples/showcase/components/dialog.rs create mode 100644 vendor/gpui-base/examples/showcase/components/dock.rs create mode 100644 vendor/gpui-base/examples/showcase/components/editor.rs create mode 100644 vendor/gpui-base/examples/showcase/components/hover_card.rs create mode 100644 vendor/gpui-base/examples/showcase/components/input.rs create mode 100644 vendor/gpui-base/examples/showcase/components/link.rs create mode 100644 vendor/gpui-base/examples/showcase/components/mod.rs create mode 100644 vendor/gpui-base/examples/showcase/components/number_input.rs create mode 100644 vendor/gpui-base/examples/showcase/components/otp_input.rs create mode 100644 vendor/gpui-base/examples/showcase/components/pagination.rs create mode 100644 vendor/gpui-base/examples/showcase/components/popover.rs create mode 100644 vendor/gpui-base/examples/showcase/components/popup.rs create mode 100644 vendor/gpui-base/examples/showcase/components/progress.rs create mode 100644 vendor/gpui-base/examples/showcase/components/radio.rs create mode 100644 vendor/gpui-base/examples/showcase/components/radio_group.rs create mode 100644 vendor/gpui-base/examples/showcase/components/resizable.rs create mode 100644 vendor/gpui-base/examples/showcase/components/scrollbar.rs create mode 100644 vendor/gpui-base/examples/showcase/components/select.rs create mode 100644 vendor/gpui-base/examples/showcase/components/sheet.rs create mode 100644 vendor/gpui-base/examples/showcase/components/slider.rs create mode 100644 vendor/gpui-base/examples/showcase/components/switch.rs create mode 100644 vendor/gpui-base/examples/showcase/components/table.rs create mode 100644 vendor/gpui-base/examples/showcase/components/tabs.rs create mode 100644 vendor/gpui-base/examples/showcase/components/text_selection.rs create mode 100644 vendor/gpui-base/examples/showcase/components/textarea.rs create mode 100644 vendor/gpui-base/examples/showcase/components/toast.rs create mode 100644 vendor/gpui-base/examples/showcase/components/toggle.rs create mode 100644 vendor/gpui-base/examples/showcase/components/toggle_group.rs create mode 100644 vendor/gpui-base/examples/showcase/components/tooltip.rs create mode 100644 vendor/gpui-base/examples/showcase/components/tree.rs create mode 100644 vendor/gpui-base/examples/showcase/components/virtual_list.rs create mode 100644 vendor/gpui-base/examples/showcase/mod.rs create mode 100644 vendor/gpui-base/examples/showcase/syntect_highlighter.rs create mode 100644 vendor/gpui-base/examples/wasm/Cargo.toml create mode 100644 vendor/gpui-base/examples/wasm/Makefile create mode 100755 vendor/gpui-base/examples/wasm/scripts/build.sh create mode 100644 vendor/gpui-base/examples/wasm/src/lib.rs create mode 100644 vendor/gpui-base/examples/wasm/www/.gitignore create mode 100644 vendor/gpui-base/examples/wasm/www/bun.lock create mode 100644 vendor/gpui-base/examples/wasm/www/index.html create mode 100644 vendor/gpui-base/examples/wasm/www/package.json create mode 100644 vendor/gpui-base/examples/wasm/www/src/main.js create mode 100644 vendor/gpui-base/examples/wasm/www/vite.config.js create mode 100644 vendor/gpui-base/src/accordion.rs create mode 100644 vendor/gpui-base/src/actions.rs create mode 100644 vendor/gpui-base/src/alert_dialog.rs create mode 100644 vendor/gpui-base/src/animation.rs create mode 100644 vendor/gpui-base/src/async_util.rs create mode 100644 vendor/gpui-base/src/auto_scroll.rs create mode 100644 vendor/gpui-base/src/avatar.rs create mode 100644 vendor/gpui-base/src/bin/components.rs create mode 100644 vendor/gpui-base/src/button.rs create mode 100644 vendor/gpui-base/src/calendar.rs create mode 100644 vendor/gpui-base/src/checkbox.rs create mode 100644 vendor/gpui-base/src/collapsible.rs create mode 100644 vendor/gpui-base/src/color_picker.rs create mode 100644 vendor/gpui-base/src/combobox.rs create mode 100644 vendor/gpui-base/src/component_traits.rs create mode 100644 vendor/gpui-base/src/date_picker.rs create mode 100644 vendor/gpui-base/src/dialog.rs create mode 100644 vendor/gpui-base/src/dock/active.rs create mode 100644 vendor/gpui-base/src/dock/dock_area.rs create mode 100644 vendor/gpui-base/src/dock/dock_placement.rs create mode 100644 vendor/gpui-base/src/dock/drag.rs create mode 100644 vendor/gpui-base/src/dock/fixtures/bare_tab_panel_root.json rename vendor/{gpui-component/tests => gpui-base/src/dock}/fixtures/layout.json (99%) create mode 100644 vendor/gpui-base/src/dock/fixtures/legacy_empty_tab_group.json create mode 100644 vendor/gpui-base/src/dock/fixtures/nested_splits.json create mode 100644 vendor/gpui-base/src/dock/fixtures/tiles.json create mode 100644 vendor/gpui-base/src/dock/fixtures/tiles_tab_panel_children.json create mode 100644 vendor/gpui-base/src/dock/fixtures/unregistered_panel.json create mode 100644 vendor/gpui-base/src/dock/fixtures/zero_size_sentinel.json create mode 100644 vendor/gpui-base/src/dock/layout/builder.rs create mode 100644 vendor/gpui-base/src/dock/layout/edit.rs create mode 100644 vendor/gpui-base/src/dock/layout/mod.rs create mode 100644 vendor/gpui-base/src/dock/layout/node.rs create mode 100644 vendor/gpui-base/src/dock/layout/normalize.rs create mode 100644 vendor/gpui-base/src/dock/layout/tree.rs create mode 100644 vendor/gpui-base/src/dock/mod.rs create mode 100644 vendor/gpui-base/src/dock/panel.rs create mode 100644 vendor/gpui-base/src/dock/registry.rs create mode 100644 vendor/gpui-base/src/dock/state.rs create mode 100644 vendor/gpui-base/src/dock/state_convert.rs create mode 100644 vendor/gpui-base/src/dock/tab_group.rs create mode 100644 vendor/gpui-base/src/dock/test_support.rs create mode 100644 vendor/gpui-base/src/dock/tiles_geometry.rs create mode 100644 vendor/gpui-base/src/dock/tiles_state.rs create mode 100644 vendor/gpui-base/src/element_ext.rs create mode 100644 vendor/gpui-base/src/event.rs create mode 100644 vendor/gpui-base/src/focus_trap.rs create mode 100644 vendor/gpui-base/src/geometry.rs create mode 100644 vendor/gpui-base/src/global_state.rs create mode 100644 vendor/gpui-base/src/history.rs create mode 100644 vendor/gpui-base/src/hover_card.rs create mode 100644 vendor/gpui-base/src/index_path.rs create mode 100644 vendor/gpui-base/src/input/README.md rename vendor/{gpui-component/src/input => gpui-base/src/input/base}/blink_cursor.rs (76%) rename vendor/{gpui-component/src/input => gpui-base/src/input/base}/change.rs (71%) rename vendor/{gpui-component/src/input => gpui-base/src/input/base}/cursor.rs (100%) create mode 100644 vendor/gpui-base/src/input/base/element.rs create mode 100644 vendor/gpui-base/src/input/base/kind.rs create mode 100644 vendor/gpui-base/src/input/base/layout.rs rename vendor/{gpui-component/src/input => gpui-base/src/input/base}/mask_pattern.rs (86%) create mode 100644 vendor/gpui-base/src/input/base/mod.rs create mode 100644 vendor/gpui-base/src/input/base/mode.rs rename vendor/{gpui-component/src/input => gpui-base/src/input/base}/movement.rs (78%) create mode 100644 vendor/gpui-base/src/input/base/native.rs rename vendor/{gpui-component/src/input => gpui-base/src/input/base}/rope_ext.rs (93%) create mode 100644 vendor/gpui-base/src/input/base/selection.rs create mode 100644 vendor/gpui-base/src/input/base/state.rs create mode 100644 vendor/gpui-base/src/input/base/undo_manager.rs create mode 100644 vendor/gpui-base/src/input/editor/decorations.rs rename vendor/{gpui-component/src/highlighter => gpui-base/src/input/editor}/diagnostics.rs (76%) create mode 100644 vendor/gpui-base/src/input/editor/display_map/README.md create mode 100644 vendor/gpui-base/src/input/editor/display_map/display_map.rs create mode 100644 vendor/gpui-base/src/input/editor/display_map/fold_map.rs create mode 100644 vendor/gpui-base/src/input/editor/display_map/folding.rs create mode 100644 vendor/gpui-base/src/input/editor/display_map/mod.rs create mode 100644 vendor/gpui-base/src/input/editor/display_map/text_wrapper.rs create mode 100644 vendor/gpui-base/src/input/editor/display_map/wrap_map.rs create mode 100644 vendor/gpui-base/src/input/editor/highlighting.rs rename vendor/{gpui-component/src/input => gpui-base/src/input/editor}/indent.rs (84%) rename vendor/{gpui-component/src/input => gpui-base/src/input/editor}/lsp/code_actions.rs (72%) rename vendor/{gpui-component/src/input => gpui-base/src/input/editor}/lsp/completions.rs (64%) rename vendor/{gpui-component/src/input => gpui-base/src/input/editor}/lsp/definitions.rs (64%) create mode 100644 vendor/gpui-base/src/input/editor/lsp/document_colors.rs create mode 100644 vendor/gpui-base/src/input/editor/lsp/hover.rs create mode 100644 vendor/gpui-base/src/input/editor/lsp/mod.rs create mode 100644 vendor/gpui-base/src/input/editor/lsp/overlay.rs create mode 100644 vendor/gpui-base/src/input/editor/lsp/semantic_tokens.rs create mode 100644 vendor/gpui-base/src/input/editor/mod.rs create mode 100644 vendor/gpui-base/src/input/editor/search.rs create mode 100644 vendor/gpui-base/src/input/input/mod.rs create mode 100644 vendor/gpui-base/src/input/mod.rs create mode 100644 vendor/gpui-base/src/input/textarea/mod.rs create mode 100644 vendor/gpui-base/src/lib.rs create mode 100644 vendor/gpui-base/src/link.rs create mode 100644 vendor/gpui-base/src/list_settings.rs create mode 100644 vendor/gpui-base/src/macos_accessibility.rs create mode 100644 vendor/gpui-base/src/measure.rs create mode 100644 vendor/gpui-base/src/motion.rs create mode 100644 vendor/gpui-base/src/number_input.rs create mode 100644 vendor/gpui-base/src/otp_input.rs create mode 100644 vendor/gpui-base/src/pagination.rs create mode 100644 vendor/gpui-base/src/popover.rs create mode 100644 vendor/gpui-base/src/popup.rs create mode 100644 vendor/gpui-base/src/positioner.rs create mode 100644 vendor/gpui-base/src/progress.rs create mode 100644 vendor/gpui-base/src/radio.rs create mode 100644 vendor/gpui-base/src/radio_group.rs create mode 100644 vendor/gpui-base/src/resizable/mod.rs rename vendor/{gpui-component => gpui-base}/src/resizable/panel.rs (66%) rename vendor/{gpui-component => gpui-base}/src/resizable/resize_handle.rs (66%) create mode 100644 vendor/gpui-base/src/scrollbar.rs create mode 100644 vendor/gpui-base/src/select.rs create mode 100644 vendor/gpui-base/src/sheet.rs create mode 100644 vendor/gpui-base/src/slider.rs create mode 100644 vendor/gpui-base/src/state_style.rs create mode 100644 vendor/gpui-base/src/styled.rs create mode 100644 vendor/gpui-base/src/switch.rs create mode 100644 vendor/gpui-base/src/table.rs create mode 100644 vendor/gpui-base/src/tabs.rs create mode 100644 vendor/gpui-base/src/text_boundary.rs create mode 100644 vendor/gpui-base/src/text_selection.rs create mode 100644 vendor/gpui-base/src/theme.rs create mode 100644 vendor/gpui-base/src/theme_tokens.rs create mode 100644 vendor/gpui-base/src/toast.rs create mode 100644 vendor/gpui-base/src/toggle.rs create mode 100644 vendor/gpui-base/src/toggle_group.rs create mode 100644 vendor/gpui-base/src/tooltip.rs create mode 100644 vendor/gpui-base/src/tree.rs create mode 100644 vendor/gpui-base/src/virtual_list.rs create mode 100644 vendor/gpui-base/tests/element_ext.rs create mode 100644 vendor/gpui-component-assets/Cargo.toml create mode 100644 vendor/gpui-component-assets/README.md create mode 100644 vendor/gpui-component-assets/assets/.gitkeep create mode 100644 vendor/gpui-component-assets/assets/icons/a-large-small.svg create mode 100644 vendor/gpui-component-assets/assets/icons/arrow-down.svg create mode 100644 vendor/gpui-component-assets/assets/icons/arrow-left.svg create mode 100644 vendor/gpui-component-assets/assets/icons/arrow-right.svg create mode 100644 vendor/gpui-component-assets/assets/icons/arrow-up.svg create mode 100644 vendor/gpui-component-assets/assets/icons/asterisk.svg create mode 100644 vendor/gpui-component-assets/assets/icons/battery-charging.svg create mode 100644 vendor/gpui-component-assets/assets/icons/battery-full.svg create mode 100644 vendor/gpui-component-assets/assets/icons/battery-low.svg create mode 100644 vendor/gpui-component-assets/assets/icons/battery-medium.svg create mode 100644 vendor/gpui-component-assets/assets/icons/battery-warning.svg create mode 100644 vendor/gpui-component-assets/assets/icons/battery.svg create mode 100644 vendor/gpui-component-assets/assets/icons/bell.svg create mode 100644 vendor/gpui-component-assets/assets/icons/book-open.svg create mode 100644 vendor/gpui-component-assets/assets/icons/bot.svg create mode 100644 vendor/gpui-component-assets/assets/icons/building-2.svg create mode 100644 vendor/gpui-component-assets/assets/icons/calendar.svg create mode 100644 vendor/gpui-component-assets/assets/icons/case-sensitive.svg create mode 100644 vendor/gpui-component-assets/assets/icons/chart-pie.svg create mode 100644 vendor/gpui-component-assets/assets/icons/check.svg create mode 100644 vendor/gpui-component-assets/assets/icons/chevron-down.svg create mode 100644 vendor/gpui-component-assets/assets/icons/chevron-left.svg create mode 100644 vendor/gpui-component-assets/assets/icons/chevron-right.svg create mode 100644 vendor/gpui-component-assets/assets/icons/chevron-up.svg create mode 100644 vendor/gpui-component-assets/assets/icons/chevrons-up-down.svg create mode 100644 vendor/gpui-component-assets/assets/icons/circle-check.svg create mode 100644 vendor/gpui-component-assets/assets/icons/circle-user.svg create mode 100644 vendor/gpui-component-assets/assets/icons/circle-x.svg create mode 100644 vendor/gpui-component-assets/assets/icons/close.svg create mode 100644 vendor/gpui-component-assets/assets/icons/copy.svg create mode 100644 vendor/gpui-component-assets/assets/icons/cpu.svg create mode 100644 vendor/gpui-component-assets/assets/icons/dash.svg create mode 100644 vendor/gpui-component-assets/assets/icons/delete.svg create mode 100644 vendor/gpui-component-assets/assets/icons/ellipsis-vertical.svg create mode 100644 vendor/gpui-component-assets/assets/icons/ellipsis.svg create mode 100644 vendor/gpui-component-assets/assets/icons/external-link.svg create mode 100644 vendor/gpui-component-assets/assets/icons/eye-off.svg create mode 100644 vendor/gpui-component-assets/assets/icons/eye.svg create mode 100644 vendor/gpui-component-assets/assets/icons/file.svg create mode 100644 vendor/gpui-component-assets/assets/icons/folder-closed.svg create mode 100644 vendor/gpui-component-assets/assets/icons/folder-open.svg create mode 100644 vendor/gpui-component-assets/assets/icons/folder.svg create mode 100644 vendor/gpui-component-assets/assets/icons/frame.svg create mode 100644 vendor/gpui-component-assets/assets/icons/gallery-vertical-end.svg create mode 100644 vendor/gpui-component-assets/assets/icons/github.svg create mode 100644 vendor/gpui-component-assets/assets/icons/globe.svg create mode 100644 vendor/gpui-component-assets/assets/icons/hard-drive.svg create mode 100644 vendor/gpui-component-assets/assets/icons/heart-off.svg create mode 100644 vendor/gpui-component-assets/assets/icons/heart.svg create mode 100644 vendor/gpui-component-assets/assets/icons/inbox.svg create mode 100644 vendor/gpui-component-assets/assets/icons/info.svg create mode 100644 vendor/gpui-component-assets/assets/icons/inspector.svg create mode 100644 vendor/gpui-component-assets/assets/icons/layout-dashboard.svg create mode 100644 vendor/gpui-component-assets/assets/icons/loader-circle.svg create mode 100644 vendor/gpui-component-assets/assets/icons/loader.svg create mode 100644 vendor/gpui-component-assets/assets/icons/map.svg create mode 100644 vendor/gpui-component-assets/assets/icons/maximize.svg create mode 100644 vendor/gpui-component-assets/assets/icons/memory-stick.svg create mode 100644 vendor/gpui-component-assets/assets/icons/menu.svg create mode 100644 vendor/gpui-component-assets/assets/icons/minimize.svg create mode 100644 vendor/gpui-component-assets/assets/icons/minus.svg create mode 100644 vendor/gpui-component-assets/assets/icons/moon.svg create mode 100644 vendor/gpui-component-assets/assets/icons/network.svg create mode 100644 vendor/gpui-component-assets/assets/icons/palette.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-bottom-open.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-bottom.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-left-close.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-left-open.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-left.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-right-close.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-right-open.svg create mode 100644 vendor/gpui-component-assets/assets/icons/panel-right.svg create mode 100644 vendor/gpui-component-assets/assets/icons/pause.svg create mode 100644 vendor/gpui-component-assets/assets/icons/play.svg create mode 100644 vendor/gpui-component-assets/assets/icons/plus.svg create mode 100644 vendor/gpui-component-assets/assets/icons/redo-2.svg create mode 100644 vendor/gpui-component-assets/assets/icons/redo.svg create mode 100644 vendor/gpui-component-assets/assets/icons/replace.svg create mode 100644 vendor/gpui-component-assets/assets/icons/resize-corner.svg create mode 100644 vendor/gpui-component-assets/assets/icons/search.svg create mode 100644 vendor/gpui-component-assets/assets/icons/settings-2.svg create mode 100644 vendor/gpui-component-assets/assets/icons/settings.svg create mode 100644 vendor/gpui-component-assets/assets/icons/sort-ascending.svg create mode 100644 vendor/gpui-component-assets/assets/icons/sort-descending.svg create mode 100644 vendor/gpui-component-assets/assets/icons/square-terminal.svg create mode 100644 vendor/gpui-component-assets/assets/icons/star-fill.svg create mode 100644 vendor/gpui-component-assets/assets/icons/star-off.svg create mode 100644 vendor/gpui-component-assets/assets/icons/star.svg create mode 100644 vendor/gpui-component-assets/assets/icons/sun.svg create mode 100644 vendor/gpui-component-assets/assets/icons/thumbs-down.svg create mode 100644 vendor/gpui-component-assets/assets/icons/thumbs-up.svg create mode 100644 vendor/gpui-component-assets/assets/icons/triangle-alert.svg create mode 100644 vendor/gpui-component-assets/assets/icons/undo-2.svg create mode 100644 vendor/gpui-component-assets/assets/icons/undo.svg create mode 100644 vendor/gpui-component-assets/assets/icons/user.svg create mode 100644 vendor/gpui-component-assets/assets/icons/window-close.svg create mode 100644 vendor/gpui-component-assets/assets/icons/window-maximize.svg create mode 100644 vendor/gpui-component-assets/assets/icons/window-minimize.svg create mode 100644 vendor/gpui-component-assets/assets/icons/window-restore.svg create mode 100644 vendor/gpui-component-assets/build.rs create mode 100644 vendor/gpui-component-assets/src/lib.rs create mode 100644 vendor/gpui-component-assets/src/native_assets.rs create mode 100644 vendor/gpui-component-assets/src/wasm_assets.rs create mode 100644 vendor/gpui-component-macros/Cargo.toml create mode 120000 vendor/gpui-component-macros/LICENSE-APACHE create mode 100644 vendor/gpui-component-macros/src/derive_into_plot.rs create mode 100644 vendor/gpui-component-macros/src/lib.rs create mode 100644 vendor/gpui-component/build.rs delete mode 100644 vendor/gpui-component/src/actions.rs delete mode 100644 vendor/gpui-component/src/animation.rs create mode 100644 vendor/gpui-component/src/async_util.rs create mode 100644 vendor/gpui-component/src/button/button_icon.rs create mode 100644 vendor/gpui-component/src/chart/radar_chart.rs create mode 100644 vendor/gpui-component/src/chart/sankey_chart.rs create mode 100644 vendor/gpui-component/src/combobox.rs create mode 100644 vendor/gpui-component/src/command/command.rs create mode 100644 vendor/gpui-component/src/command/item.rs create mode 100644 vendor/gpui-component/src/command/mod.rs create mode 100644 vendor/gpui-component/src/command/state.rs create mode 100644 vendor/gpui-component/src/component_traits.rs delete mode 100644 vendor/gpui-component/src/dialog.rs create mode 100644 vendor/gpui-component/src/dialog/alert_dialog.rs create mode 100644 vendor/gpui-component/src/dialog/content.rs create mode 100644 vendor/gpui-component/src/dialog/description.rs create mode 100644 vendor/gpui-component/src/dialog/dialog.rs create mode 100644 vendor/gpui-component/src/dialog/footer.rs create mode 100644 vendor/gpui-component/src/dialog/header.rs create mode 100644 vendor/gpui-component/src/dialog/mod.rs create mode 100644 vendor/gpui-component/src/dialog/title.rs delete mode 100644 vendor/gpui-component/src/dock/stack_panel.rs delete mode 100644 vendor/gpui-component/src/dock/state.rs create mode 100644 vendor/gpui-component/src/dock/test_support.rs create mode 100644 vendor/gpui-component/src/element_ext.rs delete mode 100644 vendor/gpui-component/src/event.rs delete mode 100644 vendor/gpui-component/src/geometry.rs create mode 100644 vendor/gpui-component/src/highlighter/diagnostic_styles.rs create mode 100644 vendor/gpui-component/src/highlighter/input_adapter.rs create mode 100644 vendor/gpui-component/src/highlighter/languages/kotlin/highlights.scm create mode 100644 vendor/gpui-component/src/highlighter/languages/lua/highlights.scm create mode 100644 vendor/gpui-component/src/highlighter/languages/php/injections.scm create mode 100644 vendor/gpui-component/src/highlighter/wasm_stub.rs create mode 100644 vendor/gpui-component/src/hover_card.rs create mode 100644 vendor/gpui-component/src/input/content_type.rs create mode 100644 vendor/gpui-component/src/input/editor.rs delete mode 100644 vendor/gpui-component/src/input/element.rs delete mode 100644 vendor/gpui-component/src/input/lsp/document_colors.rs delete mode 100644 vendor/gpui-component/src/input/lsp/hover.rs delete mode 100644 vendor/gpui-component/src/input/lsp/mod.rs delete mode 100644 vendor/gpui-component/src/input/mode.rs create mode 100644 vendor/gpui-component/src/input/overlay.rs delete mode 100644 vendor/gpui-component/src/input/popovers/context_menu.rs delete mode 100644 vendor/gpui-component/src/input/selection.rs delete mode 100644 vendor/gpui-component/src/input/text_wrapper.rs create mode 100644 vendor/gpui-component/src/input/textarea.rs create mode 100644 vendor/gpui-component/src/native_menu/fallback.rs create mode 100644 vendor/gpui-component/src/native_menu/macos.rs create mode 100644 vendor/gpui-component/src/native_menu/mod.rs create mode 100644 vendor/gpui-component/src/native_menu/windows.rs create mode 100644 vendor/gpui-component/src/pagination.rs create mode 100644 vendor/gpui-component/src/plot/shape/radial_line.rs create mode 100644 vendor/gpui-component/src/plot/shape/sankey.rs delete mode 100644 vendor/gpui-component/src/progress.rs create mode 100644 vendor/gpui-component/src/progress/mod.rs create mode 100644 vendor/gpui-component/src/progress/progress.rs create mode 100644 vendor/gpui-component/src/progress/progress_circle.rs create mode 100644 vendor/gpui-component/src/rating.rs delete mode 100644 vendor/gpui-component/src/resizable/mod.rs delete mode 100644 vendor/gpui-component/src/scroll/scrollbar.rs create mode 100644 vendor/gpui-component/src/searchable_list.rs create mode 100644 vendor/gpui-component/src/searchable_list/adapter.rs create mode 100644 vendor/gpui-component/src/searchable_list/change.rs create mode 100644 vendor/gpui-component/src/searchable_list/delegate.rs create mode 100644 vendor/gpui-component/src/searchable_list/item.rs create mode 100644 vendor/gpui-component/src/searchable_list/state.rs create mode 100644 vendor/gpui-component/src/searchable_list/vec.rs rename vendor/gpui-component/src/{divider.rs => separator.rs} (80%) create mode 100644 vendor/gpui-component/src/sizing.rs create mode 100644 vendor/gpui-component/src/status_bar.rs create mode 100644 vendor/gpui-component/src/stepper/item.rs create mode 100644 vendor/gpui-component/src/stepper/mod.rs create mode 100644 vendor/gpui-component/src/stepper/stepper.rs create mode 100644 vendor/gpui-component/src/stepper/trigger.rs create mode 100644 vendor/gpui-component/src/table/data_table.rs create mode 100644 vendor/gpui-component/src/table/table.rs create mode 100644 vendor/gpui-component/src/text/document.rs create mode 100644 vendor/gpui-component/src/text/inline_flow.rs create mode 100644 vendor/gpui-component/src/text/markdown_ext.rs create mode 100644 vendor/gpui-component/src/text/selection.rs create mode 100644 vendor/gpui-component/src/text/selection_adapter.rs create mode 100644 vendor/gpui-component/src/text/state.rs create mode 100644 vendor/gpui-component/src/text/window_selection.rs delete mode 100644 vendor/gpui-component/src/webview.rs create mode 100644 vendor/gpui-component/src/window_ext.rs create mode 100644 vendor/gpui-component/tests/base_compat.rs create mode 100644 vendor/gpui-component/tests/legacy_button_compat.rs create mode 100644 vendor/gpui-component/tests/legacy_controls_compat.rs create mode 100644 vendor/gpui-component/tests/theme_compat.rs delete mode 100644 vendor/gpui-component/themes/ashen.json create mode 100644 vendor/gpui-component/themes/aurora.json delete mode 100644 vendor/gpui-component/themes/matrix.json diff --git a/Cargo.lock b/Cargo.lock index 5627a842..03663567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,106 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +dependencies = [ + "accesskit", + "accesskit_consumer 0.37.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -131,6 +231,17 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width 0.2.2", +] + [[package]] name = "anstream" version = "1.0.0" @@ -240,6 +351,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -288,53 +405,21 @@ dependencies = [ "libloading", ] -[[package]] -name = "ash-window" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" -dependencies = [ - "ash", - "raw-window-handle", - "raw-window-metal", -] - [[package]] name = "ashpd" -version = "0.11.1" +version = "0.13.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" dependencies = [ - "async-fs", - "async-net", "enumflags2", "futures-channel", "futures-util", - "rand 0.9.4", + "getrandom 0.4.2", "serde", "serde_repr", - "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.12", - "zbus", -] - -[[package]] -name = "ashpd" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33a3c86f3fd70c0ffa500ed189abfa90b5a52398a45d5dc372fcc38ebeb7a645" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.4", - "serde", - "serde_repr", - "url", + "wayland-protocols", "zbus", ] @@ -344,23 +429,12 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.1", + "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -393,8 +467,8 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand 2.4.1", - "futures-lite 2.6.1", + "fastrand", + "futures-lite", "pin-project-lite", "slab", ] @@ -407,22 +481,7 @@ checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", - "futures-lite 2.6.1", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite 2.6.1", - "once_cell", + "futures-lite", ] [[package]] @@ -435,7 +494,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.6.1", + "futures-lite", "parking", "polling", "rustix 1.1.4", @@ -449,7 +508,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.1", + "event-listener", "event-listener-strategy", "pin-project-lite", ] @@ -462,7 +521,7 @@ checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", - "futures-lite 2.6.1", + "futures-lite", ] [[package]] @@ -471,15 +530,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", - "futures-lite 2.6.1", + "event-listener", + "futures-lite", "rustix 1.1.4", ] @@ -512,33 +571,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "async-process", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite 2.6.1", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - [[package]] name = "async-task" version = "4.7.1" @@ -567,19 +599,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "async_zip" -version = "0.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" -dependencies = [ - "async-compression", - "crc32fast", - "futures-lite 2.6.1", - "pin-project", - "thiserror 1.0.69", -] - [[package]] name = "atomic" version = "0.5.3" @@ -612,6 +631,43 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "attohttpc" version = "0.30.1" @@ -707,7 +763,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand 2.4.1", + "fastrand", "gloo-timers", "tokio", ] @@ -806,7 +862,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -815,6 +880,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bit_field" version = "0.10.3" @@ -875,64 +946,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "blade-graphics" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e71cfb73b98eb9f58ee84048aa1bdf4e7497fd20c141b57523499fa066b48fed" -dependencies = [ - "ash", - "ash-window", - "bitflags 2.13.0", - "bytemuck", - "codespan-reporting", - "glow", - "gpu-alloc", - "gpu-alloc-ash", - "hidden-trait", - "js-sys", - "khronos-egl", - "libloading", - "log", - "mint", - "naga", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", - "objc2-ui-kit", - "once_cell", - "raw-window-handle", - "slab", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "blade-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "blade-util" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c" -dependencies = [ - "blade-graphics", - "bytemuck", - "log", - "profiling", -] - [[package]] name = "blake3" version = "1.8.5" @@ -991,13 +1004,22 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -1006,10 +1028,10 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-task", "futures-io", - "futures-lite 2.6.1", + "futures-lite", "piper", ] @@ -1030,7 +1052,7 @@ dependencies = [ "libc", "log", "macaddr", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "pin-project", @@ -1067,6 +1089,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1169,26 +1215,25 @@ dependencies = [ [[package]] name = "calloop" -version = "0.13.0" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.0", - "log", "polling", - "rustix 0.38.44", + "rustix 1.1.4", "slab", - "thiserror 1.0.69", + "tracing", ] [[package]] name = "calloop-wayland-source" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop", - "rustix 0.38.44", + "rustix 1.1.4", "wayland-backend", "wayland-client", ] @@ -1521,13 +1566,13 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1536,6 +1581,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9589e1effc5cacbea347899645c654158b03b2053d24bb426fd3128ced6e423c" +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "gpui_util", + "indexmap", + "rustc-hash 2.1.2", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1582,24 +1637,14 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "command-fds" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" -dependencies = [ - "nix 0.31.3", - "thiserror 2.0.18", -] - [[package]] name = "compression-codecs" version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ + "bzip2", "compression-core", - "deflate64", "flate2", "memchr", ] @@ -1619,6 +1664,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -1653,15 +1708,18 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" -version = "0.4.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "convert_case" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" dependencies = [ "unicode-segmentation", ] @@ -1765,9 +1823,9 @@ dependencies = [ [[package]] name = "core-graphics2" -version = "0.4.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" dependencies = [ "bitflags 2.13.0", "block", @@ -1790,9 +1848,9 @@ dependencies = [ [[package]] name = "core-video" -version = "0.4.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45e71d5be22206bed53c3c3cb99315fc4c3d31b8963808c6bc4538168c4f8ef" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" dependencies = [ "block", "core-foundation 0.10.0", @@ -1824,7 +1882,7 @@ dependencies = [ "rustc-hash 1.1.0", "rustybuzz 0.14.1", "self_cell", - "smol_str", + "smol_str 0.2.2", "swash", "sys-locale", "ttf-parser 0.21.1", @@ -1834,6 +1892,30 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.0", + "fontdb 0.23.0", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.2", + "self_cell", + "skrifa 0.40.0", + "smol_str 0.3.6", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "countme" version = "3.0.1" @@ -1944,7 +2026,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1980,20 +2061,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.4.3" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "ctr" version = "0.9.2" @@ -2155,7 +2230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2271,19 +2346,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2307,6 +2369,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "diatomic-waker" version = "0.2.3" @@ -2343,15 +2415,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys 0.3.7", -] - [[package]] name = "dirs" version = "5.0.1" @@ -2380,17 +2443,6 @@ dependencies = [ "dirs-sys-next", ] -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", -] - [[package]] name = "dirs-sys" version = "0.4.1" @@ -2439,9 +2491,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -2475,6 +2527,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "docx-rs" version = "0.4.20" @@ -2529,21 +2590,6 @@ dependencies = [ "phf 0.11.3", ] -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - [[package]] name = "dunce" version = "1.0.5" @@ -2669,6 +2715,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endi" version = "1.1.1" @@ -2789,6 +2844,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.4.2" @@ -2855,12 +2921,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "event-listener" version = "5.4.1" @@ -2878,7 +2938,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener", "pin-project-lite", ] @@ -2915,7 +2975,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax 0.8.10", ] @@ -2932,20 +2992,14 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "fax" @@ -2981,7 +3035,7 @@ dependencies = [ "file-id", "log", "notify 9.0.0-rc.4", - "notify-types 2.1.0", + "notify-types", "rustc-hash 2.1.2", "walkdir", ] @@ -3055,16 +3109,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3111,12 +3155,12 @@ name = "flowstate" version = "0.1.0" dependencies = [ "anyhow", - "async-channel 2.5.0", + "async-channel", "atomicwrites", "blake3", "chrono", "clap", - "cosmic-text", + "cosmic-text 0.14.2", "dirs 6.0.0", "fff-search", "flowstate-collab", @@ -3130,6 +3174,7 @@ dependencies = [ "gpui-component", "gpui-component-assets", "gpui-symbols", + "gpui_platform", "hotpath", "iroh", "loro", @@ -3146,6 +3191,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zed-font-kit", ] [[package]] @@ -3153,7 +3199,7 @@ name = "flowstate-collab" version = "0.1.0" dependencies = [ "anyhow", - "async-channel 2.5.0", + "async-channel", "base64", "blake3", "bluer", @@ -3169,12 +3215,12 @@ dependencies = [ "iroh-tickets", "loro", "n0-future", - "objc2", + "objc2 0.6.4", "objc2-core-bluetooth", - "objc2-foundation", + "objc2-foundation 0.3.2", "pollster 0.4.0", "postcard", - "proptest", + "proptest 1.11.0", "rand 0.9.4", "reqwest", "rustc-hash 2.1.2", @@ -3203,6 +3249,7 @@ dependencies = [ "gpui", "gpui-component", "gpui-flowtext", + "gpui_platform", "hotpath", "ignore", "image", @@ -3224,7 +3271,7 @@ dependencies = [ "gpui", "gpui-flowtext", "hotpath", - "imagesize", + "imagesize 0.13.0", "loro", "loro-internal", "postcard", @@ -3246,7 +3293,7 @@ dependencies = [ "gpui", "hotpath", "image", - "imagesize", + "imagesize 0.13.0", "lopdf", "memchr", "quick-xml 0.37.5", @@ -3317,13 +3364,13 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ + "fastrand", "futures-core", "futures-sink", - "nanorand", "spin 0.9.8", ] @@ -3378,7 +3425,6 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" dependencies = [ - "fontconfig-parser", "log", "memmap2", "slotmap", @@ -3436,6 +3482,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a440748e063798e4893ceb877151e84acef9bea9a8c6800645cf3f1b3a7806e" +dependencies = [ + "freetype-sys", + "libc", +] + [[package]] name = "freetype-sys" version = "0.20.1" @@ -3534,7 +3590,7 @@ checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" dependencies = [ "fixedbitset", "futures-core", - "futures-lite 2.6.1", + "futures-lite", "pin-project", "smallvec", ] @@ -3562,28 +3618,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.4.1", + "fastrand", "futures-core", "futures-io", "parking", @@ -3706,9 +3747,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -3776,6 +3819,17 @@ dependencies = [ "url", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glidesort" version = "0.1.2" @@ -3826,9 +3880,9 @@ dependencies = [ [[package]] name = "glow" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -3837,31 +3891,44 @@ dependencies = [ ] [[package]] -name = "gpu-alloc" -version = "0.6.0" +name = "glutin_wgl_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" dependencies = [ - "bitflags 2.13.0", - "gpu-alloc-types", + "gl_generator", ] [[package]] -name = "gpu-alloc-ash" -version = "0.7.0" +name = "gpu-allocator" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbda7a18a29bc98c2e0de0435c347df935bf59489935d0cbd0b73f1679b6f79a" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ "ash", - "gpu-alloc-types", - "tinyvec", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", + "windows 0.62.2", ] [[package]] -name = "gpu-alloc-types" -version = "0.3.0" +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ "bitflags 2.13.0", ] @@ -3869,124 +3936,154 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ + "accesskit", "anyhow", - "as-raw-xcb-connection", - "ashpd 0.11.1", + "async-channel", "async-task", "backtrace", "bindgen", - "blade-graphics", - "blade-macros", - "blade-util", + "bitflags 2.13.0", "block", - "bytemuck", - "calloop", - "calloop-wayland-source", "cbindgen", + "chrono", "cocoa 0.26.0", "cocoa-foundation 0.2.0", + "collections", "core-foundation 0.10.0", "core-foundation-sys", "core-graphics 0.24.0", "core-text", "core-video", - "cosmic-text", "ctor", - "derive_more 0.99.20", + "derive_more", "embed-resource", "etagere", - "filedescriptor", - "flume", "foreign-types", "futures", - "gpui-macros", - "gpui_collections", - "gpui_http_client", - "gpui_media", - "gpui_refineable", - "gpui_semantic_version", - "gpui_sum_tree", + "futures-concurrency", + "getrandom 0.3.4", + "gpui_macros", + "gpui_shared_string", "gpui_util", - "gpui_util_macros", + "heapless 0.9.3", + "http_client", "image", "inventory", "itertools 0.14.0", - "libc", "log", "lyon", + "mach2", + "media", "metal", - "naga", "num_cpus", "objc", - "oo7", - "open", "parking", "parking_lot", "pathfinder_geometry", "pin-project", + "pollster 0.4.0", "postage", "profiling", + "proptest 1.10.0", "rand 0.9.4", "raw-window-handle", - "resvg", + "refineable", + "regex", + "resvg 0.46.0", + "scheduler", "schemars", "seahash", "serde", "serde_json", "slotmap", "smallvec", - "smol", + "spin 0.10.0", "stacksafe", "strum 0.27.2", + "sum_tree", "taffy", "thiserror 2.0.18", - "usvg", + "tracing", + "ttf-parser 0.25.1", + "url", + "usvg 0.46.0", + "util_macros", "uuid", "waker-fn", - "wayland-backend", - "wayland-client", - "wayland-cursor", - "wayland-protocols 0.31.2", - "wayland-protocols-plasma", + "web-time", "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics 0.2.0", - "windows-registry 0.5.3", - "x11-clipboard", - "x11rb", - "xkbcommon", - "zed-font-kit", "zed-scap", - "zed-xim", + "ztracing", ] [[package]] -name = "gpui-component" -version = "0.5.1" +name = "gpui-base" +version = "0.5.2" dependencies = [ "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "gpui", + "gpui_macros", + "gpui_platform", + "indoc", + "instant", + "lsp-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "syntect", + "tracing", + "unicode-segmentation", + "web-time", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component" +version = "0.5.2" +dependencies = [ "anyhow", "chrono", "core-text", "enum-iterator", + "futures", "gpui", + "gpui-base", + "gpui-component-assets", "gpui-component-macros", - "gpui-macros", + "gpui_macros", "html5ever", + "indoc", + "instant", "itertools 0.13.0", + "log", "lsp-types", "markdown", "markup5ever_rcdom", - "notify 7.0.0", + "notify 8.2.0", "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "once_cell", "paste", - "regex", + "raw-window-handle", + "resvg 0.45.1", "ropey", "rust-i18n", + "rust_decimal", "schemars", "serde", "serde_json", @@ -3995,28 +4092,61 @@ dependencies = [ "smol", "tracing", "tree-sitter", + "tree-sitter-astro-next", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-embedded-template", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", "tree-sitter-json", - "unicode-segmentation", + "tree-sitter-kotlin-sg", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-php", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", + "tree-sitter-svelte-next", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", "uuid", + "windows 0.58.0", "zed-sum-tree", ] [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc6e4c6551a1a12d4e8b69c3e8eba3cef43331c8c87898a0d4d040c78c6865e" dependencies = [ "anyhow", "gpui", + "log", + "reqwest", "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fbc2d84bf91717b171320e6adc600d91ccb3ed259448f3b006787633c1c615" dependencies = [ "proc-macro2", "quote", @@ -4028,7 +4158,7 @@ name = "gpui-flowtext" version = "0.1.0" dependencies = [ "blake3", - "cosmic-text", + "cosmic-text 0.14.2", "crop", "flowstate-collab", "flowstate-document", @@ -4037,10 +4167,10 @@ dependencies = [ "gpui-component", "hotpath", "im", - "imagesize", + "imagesize 0.13.0", "loro", "mitex", - "resvg", + "resvg 0.45.1", "rustc-hash 2.1.2", "serde", "serde_json", @@ -4054,18 +4184,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "gpui-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcb02dd63a2859714ac7b6b476937617c3c744157af1b49f7c904023a79039be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "gpui-symbols" version = "0.6.1" @@ -4080,165 +4198,260 @@ dependencies = [ ] [[package]] -name = "gpui_collections" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c" -dependencies = [ - "indexmap", - "rustc-hash 2.1.2", -] - -[[package]] -name = "gpui_derive_refineable" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644de174341a87b3478bd65b66bca38af868bcf2b2e865700523734f83cfc664" +name = "gpui_apple" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "anyhow", + "block", + "cbindgen", + "cocoa 0.26.0", + "collections", + "core-foundation 0.10.0", + "core-video", + "derive_more", + "etagere", + "foreign-types", + "gpui", + "image", + "log", + "metal", + "objc", + "parking_lot", ] [[package]] -name = "gpui_http_client" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e" +name = "gpui_linux" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ + "accesskit", + "accesskit_unix", "anyhow", - "async-compression", - "async-fs", - "bytes", - "derive_more 0.99.20", + "as-raw-xcb-connection", + "ashpd", + "bitflags 2.13.0", + "bytemuck", + "calloop", + "calloop-wayland-source", + "collections", + "filedescriptor", "futures", + "gpui", "gpui_util", - "http", - "http-body", + "gpui_wgpu", + "http_client", + "image", + "itertools 0.14.0", + "libc", "log", + "notify-rust", + "oo7", + "open", "parking_lot", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", + "pathfinder_geometry", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "smallvec", + "smol", + "strum 0.27.2", + "swash", "url", - "zed-async-tar", - "zed-reqwest", + "uuid", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-scap", + "zed-xim", ] [[package]] -name = "gpui_media" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" +name = "gpui_macos" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ + "accesskit", + "accesskit_macos", "anyhow", - "bindgen", + "async-task", + "block", + "block2 0.6.2", + "cocoa 0.26.0", + "collections", "core-foundation 0.10.0", - "core-video", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", "ctor", + "dispatch2", "foreign-types", + "futures", + "gpui", + "gpui_apple", + "gpui_util", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "media", "metal", "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-user-notifications", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum 0.27.2", + "uuid", ] [[package]] -name = "gpui_perf" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40a0961dcf598955130e867f4b731150a20546427b41b1a63767c1037a86d77" +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "gpui_platform" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "console_error_panic_hook", + "gpui", + "gpui_linux", + "gpui_macos", + "gpui_web", + "gpui_windows", +] + +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ - "gpui_collections", + "schemars", "serde", - "serde_json", + "smol_str 0.3.6", ] [[package]] -name = "gpui_refineable" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258cb099254e9468181aee5614410fba61db4ae115fc1d51b4a0b985f60d6641" +name = "gpui_util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ - "gpui_derive_refineable", + "anyhow", + "log", + "which 6.0.3", ] [[package]] -name = "gpui_semantic_version" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201e45eff7b695528fb3af6560a534943fbc2db5323d755b9d198bd743948e35" +name = "gpui_web" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ "anyhow", - "serde", + "console_error_panic_hook", + "futures", + "gpui", + "gpui_wgpu", + "http_client", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", ] [[package]] -name = "gpui_sum_tree" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9" +name = "gpui_wgpu" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ - "arrayvec", + "anyhow", + "bytemuck", + "collections", + "cosmic-text 0.19.0", + "etagere", + "gpui", + "gpui_util", + "itertools 0.14.0", + "js-sys", "log", - "rayon", + "parking_lot", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-bidi", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", + "zed-font-kit", ] [[package]] -name = "gpui_util" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3" +name = "gpui_windows" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ + "accesskit", + "accesskit_windows", "anyhow", - "async-fs", - "async_zip", - "command-fds", - "dirs 4.0.0", + "collections", "dunce", + "etagere", "futures", - "futures-lite 1.13.0", - "git2", - "globset", - "gpui_collections", - "gpui_util_macros", + "gpui", + "gpui_util", + "image", "itertools 0.14.0", - "libc", "log", - "nix 0.29.0", + "parking_lot", "rand 0.9.4", - "regex", - "rust-embed", - "schemars", - "serde", - "serde_json", - "serde_json_lenient", - "shlex 1.3.0", - "smol", - "take-until", - "tempfile", - "tendril", - "unicase", - "walkdir", - "which 6.0.3", + "raw-window-handle", + "smallvec", + "uuid", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics 0.2.0", + "windows-registry 0.5.3", ] [[package]] -name = "gpui_util_macros" -version = "0.2.2" +name = "granit-parser" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c28f65ef47fb97e21e82fd4dd75ccc2506eda010c846dc8054015ea234f1a22" +checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" dependencies = [ - "gpui_perf", - "quote", - "syn 2.0.117", + "arraydeque", + "smallvec", ] -[[package]] -name = "grid" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" - [[package]] name = "h2" version = "0.4.14" @@ -4271,10 +4484,23 @@ dependencies = [ ] [[package]] -name = "hash32" -version = "0.2.1" +name = "harfrust" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" dependencies = [ "byteorder", ] @@ -4616,24 +4842,13 @@ dependencies = [ "resolv-conf", "rustls", "smallvec", - "system-configuration 0.7.0", + "system-configuration", "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", ] -[[package]] -name = "hidden-trait" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -4776,6 +4991,26 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + [[package]] name = "httparse" version = "1.10.1" @@ -4829,7 +5064,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -5248,6 +5482,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "imgref" version = "1.12.2" @@ -5267,14 +5507,12 @@ dependencies = [ ] [[package]] -name = "inotify" -version = "0.10.2" +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", + "rustversion", ] [[package]] @@ -5324,6 +5562,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -5392,7 +5633,7 @@ dependencies = [ "cfg_aliases", "ctutils", "data-encoding", - "derive_more 2.1.1", + "derive_more", "ed25519-dalek", "futures-util", "getrandom 0.4.2", @@ -5442,7 +5683,7 @@ dependencies = [ "curve25519-dalek", "data-encoding", "data-encoding-macro", - "derive_more 2.1.1", + "derive_more", "digest 0.11.3", "ed25519-dalek", "getrandom 0.4.2", @@ -5463,7 +5704,7 @@ checksum = "de4112c91eb64094d77df9d3112606dcf7ff216421afccd2dc762fda5a7b2879" dependencies = [ "arc-swap", "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "hickory-resolver", "iroh-base", "n0-error", @@ -5489,7 +5730,7 @@ dependencies = [ "bytes", "constant_time_eq", "data-encoding", - "derive_more 2.1.1", + "derive_more", "ed25519-dalek", "futures-concurrency", "hex", @@ -5545,7 +5786,7 @@ dependencies = [ "bytes", "cfg_aliases", "data-encoding", - "derive_more 2.1.1", + "derive_more", "getrandom 0.4.2", "hickory-resolver", "http", @@ -5588,7 +5829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d4051ac9825cd834d88377dab71c1d9d483b65e9740029b2d624cc17ac58d" dependencies = [ "data-encoding", - "derive_more 2.1.1", + "derive_more", "iroh-base", "n0-error", "postcard", @@ -5750,7 +5991,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", @@ -5771,6 +6012,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -5828,8 +6078,15 @@ checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", "libloading", + "pkg-config", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kqueue" version = "1.2.0" @@ -5884,23 +6141,11 @@ dependencies = [ "smallvec", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.8", -] [[package]] name = "leak" @@ -6041,12 +6286,30 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -6084,6 +6347,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lmdb-master-sys" version = "0.2.6" @@ -6462,12 +6731,35 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + [[package]] name = "macaddr" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -6483,6 +6775,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" dependencies = [ + "serde", "unicode-id", ] @@ -6566,6 +6859,21 @@ dependencies = [ "log", ] +[[package]] +name = "media" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + [[package]] name = "memchr" version = "2.8.2" @@ -6592,13 +6900,13 @@ dependencies = [ [[package]] name = "metal" -version = "0.29.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" dependencies = [ "bitflags 2.13.0", "block", - "core-graphics-types 0.1.3", + "core-graphics-types 0.2.0", "foreign-types", "log", "objc", @@ -6614,22 +6922,6 @@ dependencies = [ "libmimalloc-sys", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6646,12 +6938,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mint" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" - [[package]] name = "mio" version = "1.2.1" @@ -6811,9 +7097,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "futures-buffered", - "futures-lite 2.6.1", + "futures-lite", "futures-util", "js-sys", "pin-project", @@ -6831,51 +7117,52 @@ version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52" dependencies = [ - "derive_more 2.1.1", + "derive_more", "n0-error", "n0-future", ] [[package]] name = "naga" -version = "25.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.0", + "cfg-if", "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hexf-parse", "indexmap", + "libm", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", - "strum 0.26.3", "thiserror 2.0.18", "unicode-ident", ] -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "neo_frizbee" version = "0.10.2" @@ -6892,7 +7179,7 @@ version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" dependencies = [ - "block2", + "block2 0.6.2", "dispatch2", "dlopen2", "ipnet", @@ -6903,7 +7190,7 @@ dependencies = [ "netlink-sys", "objc2-core-foundation", "objc2-core-wlan", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-system-configuration", "once_cell", "plist", @@ -6979,7 +7266,7 @@ dependencies = [ "atomic-waker", "bytes", "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "js-sys", "libc", "n0-error", @@ -7024,18 +7311,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "no_std_io2" version = "0.9.4" @@ -7045,6 +7320,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -7084,7 +7365,7 @@ checksum = "198b99fc085a5db1f7d259edb5ede8311e59f28cdd2687920b4313613d21a73f" dependencies = [ "bytes", "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "noq-proto", "noq-udp", "pin-project-lite", @@ -7106,7 +7387,7 @@ checksum = "1ab0ac774795ce1e42a7e61266e71f3be8110210630441169ac8dda403dd23f1" dependencies = [ "aes-gcm", "bytes", - "derive_more 2.1.1", + "derive_more", "enum-assoc", "getrandom 0.4.2", "identity-hash", @@ -7147,25 +7428,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "notify" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" -dependencies = [ - "bitflags 2.13.0", - "filetime", - "fsevent-sys", - "inotify 0.10.2", - "kqueue", - "libc", - "log", - "mio", - "notify-types 1.0.1", - "walkdir", - "windows-sys 0.52.0", -] - [[package]] name = "notify" version = "8.2.0" @@ -7174,12 +7436,12 @@ checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.13.0", "fsevent-sys", - "inotify 0.11.2", + "inotify", "kqueue", "libc", "log", "mio", - "notify-types 2.1.0", + "notify-types", "walkdir", "windows-sys 0.60.2", ] @@ -7191,12 +7453,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e" dependencies = [ "bitflags 2.13.0", - "inotify 0.11.2", + "inotify", "kqueue", "libc", "log", "mio", - "notify-types 2.1.0", + "notify-types", "objc2-core-foundation", "objc2-core-services", "walkdir", @@ -7205,12 +7467,17 @@ dependencies = [ ] [[package]] -name = "notify-types" -version = "1.0.1" +name = "notify-rust" +version = "4.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" dependencies = [ - "instant", + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", ] [[package]] @@ -7266,16 +7533,16 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.6" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" dependencies = [ - "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "once_cell", + "rand 0.9.4", "serde", "smallvec", "zeroize", @@ -7410,6 +7677,22 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -7421,119 +7704,295 @@ dependencies = [ [[package]] name = "objc2-app-kit" -version = "0.3.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", ] [[package]] -name = "objc2-core-bluetooth" +name = "objc2-app-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", - "dispatch2", - "objc2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] -name = "objc2-core-foundation" +name = "objc2-cloud-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.0", - "block2", - "dispatch2", - "libc", - "objc2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-core-services" +name = "objc2-core-bluetooth" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" dependencies = [ - "libc", + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-core-wlan" -version = "0.3.2" +name = "objc2-core-data" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-security", - "objc2-security-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "objc2-core-data" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] [[package]] -name = "objc2-foundation" +name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.6.2", + "dispatch2", "libc", - "objc2", - "objc2-core-foundation", + "objc2 0.6.4", ] [[package]] -name = "objc2-metal" +name = "objc2-core-graphics" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.0", - "block2", - "objc2", - "objc2-foundation", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] -name = "objc2-quartz-core" -version = "0.3.2" +name = "objc2-core-image" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] -name = "objc2-security" +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-security" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.13.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -7543,8 +8002,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -7556,22 +8015,22 @@ dependencies = [ "bitflags 2.13.0", "dispatch2", "libc", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-security", ] [[package]] -name = "objc2-ui-kit" +name = "objc2-user-notifications" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ "bitflags 2.13.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-location", + "objc2-foundation 0.3.2", ] [[package]] @@ -7634,12 +8093,12 @@ checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "oo7" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes 0.8.4", - "ashpd 0.12.3", + "ashpd", "async-fs", "async-io", "async-lock", @@ -7648,17 +8107,17 @@ dependencies = [ "cipher 0.4.4", "digest 0.10.7", "endi", - "futures-lite 2.6.1", + "futures-lite", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.2", "hkdf", "hmac 0.12.1", "md-5 0.10.6", "num", "num-bigint-dig", "pbkdf2 0.12.2", - "rand 0.9.4", "serde", + "serde_bytes", "sha2 0.10.9", "subtle", "zbus", @@ -7792,7 +8251,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -7881,6 +8340,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "collections", + "serde", + "serde_json", +] + [[package]] name = "pest" version = "2.8.6" @@ -7981,7 +8450,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand 2.4.1", + "fastrand", "phf_shared 0.13.1", ] @@ -8061,12 +8530,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.5" @@ -8074,7 +8537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand 2.4.1", + "fastrand", "futures-io", ] @@ -8206,7 +8669,7 @@ checksum = "64959cbabf952c8ffcbaea13745308508f1f825922f4068353f3de08d42cf214" dependencies = [ "base64", "bytes", - "derive_more 2.1.1", + "derive_more", "hyper-util", "igd-next", "iroh-metrics", @@ -8316,6 +8779,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -8358,28 +8827,6 @@ dependencies = [ "toml_edit 0.25.12+spec-1.1.0", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -8414,14 +8861,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.0", + "num-traits", + "proptest-macro", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax 0.8.10", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "proptest" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.13.0", "num-traits", "rand 0.9.4", @@ -8433,6 +8899,17 @@ dependencies = [ "unarray", ] +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "psm" version = "0.1.31" @@ -8766,6 +9243,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "rangemap" version = "1.7.1" @@ -8839,14 +9322,14 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "raw-window-metal" -version = "0.4.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "cocoa 0.25.0", - "core-graphics 0.23.2", - "objc", - "raw-window-handle", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -8959,6 +9442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", + "core_maths", "font-types 0.11.3", ] @@ -8972,15 +9456,6 @@ dependencies = [ "font-types 0.11.3", ] -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -9032,6 +9507,14 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "derive_refineable", +] + [[package]] name = "regex" version = "1.12.3" @@ -9076,6 +9559,12 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.13.4" @@ -9113,7 +9602,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -9134,12 +9623,29 @@ dependencies = [ "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia", - "usvg", + "usvg 0.45.1", "zune-jpeg 0.4.21", ] +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia", + "usvg 0.46.0", + "zune-jpeg 0.5.15", +] + [[package]] name = "rgb" version = "0.8.53" @@ -9298,12 +9804,11 @@ dependencies = [ [[package]] name = "rust-i18n" -version = "3.1.5" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4" dependencies = [ "globwalk", - "once_cell", "regex", "rust-i18n-macro", "rust-i18n-support", @@ -9312,39 +9817,33 @@ dependencies = [ [[package]] name = "rust-i18n-macro" -version = "3.1.5" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e" dependencies = [ "glob", - "once_cell", "proc-macro2", "quote", "rust-i18n-support", "serde", "serde_json", - "serde_yaml", "syn 2.0.117", ] [[package]] name = "rust-i18n-support" -version = "3.1.5" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9" dependencies = [ "arc-swap", "base62", "globwalk", "itertools 0.11.0", - "lazy_static", "normpath", - "once_cell", - "proc-macro2", - "regex", "serde", + "serde-saphyr", "serde_json", - "serde_yaml", "siphasher", "toml 0.8.23", "triomphe", @@ -9367,7 +9866,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" dependencies = [ "arrayvec", + "borsh", + "bytes", "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", ] [[package]] @@ -9451,15 +9957,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -9587,17 +10084,32 @@ dependencies = [ ] [[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" dependencies = [ - "dyn-clone", - "indexmap", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.4", + "web-time", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", ] [[package]] @@ -9697,6 +10209,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -9714,6 +10230,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +dependencies = [ + "ahash 0.8.12", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -9803,19 +10338,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_json_lenient" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" -dependencies = [ - "indexmap", - "itoa", - "memchr", - "ryu", - "serde", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -10105,7 +10627,7 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-executor", "async-fs", "async-io", @@ -10113,7 +10635,7 @@ dependencies = [ "async-net", "async-process", "blocking", - "futures-lite 2.6.1", + "futures-lite", ] [[package]] @@ -10122,6 +10644,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.4" @@ -10163,12 +10695,15 @@ name = "spin" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ "bitflags 2.13.0", ] @@ -10216,9 +10751,9 @@ dependencies = [ [[package]] name = "stacksafe" -version = "0.1.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" dependencies = [ "stacker", "stacksafe-macro", @@ -10226,13 +10761,13 @@ dependencies = [ [[package]] name = "stacksafe-macro" -version = "0.1.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" dependencies = [ - "proc-macro-error2", + "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -10386,6 +10921,18 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "heapless 0.9.3", + "log", + "rayon", + "tracing", + "ztracing", +] + [[package]] name = "sval" version = "2.20.0" @@ -10480,6 +11027,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo 0.13.1", + "siphasher", +] + [[package]] name = "swash" version = "0.2.7" @@ -10519,6 +11076,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10592,17 +11160,6 @@ dependencies = [ "windows 0.57.0", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -10626,14 +11183,14 @@ dependencies = [ [[package]] name = "taffy" -version = "0.9.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004" +checksum = "c034e05f6ee85a12daa63863c2245797715075c70649947aa0da54f3f2ab1d0f" dependencies = [ "arrayvec", - "grid", "serde", "slotmap", + "smallvec", ] [[package]] @@ -10642,12 +11199,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "take-until" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" - [[package]] name = "tantivy" version = "0.26.1" @@ -10814,13 +11365,24 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand 2.4.1", + "fastrand", "getrandom 0.4.2", "once_cell", "rustix 1.1.4", @@ -11087,18 +11649,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-socks" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.18" @@ -11298,110 +11848,440 @@ dependencies = [ ] [[package]] -name = "tracing-appender" -version = "0.2.5" +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tree-sitter" +version = "0.26.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c567a8e18ae93f20982c90370b16fd24023aeaf52f6052b96957ab253a0fec" +dependencies = [ + "cc", + "regex", + "regex-syntax 0.8.10", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-astro-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-embedded-template" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-jsdoc" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-sg" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-lua" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "tree-sitter-scala" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tree-sitter-sequel" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" dependencies = [ - "once_cell", - "valuable", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "tree-sitter-svelte-next" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753" dependencies = [ - "log", - "once_cell", - "tracing-core", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-serde" -version = "0.2.0" +name = "tree-sitter-swift" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" dependencies = [ - "serde", - "tracing-core", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-subscriber" -version = "0.3.23" +name = "tree-sitter-toml-ng" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", + "cc", + "tree-sitter-language", ] [[package]] -name = "tree-sitter" -version = "0.25.10" +name = "tree-sitter-typescript" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" dependencies = [ "cc", - "regex", - "regex-syntax 0.8.10", - "serde_json", - "streaming-iterator", "tree-sitter-language", ] [[package]] -name = "tree-sitter-json" -version = "0.24.8" +name = "tree-sitter-yaml" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" dependencies = [ "cc", "tree-sitter-language", ] [[package]] -name = "tree-sitter-language" -version = "0.1.7" +name = "tree-sitter-zig" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] [[package]] name = "triomphe" @@ -11672,7 +12552,7 @@ dependencies = [ "unicode-normalization", "unicode-segmentation", "unscanny", - "usvg", + "usvg 0.45.1", "utf8_iter", "wasmi", "xmlwriter", @@ -11844,12 +12724,6 @@ dependencies = [ "unic-langid-impl", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -12006,7 +12880,7 @@ dependencies = [ "data-url", "flate2", "fontdb 0.23.0", - "imagesize", + "imagesize 0.13.0", "kurbo 0.11.3", "log", "pico-args", @@ -12015,7 +12889,34 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize 0.14.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz 0.20.1", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.16.1", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -12047,6 +12948,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "perf", + "quote", + "syn 2.0.117", +] + [[package]] name = "uuid" version = "1.23.2" @@ -12249,6 +13160,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -12319,9 +13231,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -12331,15 +13243,13 @@ dependencies = [ ] [[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +name = "wasm_thread" +version = "0.3.3" +source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" dependencies = [ - "futures-util", + "futures", "js-sys", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", ] @@ -12440,9 +13350,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.31.2" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ "bitflags 2.13.0", "wayland-backend", @@ -12451,103 +13361,282 @@ dependencies = [ ] [[package]] -name = "wayland-protocols" -version = "0.32.12" +name = "wayland-protocols-plasma" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ "bitflags 2.13.0", "wayland-backend", "wayland-client", + "wayland-protocols", "wayland-scanner", ] [[package]] -name = "wayland-protocols-plasma" -version = "0.2.0" +name = "wayland-protocols-wlr" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.13.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-scanner", ] [[package]] -name = "wayland-scanner" -version = "0.31.10" +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags 2.13.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ - "proc-macro2", - "quick-xml 0.39.4", - "quote", + "wgpu-hal", ] [[package]] -name = "wayland-sys" -version = "0.31.11" +name = "wgpu-core-deps-wasm" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", + "wgpu-hal", ] [[package]] -name = "web-sys" -version = "0.3.103" +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ - "js-sys", - "wasm-bindgen", + "wgpu-hal", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "wgpu-hal" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.9.1", + "bitflags 2.13.0", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] -name = "webpki-root-certs" -version = "1.0.8" +name = "wgpu-naga-bridge" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ - "rustls-pki-types", + "naga", + "wgpu-types", ] [[package]] -name = "webpki-roots" -version = "1.0.7" +name = "wgpu-types" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ - "rustls-pki-types", + "bitflags 2.13.0", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "weezl" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" - [[package]] name = "which" version = "5.0.0" @@ -12620,6 +13709,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -12688,6 +13787,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -12747,6 +13859,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -12769,6 +13892,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -12812,17 +13946,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-registry" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result 0.3.4", - "windows-strings 0.3.1", - "windows-targets 0.53.5", -] - [[package]] name = "windows-registry" version = "0.5.3" @@ -12854,6 +13977,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -12874,11 +14006,12 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.3.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-link 0.1.3", + "windows-result 0.2.0", + "windows-targets 0.52.6", ] [[package]] @@ -13010,6 +14143,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -13399,15 +14541,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "xattr" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" -dependencies = [ - "libc", -] - [[package]] name = "xcb" version = "1.7.0" @@ -13429,17 +14562,15 @@ checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] name = "xim-ctext" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "encoding_rs", ] [[package]] name = "xim-parser" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" +version = "0.2.1" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "bitflags 2.13.0", ] @@ -13607,9 +14738,9 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.1", + "event-listener", "futures-core", - "futures-lite 2.6.1", + "futures-lite", "hex", "libc", "ordered-stream", @@ -13626,6 +14757,30 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.16.0" @@ -13638,14 +14793,14 @@ dependencies = [ "syn 2.0.117", "zbus_names", "zvariant", - "zvariant_utils", + "zvariant_utils 3.4.0", ] [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow 1.0.3", @@ -13653,33 +14808,40 @@ dependencies = [ ] [[package]] -name = "zed-async-tar" -version = "0.5.0-zed" +name = "zbus_xml" +version = "5.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf4b5f655e29700e473cb1acd914ab112b37b62f96f7e642d5fc6a0c02eb881" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ - "async-std", - "filetime", - "libc", - "pin-project", - "redox_syscall 0.2.16", - "xattr", + "serde", + "winnow 1.0.3", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", ] [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" +source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" dependencies = [ "bitflags 2.13.0", "byteorder", "core-foundation 0.10.0", "core-graphics 0.24.0", "core-text", - "dirs 5.0.1", + "dirs 6.0.0", "dwrote", "float-ord", + "freetype", "freetype-sys", "lazy_static", "libc", @@ -13691,61 +14853,10 @@ dependencies = [ "yeslogic-fontconfig-sys", ] -[[package]] -name = "zed-reqwest" -version = "0.12.15-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "mime_guess", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration 0.6.1", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-util", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "windows-registry 0.4.0", -] - [[package]] name = "zed-scap" version = "0.0.8-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" dependencies = [ "anyhow", "cocoa 0.25.0", @@ -13778,8 +14889,7 @@ dependencies = [ [[package]] name = "zed-xim" version = "0.4.0-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "ahash 0.8.12", "hashbrown 0.14.5", @@ -13970,6 +15080,17 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + [[package]] name = "zmij" version = "1.0.21" @@ -14016,6 +15137,22 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=e0931d5a9dbf4f781b336fdf448739e74a2ac0b5#e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" + [[package]] name = "zune-core" version = "0.4.12" @@ -14057,30 +15194,31 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "url", + "serde_bytes", "winnow 1.0.3", + "zcheapstr", "zvariant_derive", - "zvariant_utils", + "zvariant_utils 4.2.0", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", - "zvariant_utils", + "syn 3.0.3", + "zvariant_utils 4.2.0", ] [[package]] @@ -14095,3 +15233,16 @@ dependencies = [ "syn 2.0.117", "winnow 1.0.3", ] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml index bbcf9946..2903536e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,13 @@ cargo-features = ["profile-rustflags"] [workspace] -members = ["crates/*"] +members = [ + "crates/*", + "vendor/gpui-base", + "vendor/gpui-component", + "vendor/gpui-component-assets", + "vendor/gpui-component-macros", +] default-members = ["crates/flowstate"] resolver = "3" @@ -31,9 +37,25 @@ flowstate-collab = { version = "0.1.0", path = "crates/flowstate-collab" } flowstate-flow = { version = "0.1.0", path = "crates/flowstate-flow" } flowstate-tub = { version = "0.1.0", path = "crates/flowstate-tub" } gpui-flowtext = { version = "0.1.0", path = "crates/gpui-flowtext" } -gpui = "0.2.2" +gpui = { git = "https://github.com/zed-industries/zed.git", rev = "e0931d5a9dbf4f781b336fdf448739e74a2ac0b5", default-features = false } +gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" } gpui-component = "0.5.1" gpui-component-assets = "0.5.1" +gpui-base = { path = "vendor/gpui-base" } +gpui-component-macros = { path = "vendor/gpui-component-macros" } +gpui_macros = { git = "https://github.com/zed-industries/zed.git", rev = "e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" } +instant = { version = "0.1", features = ["wasm-bindgen"] } +log = "0.4" +lsp-types = { version = "0.97.0", features = ["proposed"] } +raw-window-handle = "0.6.2" +ropey = { version = "=2.0.0-beta.1", features = ["metric_chars", "metric_lines_lf", "metric_utf16"] } +rust-i18n = "4.2.0" +schemars = "1" +serde_repr = "0.1" +smallvec = "1" +smol = "2" +sum-tree = { version = "0.2.0", package = "zed-sum-tree" } +syntect = { version = "5.3.0", default-features = false, features = ["parsing", "default-syntaxes", "regex-fancy"] } gpui-symbols = { version = "0.6.1", features = ["component"] } hotpath = "0.16.1" ignore = "0.4.25" @@ -76,8 +98,10 @@ typst-svg = "0.14" unicode-segmentation = "1" url = "2.5" uuid = { version = "1.23.1", features = ["serde", "v4"] } +windows = { version = "0.58.0", features = ["Wdk", "Wdk_System", "Wdk_System_SystemServices"] } zip = { version = "0.6.6", default-features = false, features = ["deflate"] } zstd = "0.13.3" +zed-font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", features = ["loader-freetype-default", "source-fontconfig-dlopen"] } [patch.crates-io] # §perf (bimodal-undo fix): vendored to add `BTree::next_leaf_matching`, a @@ -87,7 +111,11 @@ zstd = "0.13.3" # rebuild retreats a large concurrent branch (the 80-195s dirty-history undo # regime). One added method; re-apply on generic-btree upgrades. generic-btree = { path = "vendor/generic-btree" } +gpui = { git = "https://github.com/zed-industries/zed.git", rev = "e0931d5a9dbf4f781b336fdf448739e74a2ac0b5" } gpui-component = { path = "vendor/gpui-component" } +gpui-base = { path = "vendor/gpui-base" } +gpui-component-assets = { path = "vendor/gpui-component-assets" } +gpui-component-macros = { path = "vendor/gpui-component-macros" } # §perf: vendored to add `LoroDoc::query_text_id_positions`, a single-pass batch # resolver for many text cursor ids. Upstream Loro 1.13 only offers per-id cursor # resolution (`get_cursor_pos`), which is O(elements) each; resolving every diff --git a/crates/flowstate-corpus/Cargo.toml b/crates/flowstate-corpus/Cargo.toml index 0af7648b..75f738db 100644 --- a/crates/flowstate-corpus/Cargo.toml +++ b/crates/flowstate-corpus/Cargo.toml @@ -11,6 +11,7 @@ flowstate-document = { version = "0.1.0", path = "../flowstate-document" } flowstate-docx = { version = "0.1.0", path = "../flowstate-docx" } tempfile.workspace = true gpui.workspace = true +gpui_platform.workspace = true gpui-component.workspace = true gpui-flowtext = { version = "0.1.0", path = "../gpui-flowtext" } hotpath.workspace = true diff --git a/crates/flowstate-document/Cargo.toml b/crates/flowstate-document/Cargo.toml index 6ea81680..4469f912 100644 --- a/crates/flowstate-document/Cargo.toml +++ b/crates/flowstate-document/Cargo.toml @@ -9,7 +9,7 @@ workspace = true [dependencies] gpui-flowtext = { workspace = true } -gpui = { version = "0.2.2", default-features = false } +gpui.workspace = true imagesize = { workspace = true } rustc-hash = { workspace = true } # §A13.1.2: "rayon" enables update_rayon — multithreaded hashing for the diff --git a/crates/flowstate-docx/Cargo.toml b/crates/flowstate-docx/Cargo.toml index 2acce0b8..a8162533 100644 --- a/crates/flowstate-docx/Cargo.toml +++ b/crates/flowstate-docx/Cargo.toml @@ -12,7 +12,7 @@ docx-rs = { workspace = true } docxide-pdf = { workspace = true } flowstate-document = { workspace = true } flowstate-fidelity.workspace = true -gpui = { version = "0.2.2", default-features = false } +gpui.workspace = true hotpath = { workspace = true } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "tiff", "webp"] } imagesize = { workspace = true } diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index 8f4518dd..9b3a61ec 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -20,7 +20,8 @@ flowstate-collab = { workspace = true } flowstate-flow = { workspace = true } flowstate-tub = { workspace = true } futures-util = "0.3" -gpui = { workspace = true } +gpui = { workspace = true, features = ["wayland", "x11"] } +gpui_platform = { workspace = true, features = ["wayland", "x11"] } gpui-component = { workspace = true } gpui-component-assets = { workspace = true } hotpath = { workspace = true } @@ -75,6 +76,7 @@ gpui-symbols = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] cosmic-text = { workspace = true } +zed-font-kit.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/vendor/gpui-base/Cargo.toml b/vendor/gpui-base/Cargo.toml new file mode 100644 index 00000000..ee673276 --- /dev/null +++ b/vendor/gpui-base/Cargo.toml @@ -0,0 +1,58 @@ +[package] +description = "Behavior, interaction, and infrastructure foundations for GPUI applications." +keywords = ["desktop", "gpui", "ui", "foundation"] +license = "Apache-2.0" +name = "gpui-base" +default-run = "components" +repository = "https://github.com/longbridge/gpui-component" +homepage = "https://longbridge.github.io/gpui-component" +documentation = "https://docs.rs/gpui-base" +version = "0.5.2" +publish = false +edition.workspace = true + +[lib] +doctest = false + +[features] +inspector = ["gpui_macros/inspector", "gpui/inspector"] + +[dependencies] +anyhow.workspace = true +aho-corasick = "1.1.3" +chrono = "0.4.38" +gpui.workspace = true +gpui_macros.workspace = true +instant.workspace = true +lsp-types.workspace = true +ropey.workspace = true +regex = "1" +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +smallvec.workspace = true +sum-tree.workspace = true +tracing.workspace = true +unicode-segmentation = "1.12.0" +web-time = "1" +syntect.workspace = true + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +smol.workspace = true +gpui_platform.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +async-channel = "2.3.1" + +[target.'cfg(target_os = "macos")'.dependencies] +raw-window-handle.workspace = true +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSView", "NSWindow"] } +objc2-foundation = { version = "0.3", features = ["NSGeometry", "NSString"] } + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +indoc = "2" + +[lints] +workspace = true diff --git a/vendor/gpui-base/LICENSE-APACHE b/vendor/gpui-base/LICENSE-APACHE new file mode 100644 index 00000000..1e9e81e7 --- /dev/null +++ b/vendor/gpui-base/LICENSE-APACHE @@ -0,0 +1,191 @@ +Copyright 2024 - 2026 Longbridge + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/vendor/gpui-base/README.md b/vendor/gpui-base/README.md new file mode 100644 index 00000000..f97deda9 --- /dev/null +++ b/vendor/gpui-base/README.md @@ -0,0 +1,326 @@ +# gpui-base + +[![Crates.io](https://img.shields.io/crates/v/gpui-base.svg)](https://crates.io/crates/gpui-base) +[![Documentation](https://docs.rs/gpui-base/badge.svg)](https://docs.rs/gpui-base) +[![License](https://img.shields.io/crates/l/gpui-base.svg)](../../LICENSE-APACHE) + +`gpui-base` is the reusable foundation of the [GPUI Component](https://github.com/longbridge/gpui-component) Rust desktop application framework, built on [GPUI](https://gpui.rs). It is intended for applications that want to build and own their own design systems. It provides interaction behavior, focus management, accessibility semantics, animation, virtual lists, theme tokens, and other foundational capabilities without imposing a visual style. + +> Use [`gpui-component`](https://crates.io/crates/gpui-component) if you want ready-to-use components with a complete visual design. Use `gpui-base` if your application should own its component source and visual styles while reusing stable, shared behavior. + +## Where It Fits in GPUI Component + +GPUI Component is the framework and project brand. Its currently implemented +architecture has two directly usable layers: + +```text +application +├── gpui-component Complete, styled framework experience +└── custom UI Application-owned design system + └── gpui-base Interaction, state, and infrastructure (this crate) +``` + +Dependencies always point from higher layers toward the foundation: `gpui-base` does not depend on `gpui-component`. Existing applications can continue using `gpui-component`; a direct dependency on `gpui-base` is only necessary when building custom components or a design system. + +## Relationship to the shadcn Ecosystem + +The GPUI Component ecosystem follows the same layering idea as [shadcn](https://ui.shadcn.com): + +| GPUI ecosystem | shadcn ecosystem | +| --- | --- | +| [GPUI](https://gpui.rs) | HTML + Tailwind CSS | +| `gpui-base` | [Base UI](https://base-ui.com) | +| `gpui-component` | shadcn | +| `crates/ui` in GPUI Component | shadcn's default UI | + +## Design Principles + +- **Behavior belongs to the foundation:** click handling, keyboard activation, controlled state, focus, accessibility roles, and infrastructure. +- **Presentation belongs to the application:** layout, size, color, spacing, radius, borders, shadows, variants, and animation are defined by the application or a higher-level component. +- **Applications own their components:** foundation controls can be freely composed and modified without adopting a fixed visual language. +- **Semantic APIs come first:** themes expose tokens such as `primary`, `surface`, and `destructive` instead of accumulating component-specific fields. +- **GPUI-native composition:** controls implement GPUI interfaces such as `Styled` and `ParentElement` and work with GPUI's fluent builder API. + +For example, `Button::new("save")` has no padding, background, radius, or size by default. Being unstyled is an explicit API contract, not a missing feature. + +## Installation + +To follow the repository's development branch, use a Git dependency instead: + +```toml +[dependencies] +gpui-base = { git = "https://github.com/longbridge/gpui-component" } +gpui = { git = "https://github.com/zed-industries/zed" } +gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] } +``` + +`gpui-base` uses the same GPUI version as the repository. If Cargo reports incompatible GPUI types, check whether your application is pulling GPUI from a different revision. + +### Optional Features + +| Feature | Enabled by default | Purpose | +| ----------- | ------------------ | ---------------------------------------------------------- | +| `inspector` | No | Enables inspector support in both `gpui` and `gpui_macros` | + +## Initialization + +Call `gpui_base::init(cx)` once before creating windows or using foundation controls. It installs the global theme and focus-trap infrastructure required by the base layer. + +```rust +use gpui::*; + +fn main() { + gpui_platform::application().run(|cx| { + gpui_base::init(cx); + + // Create windows and views after initialization. + }); +} +``` + +If the application already calls `gpui_component::init(cx)`, do not call `gpui_base::init(cx)` again. The higher-level initializer includes base initialization. + +## Quick Start + +Foundation controls can be styled and given children like ordinary GPUI elements: + +```rust +use gpui::prelude::*; +use gpui::{Context, IntoElement, Render, Window, px, rgb}; +use gpui_base::Button; + +struct SaveButton; + +impl Render for SaveButton { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Button::new("save") + .px_3() + .py_2() + .rounded(px(6.)) + .bg(rgb(0x2563eb)) + .text_color(rgb(0xffffff)) + .accessibility_label("Save document") + .on_click(|_, _, _| println!("save")) + .child("Save") + } +} +``` + +An `ElementId` must remain stable within a view so GPUI can preserve focus and element state. `Button` handles pointer, Enter, and Space activation through one path and provides `disabled`, `selected`, `tab_index`, and `tab_stop` for semantic state and focus traversal. + +### Controlled State + +`Checkbox`, `Radio`, `Switch`, and `Toggle` are controlled components. Their callbacks report the next value; the application updates its own state and passes that value back on the next render: + +```rust +use gpui::prelude::*; +use gpui::{Context, IntoElement, Render, Window}; +use gpui_base::{Checkbox, CheckboxIndicator}; + +struct Settings { + telemetry: bool, +} + +impl Render for Settings { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let checked = self.telemetry; + let settings = cx.entity().downgrade(); + + Checkbox::new("telemetry") + .checked(checked) + .accessibility_label("Send anonymous usage data") + .on_change(move |state, _, cx| { + _ = settings.update(cx, |this, cx| { + this.telemetry = state == gpui_base::CheckboxState::Checked; + cx.notify(); + }); + }) + .child( + CheckboxIndicator::new() + .checked(checked) + .child(if checked { "✓" } else { "" }), + ) + .child("Send anonymous usage data") + } +} +``` + +The caller also defines how semantic states look. For example: + +```rust +Button::new("menu-trigger") + .selected(menu_open) + .disabled(is_busy) + .styles(|styles| { + styles + .selected(|style| style.bg(rgb(0xe2e8f0))) + .disabled(|style| style.opacity(0.5)) + }) + .child("Menu") +``` + +Semantic state styles express states such as checked, pressed, selected, indeterminate, and disabled. Every control resolves its final style in one fixed order: + +1. the style applied directly in the main builder chain, +2. value states such as `checked`, `pressed`, `selected`, or `focused`, +3. `disabled`, which is always resolved last. + +Semantic states therefore layer over the builder chain, the same way GPUI layers `hover`, `active`, and `focus_visible` on top of an element's base style. A state only overrides the fields it sets, so unrelated builder-chain styles are preserved. To keep a specific builder-chain style as the closest layer even while a state is active, replay it inside that state: + +```rust +Button::new("save") + .bg(brand) + .styles(|styles| styles.disabled(|style| style.opacity(0.5).bg(brand))) +``` + +Base controls cannot suppress `hover` or `active` styles while disabled, because GPUI does not expose those refinements. Guard them at the call site with `when(!disabled, ..)`. + +## Capability Overview + +### Unstyled Controls + +| API | Behavior provided | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `Button` | Click and keyboard activation, focus, disabled and selected states, and the Button accessibility role | +| `Checkbox` / `CheckboxIndicator` | Checked, unchecked, and indeterminate states with corresponding accessibility semantics | +| `Radio` / `RadioGroup` | Radio activation, focus, and a grouping container | +| `Switch` / `SwitchTrack` / `SwitchThumb` | A controlled switch with independently styled track and thumb parts | +| `Toggle` / `ToggleGroup` | A controlled pressed state and grouping container | +| `Link` | Link semantics and activation with an application-provided `open_with` navigation strategy | +| `Table` and semantic table parts | Table, row-group, row, column-header, cell roles, and accessibility indices without layout or styling | +| `Toast` / `ToastStack` / `ToastManager` | Alert semantics, lifecycle, timers, limits, measured stack geometry, and interaction-aware motion | + +### Text Editing + +Text editing is split into purpose-specific controls instead of exposing the +complete editor interface on every text field: + +| Control | State | Use | +| --- | --- | --- | +| [`Input`](../../website/base/primitives/input.md) | `InputState` | Single-line values, masking, validation, and number stepping | +| [`Textarea`](../../website/base/primitives/textarea.md) | `TextareaState` | Ordinary multi-line text, fixed rows, wrapping, and auto-grow | +| [`Editor`](../../website/base/primitives/editor.md) | `EditorState` | Source code, highlighting, gutter, folding, decorations, diagnostics, and LSP integration | + +All three share the internal `InputBaseState` editing engine. Applications +should construct the purpose-specific state rather than configuring modes on +the shared engine. + +The base layer never opens a URL by itself. This allows the same `Link` to target internal routing, an embedded web view, or the system browser. + +### Focus and Interaction + +- `FocusTrapElement` turns an interactive element into a focus trap. Tab and Shift-Tab cycle within the container. +- `active_focus_trap` returns the active focus trap for a window. +- `InteractiveElementExt` adds interaction helpers to GPUI interactive elements. +- `ElementExt` adds post-layout, prepaint observation to parent elements. +- `FocusableExt` draws a focus ring using the application theme. + +### Scrolling and Large Data Sets + +- `Scrollbar` supports `ScrollHandle`, `UniformListScrollHandle`, `ListState`, and `VirtualListScrollHandle`, with vertical, horizontal, and dual-axis modes. +- `ScrollbarMode` controls when a scrollbar is visible. +- `v_virtual_list` and `h_virtual_list` render only the visible range while allowing every item to have a different size. +- `VirtualListScrollHandle` reads or updates a virtual list's scroll position. +- `AutoScroll` provides timer-based edge scrolling during drag interactions. + +A virtual list requires the caller to provide item sizes. Vertical lists use each item's height, while horizontal lists use each item's width. Unlike GPUI's `uniform_list`, this is suitable for data whose rows or columns do not share one size. + +### Animation + +`gpui-base` provides two animation APIs: + +- `motion::transition` is the preferred value-transition API. The caller chooses the animated property; it supports duration, delay, custom easing, smooth target reversal, and reduced-motion preferences. +- `animation::Transition` is the legacy element-animation API for composing fade, slide, and size effects. + +Foundation controls do not install animation automatically. Applications choose animation properties and timing according to their own visual language. + +### Themes and Styles + +- `Theme` stores base-layer global configuration, including semantic tokens and scrollbar defaults. +- `SemanticThemeTokens` contains `colors`, `radius`, `spacing`, `typography`, and `shadow` scales. +- `StateStyle` is a semantic-state style builder compatible with fluent helpers such as `when` and `when_some`. +- `StyledExt` provides common helpers for horizontal and vertical flex layouts, margins and padding, font weights, focus styling, and debug outlines. +- `h_flex`, `v_flex`, and `box_shadow` are common element and style constructors. + +The global base theme can be customized after initialization: + +```rust +use gpui::{px, rgb}; +use gpui_base::Theme; + +let theme = Theme::global_mut(cx); +theme.tokens.colors.primary = rgb(0x2563eb).into(); +theme.tokens.radius.md = px(8.); +``` + +Tokens describe design semantics; they do not automatically style unstyled controls. Applications read and apply these tokens in their own component implementations. + +### General Data and Layout Utilities + +| API | Purpose | +| --------------------------------- | ----------------------------------------------------------------------- | +| `History` / `HistoryItem` | Undo and redo history with grouping, deduplication, and capacity limits | +| `SliderState` | Single or range values, linear or logarithmic scales, and slider events | +| `IndexPath` | A section, row, and column index path | +| `Placement` / `Side` | Placement and layout direction descriptions | +| `AxisExt` / `LengthExt` / `Edges` | GPUI geometry extensions and serializable edges | + +## Relationship to gpui-component + +The crates target different abstraction levels and can be used in the same application: + +| | `gpui-base` | `gpui-component` | +| -------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------- | +| Role | Behavior and infrastructure | Complete UI component library | +| Default presentation | None | Included | +| Visual style owner | Application | Component library, customizable through its Theme and APIs | +| Best suited for | Custom design systems, registry components, and foundation reuse | Building complete desktop applications quickly | +| Initialization | `gpui_base::init(cx)` | `gpui_component::init(cx)`, which includes base initialization | + +Do not migrate from `gpui-component` by mechanically replacing imports. For example, `gpui_component::button::Button` is a fully styled higher-level component, while `gpui_base::Button` requires the caller to provide its children and all presentation styles. + +## Platform Support + +Platform support follows GPUI and GPUI Component: + +- macOS on Apple Silicon and Intel +- Linux on x86_64 +- Windows on x86_64 +- WebAssembly support depends on the APIs in use and the GPUI Web runtime + +## Development and Verification + +Run these commands from the GPUI Component repository root: + +```bash +# Check the foundation crate +cargo check -p gpui-base + +# Run the foundation crate tests +cargo test -p gpui-base + +# Check formatting +cargo fmt --check + +# Run Clippy +cargo clippy -p gpui-base -- --deny warnings +``` + +The current Rust interface is defined by the source code and generated API +documentation. See [`../../docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) +for the source-derived module architecture and +[`../../docs/STYLING-AND-MOTION.md`](../../docs/STYLING-AND-MOTION.md) for the +style and motion contracts. + +## Related Resources + +- [GPUI Component repository](https://github.com/longbridge/gpui-component) +- [GPUI Component documentation](https://longbridge.github.io/gpui-component) +- [`gpui-component` crate](https://crates.io/crates/gpui-component) +- [`gpui-base` API documentation](https://docs.rs/gpui-base) +- [GPUI](https://gpui.rs) +- [Contributing guide](../../CONTRIBUTING.md) + +## License + +Apache-2.0. See [`../../LICENSE-APACHE`](../../LICENSE-APACHE). diff --git a/vendor/gpui-base/examples/README.md b/vendor/gpui-base/examples/README.md new file mode 100644 index 00000000..1cce8769 --- /dev/null +++ b/vendor/gpui-base/examples/README.md @@ -0,0 +1,34 @@ +# gpui-base examples + +`components.rs` is the single Cargo example entrypoint for every documented `gpui-base` +component. It selects one component from the shared `showcase` implementation, so native and +WebAssembly previews exercise the same Rust code without producing one binary per component. + +Run an individual component natively: + +```bash +cargo run -p gpui-base --example components -- button +cargo run -p gpui-base --example components -- alert-dialog +cargo run -p gpui-base --example components -- virtual-list +``` + +Run without a component slug to show the overview: + +```bash +cargo run -p gpui-base --example components +``` + +The website builds `examples/wasm`, which imports the same `showcase/mod.rs` and selects the +component using the `?component=` query parameter. + +## dock + +`dock` is a showcase component like the rest, but a larger one: a dockable workspace — nested +splits, tab groups, and a bottom dock — built on `gpui-base` alone. Because the base dock draws +nothing, `showcase/components/dock.rs` implements the `DockAreaRenderer`, `TabGroupRenderer`, and +`TilesRenderer` traits itself, which is what makes it worth reading: it is the smallest complete +skin over the dock's renderer seam. + +```bash +cargo run -p gpui-base dock +``` diff --git a/vendor/gpui-base/examples/components.rs b/vendor/gpui-base/examples/components.rs new file mode 100644 index 00000000..12042e1f --- /dev/null +++ b/vendor/gpui-base/examples/components.rs @@ -0,0 +1,9 @@ +mod showcase; + +fn main() { + let component = std::env::args() + .nth(1) + .unwrap_or_else(|| "overview".to_string()); + + showcase::run_native(&component); +} diff --git a/vendor/gpui-base/examples/showcase/components/accordion.rs b/vendor/gpui-base/examples/showcase/components/accordion.rs new file mode 100644 index 00000000..cd65df6f --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/accordion.rs @@ -0,0 +1,69 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn accordion(&self, cx: &mut Context) -> impl IntoElement { + let items = [ + ( + "What is GPUI Base?", + "Unstyled, accessible primitives for building native GPUI interfaces.", + ), + ( + "Can I bring my own theme?", + "Yes. Every visual detail remains application-owned.", + ), + ( + "Does it support keyboard input?", + "Focus, activation, and semantic state are built into the primitives.", + ), + ]; + + Accordion::new("example-accordion") + .w(px(270.)) + .border_t_1() + .border_color(rgb(0xd4d4d4)) + .children( + items + .into_iter() + .enumerate() + .map(|(index, (question, answer))| { + let open = self.accordion_items[index]; + let entity = cx.entity().downgrade(); + AccordionItem::new() + .open(open) + .header(AccordionHeader::new( + AccordionTrigger::new(format!("accordion-trigger-{index}")) + .on_change(move |next, _, _, cx| { + _ = entity.update(cx, |this, cx| { + this.accordion_items[index] = next; + cx.notify(); + }); + }) + .w_full() + .flex() + .items_center() + .justify_between() + .h_7() + .border_b_1() + .border_color(rgb(0xd4d4d4)) + .text_xs() + .child(question) + .child(div().text_color(rgb(0x737373)).child(if open { + "−" + } else { + "+" + })), + )) + .panel( + AccordionPanel::new() + .px_1() + .py_1() + .border_b_1() + .border_color(rgb(0xd4d4d4)) + .text_xs() + .text_color(rgb(0x525252)) + .child(answer), + ) + }), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/alert_dialog.rs b/vendor/gpui-base/examples/showcase/components/alert_dialog.rs new file mode 100644 index 00000000..d5d30e19 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/alert_dialog.rs @@ -0,0 +1,128 @@ +use gpui::relative; + +use super::*; + +impl BaseShowcase { + pub(in super::super) fn alert_dialog(&self, cx: &mut Context) -> impl IntoElement { + let open = self.alert_dialog_open; + let entity = cx.entity().downgrade(); + let open_entity = entity.clone(); + let ok_entity = entity.clone(); + let cancel_entity = entity.clone(); + let action_entity = entity.clone(); + + div() + .child( + Button::new("open-alert-dialog") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .on_click(move |_, _, cx| { + _ = open_entity.update(cx, |this, cx| { + this.alert_dialog_open = true; + cx.notify(); + }); + }) + .child("Delete project"), + ) + .child( + AlertDialog::new(cx) + .open(open) + .on_open_change(move |open, _, _, cx| { + _ = entity.update(cx, |this, cx| { + this.alert_dialog_open = open; + cx.notify(); + }); + }) + .on_ok(move |_, _, cx| { + _ = ok_entity.update(cx, |this, cx| { + this.alert_dialog_open = false; + cx.notify(); + }); + true + }) + .backdrop( + AlertDialogBackdrop::new() + .absolute() + .inset_0() + .bg(rgb(0x000000)) + .opacity(0.18), + ) + .popup( + AlertDialogPopup::new() + .flex() + .items_center() + .justify_center() + .child( + div() + .w_72() + .p_3() + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0x171717)) + .child( + AlertDialogTitle::new() + .child("Delete project?"), + ) + .child( + AlertDialogDescription::new() + .mt_2() + .text_xs() + .text_color(rgb(0x525252)) + .child( + "This permanently deletes Acme Studio and all of its data.", + ), + ) + .child( + div() + .mt_3() + .flex() + .justify_end() + .gap_2() + .child(AlertDialogCancel::new().child( + Button::new("cancel-delete") + .px_3() + .h_7() + .flex() + .items_center() + .text_xs() + .border_1() + .border_color(rgb(0xd4d4d4)) + .on_click(move |_, _, cx| { + _ = cancel_entity.update(cx, |this, cx| { + this.alert_dialog_open = false; + cx.notify(); + }); + }) + .child("Cancel"), + )) + .child(AlertDialogAction::new().child( + Button::new("confirm-delete") + .px_3() + .h_7() + .flex() + .items_center() + .text_xs() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0x171717)) + .text_color(rgb(0xffffff)) + .on_click(move |_, _, cx| { + _ = action_entity.update(cx, |this, cx| { + this.alert_dialog_open = false; + cx.notify(); + }); + }) + .child("Delete"), + )), + ), + ), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/avatar.rs b/vendor/gpui-base/examples/showcase/components/avatar.rs new file mode 100644 index 00000000..8cbddeb1 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/avatar.rs @@ -0,0 +1,33 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn avatar(&self) -> impl IntoElement { + div().flex().items_start().gap_2().children( + [ + ("AM", 0xf5f5f5), + ("JL", 0xe5e5e5), + ("SK", 0xd4d4d4), + ("+3", 0xffffff), + ] + .into_iter() + .map(|(initials, background)| { + Avatar::new() + .size(px(34.)) + .overflow_hidden() + .border_1() + .border_color(rgb(0xa3a3a3)) + .fallback( + AvatarFallback::new() + .flex() + .size_8() + .items_center() + .justify_center() + .bg(rgb(background)) + .text_xs() + .text_color(rgb(0x262626)) + .child(initials), + ) + }), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/button.rs b/vendor/gpui-base/examples/showcase/components/button.rs new file mode 100644 index 00000000..0f8aab47 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/button.rs @@ -0,0 +1,41 @@ +use gpui::relative; + +use super::*; + +impl BaseShowcase { + pub(in super::super) fn button(&self) -> impl IntoElement { + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("primary-button") + .px_3() + .h_7() + .line_height(relative(1.)) + .flex() + .items_center() + .text_xs() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0x171717)) + .text_color(rgb(0xffffff)) + .hover(|style| style.bg(rgb(0x404040))) + .child("Save changes"), + ) + .child( + Button::new("secondary-button") + .px_3() + .h_7() + .line_height(relative(1.)) + .flex() + .items_center() + .text_xs() + .border_1() + .border_color(rgb(0xd4d4d4)) + .bg(rgb(0xffffff)) + .hover(|style| style.bg(rgb(0xf5f5f5))) + .child("Cancel"), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/calendar.rs b/vendor/gpui-base/examples/showcase/components/calendar.rs new file mode 100644 index 00000000..e175ca78 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/calendar.rs @@ -0,0 +1,78 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn calendar(&self) -> impl IntoElement { + Calendar::new("example-calendar", &self.calendar) + // 7 × 32px cells + 12px padding on each side + 1px borders. + .w(px(250.)) + .p_3() + .border_1() + .border_color(rgb(0xd4d4d4)) + .item(|item, state, _, _| { + match state.kind() { + CalendarItemKind::Previous | CalendarItemKind::Next => item + .size_7() + .flex() + .items_center() + .justify_center() + .hover(|s| s.bg(rgb(0xf5f5f5))), + CalendarItemKind::MonthToggle | CalendarItemKind::YearToggle => item + .px_1() + .h_7() + .flex() + .items_center() + .justify_center() + .text_xs() + .hover(|s| s.bg(rgb(0xf5f5f5))), + CalendarItemKind::Weekday => item + .size_8() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(rgb(0x737373)), + CalendarItemKind::Day => item + .size_8() + .flex() + .items_center() + .justify_center() + .text_xs() + .when(state.is_muted(), |s| s.text_color(rgb(0xa3a3a3))) + .when(state.is_today() && !state.is_active(), |s| { + s.border_1().border_color(rgb(0xd4d4d4)) + }) + .when(state.is_active(), |s| { + s.bg(rgb(0x171717)).text_color(rgb(0xffffff)) + }) + .when(!state.is_disabled() && !state.is_active(), |s| { + s.hover(|s| s.bg(rgb(0xf5f5f5))) + }), + CalendarItemKind::Month | CalendarItemKind::Year => item + .w(px(74.)) + .h_7() + .flex() + .items_center() + .justify_center() + .text_xs() + .when(state.is_active(), |s| { + s.bg(rgb(0x171717)).text_color(rgb(0xffffff)) + }) + .when(!state.is_active(), |s| s.hover(|s| s.bg(rgb(0xf5f5f5)))), + } + .into_any_element() + }) + .label(|kind, value| match kind { + CalendarItemKind::Previous => "‹".into(), + CalendarItemKind::Next => "›".into(), + CalendarItemKind::MonthToggle | CalendarItemKind::Month => [ + "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", + "Nov", "Dec", + ][value as usize] + .into(), + CalendarItemKind::Weekday => { + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][value as usize].into() + } + _ => value.to_string().into(), + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/checkbox.rs b/vendor/gpui-base/examples/showcase/components/checkbox.rs new file mode 100644 index 00000000..2f466066 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/checkbox.rs @@ -0,0 +1,43 @@ +use super::*; +use gpui::{Image, ImageFormat, img}; +use std::sync::Arc; + +const CHECK_SVG: &[u8] = br#""#; + +impl BaseShowcase { + pub(in super::super) fn checkbox(&self, cx: &mut Context) -> impl IntoElement { + let checked = self.checkbox_checked; + let entity = cx.entity().downgrade(); + Checkbox::new("example-checkbox") + .checked(checked) + .flex() + .items_center() + .gap_2() + .on_change(move |state, _, _, cx| { + _ = entity.update(cx, |this, cx| { + this.checkbox_checked = state == CheckboxState::Checked; + cx.notify(); + }); + }) + .child( + CheckboxIndicator::new() + .checked(checked) + .flex() + .items_center() + .justify_center() + .size_4() + .border_1() + .border_color(rgb(0x171717)) + .when(checked, |this| { + this.bg(rgb(0x171717)).child( + img(Arc::new(Image::from_bytes( + ImageFormat::Svg, + CHECK_SVG.to_vec(), + ))) + .size(px(12.)), + ) + }), + ) + .child(div().text_xs().child("Enable product updates")) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/collapsible.rs b/vendor/gpui-base/examples/showcase/components/collapsible.rs new file mode 100644 index 00000000..a99c67d1 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/collapsible.rs @@ -0,0 +1,59 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn collapsible(&self, cx: &mut Context) -> impl IntoElement { + let open = self.collapsible_open; + let entity = cx.entity().downgrade(); + Collapsible::new() + .open(open) + .w_64() + .child( + div() + .flex() + .items_center() + .justify_between() + .child(div().text_xs().child("@gpui/base · 3 repositories")) + .child( + Button::new("collapsible-trigger") + .size_7() + .border_1() + .border_color(rgb(0xd4d4d4)) + .flex() + .items_center() + .justify_center() + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.collapsible_open = !this.collapsible_open; + cx.notify(); + }); + }) + .child(if open { "−" } else { "+" }), + ), + ) + .child( + div() + .mt_2() + .px_2() + .h_7() + .flex() + .items_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .text_xs() + .child("gpui-component"), + ) + .content(div().mt_2().flex().flex_col().gap_2().children( + ["gpui-base", "gpui-storybook"].into_iter().map(|name| { + div() + .px_2() + .h_7() + .flex() + .items_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .text_xs() + .child(name) + }), + )) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/color_picker.rs b/vendor/gpui-base/examples/showcase/components/color_picker.rs new file mode 100644 index 00000000..afae966f --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/color_picker.rs @@ -0,0 +1,127 @@ +use super::*; +use gpui::{Focusable as _, Hsla, MouseButton}; + +impl BaseShowcase { + pub(in super::super) fn color_picker( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + // A builder-supplied default cannot reach the hex field and the sliders + // without a window, so flush it on the first render. + self.color_picker + .update(cx, |state, cx| state.sync_pending_value(window, cx)); + + let picker = self.color_picker.read(cx); + let open = picker.is_open(); + let selected = picker.value(); + let displayed = picker.displayed_color().unwrap_or(rgb(0x171717).into()); + let hex = picker.hex_input().read(cx).value(); + let focus_handle = picker.focus_handle(cx); + let hex_input = picker.hex_input().clone(); + let state = self.color_picker.clone(); + + let trigger_state = state.clone(); + let trigger = div() + .id("color-trigger") + .w_full() + .h_7() + .px_2() + .flex() + .items_center() + .gap_2() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .on_click(move |_, _, cx| { + trigger_state.update(cx, |state, cx| state.toggle_open(cx)); + }) + .child( + div() + .size(px(14.)) + .bg(displayed) + .border_1() + .border_color(rgb(0x171717)), + ) + .child(hex) + .child(div().flex_1()) + .child(if open { "⌃" } else { "⌄" }); + + let swatches = div().flex().gap_1().children( + [0xdc2626u32, 0xd97706, 0x16a34a, 0x2563eb, 0x7c3aed] + .into_iter() + .enumerate() + .map(|(index, value)| { + let color: Hsla = rgb(value).into(); + let hover_state = state.clone(); + let click_state = state.clone(); + ColorSwatch::new(("swatch", index), color) + .selected(selected == Some(color)) + .size(px(24.)) + .bg(color) + .border_1() + .border_color(if selected == Some(color) { + rgb(0x171717) + } else { + rgb(0xffffff) + }) + // Hovering previews without committing; leaving restores + // the committed color. + .on_hover(move |color, entered, window, cx| { + hover_state.update(cx, |state, cx| { + if entered { + state.preview_color(color, window, cx); + } else { + state.clear_preview(window, cx); + } + }); + }) + .on_click(move |color, _, window, cx| { + click_state + .update(cx, |state, cx| state.select_color(color, window, cx)); + }) + }), + ); + + let content = div() + .w(px(220.)) + .mt_1() + .p_2() + .flex() + .flex_col() + .gap_2() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .child(swatches) + .child( + InputBase::new("color-hex-input") + .w_full() + .h_7() + .px_2() + .flex() + .items_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .styles(|styles| styles.focused(|style| style.border_color(rgb(0x171717)))) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + hex_input.update(cx, |input, cx| input.focus(window, cx)); + }) + .child(picker.hex_input().clone()), + ); + + let open_state = state.clone(); + let root = ColorPicker::new("example-color-picker") + .open(open) + .track_focus(&focus_handle) + .accessibility_label("Brand color") + .on_open_change(move |open, _, cx| { + open_state.update(cx, |state, cx| state.set_open(open, cx)); + }) + .w(px(220.)) + .text_xs() + .child(trigger); + + Popup::new("example-color-picker-popup", root).when(open, |this| this.content(content)) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/combobox.rs b/vendor/gpui-base/examples/showcase/components/combobox.rs new file mode 100644 index 00000000..9f214a96 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/combobox.rs @@ -0,0 +1,103 @@ +use super::*; +use gpui::MouseButton; + +impl BaseShowcase { + pub(in super::super) fn combobox( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let open = self.combobox_open; + let query = self.combobox_query.read(cx).value().to_lowercase(); + let selected = self.combobox_selection.clone(); + let entity = cx.entity().downgrade(); + let query_state = self.combobox_query.clone(); + let open_query_state = self.combobox_query.clone(); + let trigger_entity = cx.entity().downgrade(); + let trigger_query_state = self.combobox_query.clone(); + + let combobox = Combobox::new("example-combobox") + .open(open) + .on_open_change(move |open, window, cx| { + _ = entity.update(cx, |this, cx| { + this.combobox_open = open; + cx.notify(); + }); + if open { + open_query_state.update(cx, |state, cx| state.focus(window, cx)); + } + }) + .w_56() + .child( + div() + .id("combobox-trigger") + .w_full() + .h_7() + .px_2() + .flex() + .items_center() + .justify_between() + .border_1() + .border_color(rgb(0xd4d4d4)) + .text_xs() + .bg(rgb(0xffffff)) + .on_click(move |_, window, cx| { + _ = trigger_entity.update(cx, |this, cx| { + this.combobox_open = !open; + cx.notify(); + }); + if !open { + trigger_query_state.update(cx, |state, cx| state.focus(window, cx)); + } + }) + .child(selected) + .child(div().text_color(rgb(0x737373)).child("⌄")), + ); + let popup = div() + .w_56() + .p_1() + .border_1() + .border_color(rgb(0xd4d4d4)) + .bg(rgb(0xffffff)) + .child( + InputBase::new("combobox-search") + .w_full() + .h_7() + .px_2() + .border_1() + .border_color(rgb(0xe5e5e5)) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + query_state.update(cx, |state, cx| state.focus(window, cx)); + }) + .child(self.combobox_query.clone()), + ) + .child( + div().mt_1().children( + ["GPUI", "React", "SwiftUI", "Vue"] + .into_iter() + .filter(|label| query.is_empty() || label.to_lowercase().contains(&query)) + .map(|label| { + let entity = cx.entity().downgrade(); + div() + .id(format!("combobox-{label}")) + .px_2() + .h_7() + .flex() + .items_center() + .text_xs() + .hover(|s| s.bg(rgb(0xf5f5f5))) + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.combobox_selection = label.into(); + this.combobox_open = false; + cx.notify(); + }); + }) + .child(label) + }), + ), + ); + + Popup::new("example-combobox-popup", combobox).when(open, |this| this.content(popup)) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/date_picker.rs b/vendor/gpui-base/examples/showcase/components/date_picker.rs new file mode 100644 index 00000000..6bb85e40 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/date_picker.rs @@ -0,0 +1,42 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn date_picker(&self, cx: &mut Context) -> impl IntoElement { + let open = self.date_open; + let entity = cx.entity().downgrade(); + let trigger_entity = entity.clone(); + let trigger = Button::new("date-trigger") + .w_full() + .h_7() + .px_3() + .flex() + .items_center() + .justify_between() + .border_1() + .border_color(rgb(0xa3a3a3)) + .bg(rgb(0xffffff)) + .on_click(move |_, _, cx| { + _ = trigger_entity.update(cx, |this, cx| { + this.date_open = !open; + cx.notify(); + }); + }) + .child("Aug 12, 2026") + .child("⌄"); + let popup = Popup::new("date-picker-popup", trigger).when(open, |this| { + this.content(div().w(px(250.)).bg(rgb(0xffffff)).child(self.calendar())) + }); + + DatePicker::new("example-date-picker", &self.date_focus) + .open(open) + .on_open_change(move |open, _, cx| { + _ = entity.update(cx, |this, cx| { + this.date_open = open; + cx.notify(); + }); + }) + .w(px(250.)) + .text_xs() + .child(popup) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/dialog.rs b/vendor/gpui-base/examples/showcase/components/dialog.rs new file mode 100644 index 00000000..031044f8 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/dialog.rs @@ -0,0 +1,141 @@ +use super::*; +use gpui::{MouseButton, relative}; + +impl BaseShowcase { + pub(in super::super) fn dialog(&self, cx: &mut Context) -> impl IntoElement { + let open = self.dialog_open; + let entity = cx.entity().downgrade(); + let open_entity = entity.clone(); + + div() + .child( + Button::new("open-dialog") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .on_click(move |_, _, cx| { + _ = open_entity.update(cx, |this, cx| { + this.dialog_open = true; + cx.notify(); + }); + }) + .child("Edit profile"), + ) + .child( + Dialog::new(cx) + .open(open) + .on_open_change(move |open, _, _, cx| { + _ = entity.update(cx, |this, cx| { + this.dialog_open = open; + cx.notify(); + }); + }) + .backdrop( + DialogBackdrop::new() + .absolute() + .inset_0() + .bg(rgb(0x000000)) + .opacity(0.2), + ) + .popup( + DialogPopup::new() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + div() + .w_72() + .p_3() + .flex() + .flex_col() + .items_stretch() + .text_xs() + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0xd4d4d4)) + .child( + DialogTitle::new() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Edit profile"), + ) + .child( + DialogDescription::new() + .mt_2() + .text_color(rgb(0x737373)) + .child( + "Update the public details shown on your profile.", + ), + ) + .child(div().mt_3().text_sm().child("Display name")) + .child( + InputBase::new("dialog-name") + .mt_2() + .w_full() + .h_7() + .px_2() + .border_1() + .border_color(rgb(0xd4d4d4)) + .on_mouse_down(MouseButton::Left, { + let input = self.input.clone(); + move |_, window, cx| { + input.update(cx, |state, cx| { + state.focus(window, cx) + }); + } + }) + .child(self.input.clone()), + ) + .child( + div() + .mt_3() + .flex() + .justify_end() + .gap_2() + .child( + gpui_base::DialogClose::new().child( + Button::new("dialog-cancel") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .child("Cancel"), + ), + ) + .child( + Button::new("dialog-save") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(rgb(0x171717)) + .text_color(rgb(0xffffff)) + .on_click({ + let entity = cx.entity().downgrade(); + move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.dialog_open = false; + cx.notify(); + }); + } + }) + .child("Save changes"), + ), + ), + ), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/dock.rs b/vendor/gpui-base/examples/showcase/components/dock.rs new file mode 100644 index 00000000..7c8025fa --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/dock.rs @@ -0,0 +1,478 @@ +use super::*; + +use gpui::{ + AnyElement, Axis, Div, Entity, EventEmitter, FocusHandle, Focusable, MouseButton, + MouseMoveEvent, MouseUpEvent, SharedString, Stateful, rgba, +}; +use std::cell::RefCell; + +const SURFACE: u32 = 0xffffff; +const CHROME: u32 = 0xf4f4f5; +const BORDER: u32 = 0xd4d4d8; +const MUTED: u32 = 0x71717a; +const ACCENT: u32 = 0x2563eb; +const DROP_TARGET: u32 = 0x2563eb33; + +const TAB_BAR_HEIGHT: gpui::Pixels = px(26.); +const RESIZE_STRIP: gpui::Pixels = px(4.); + +/// One dockable view. Its only obligation to base is a stable name; the title +/// and body are this example's own, and reach the skin through a downcast of +/// the handle base was given. +struct ShowcasePanel { + name: &'static str, + title: SharedString, + body: SharedString, + focus_handle: FocusHandle, +} + +impl ShowcasePanel { + fn new( + name: &'static str, + title: impl Into, + body: impl Into, + cx: &mut App, + ) -> Entity { + cx.new(|cx| Self { + name, + title: title.into(), + body: body.into(), + focus_handle: cx.focus_handle(), + }) + } +} + +impl Panel for ShowcasePanel { + fn panel_name(&self) -> &'static str { + self.name + } +} + +impl EventEmitter for ShowcasePanel {} + +impl Focusable for ShowcasePanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ShowcasePanel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_1() + .p_3() + .text_xs() + .child(div().child(self.title.clone())) + .child(div().text_color(rgb(MUTED)).child(self.body.clone())) + } +} + +/// The preview that follows the cursor while a tab is dragged. +/// +/// Base's own `DragPanel` renders nothing, because a preview is appearance. +struct DragPreview { + title: SharedString, +} + +impl Render for DragPreview { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .px_2() + .py_1() + .text_xs() + .bg(rgb(SURFACE)) + .text_color(rgb(ACCENT)) + .border_1() + .border_color(rgb(ACCENT)) + .child(self.title.clone()) + } +} + +/// A panel's title, recovered across the renderer seam. +/// +/// Base carries every panel as `Arc`, which knows its +/// `panel_name` and nothing else — a title is presentation, and base has no +/// opinion about it. A skin gets one back by downcasting to the concrete +/// handle base was handed. +fn panel_title(panel: &Arc, cx: &App) -> SharedString { + panel + .as_any() + .downcast_ref::>() + .map(|panel| panel.read(cx).title.clone()) + .unwrap_or_else(|| panel.panel_name(cx).into()) +} + +/// Everything this example draws. Base draws none of it. +#[derive(Clone, Default)] +struct ShowcaseDockSkin { + /// The dock a resize drag is currently sizing, captured on mouse down. + /// + /// A resize follows the pointer anywhere in the area, not only over the + /// strip, so the listener that tracks it sits on the area frame — which is + /// not handed a `DockContext`. The strip stashes its own here instead. + resizing: Rc>>, +} + +impl ShowcaseDockSkin { + /// The strip on a dock's inner edge that resizes it: a wide hit area with + /// a hairline inside, so the edge reads as a line rather than a bar. + fn render_resize_strip(&self, dock: &DockContext) -> impl IntoElement { + let placement = dock.placement(); + let dock = dock.clone(); + let resizing = self.resizing.clone(); + + div() + .absolute() + .flex() + .items_center() + .justify_center() + .map(|this| match placement { + DockPlacement::Left => this + .top_0() + .right_0() + .h_full() + .w(RESIZE_STRIP) + .cursor_col_resize(), + DockPlacement::Bottom => this + .top_0() + .left_0() + .w_full() + .h(RESIZE_STRIP) + .cursor_row_resize(), + _ => this + .top_0() + .left_0() + .h_full() + .w(RESIZE_STRIP) + .cursor_col_resize(), + }) + .child(div().bg(rgb(BORDER)).map(|line| match placement { + DockPlacement::Bottom => line.h(px(1.)).w_full(), + _ => line.w(px(1.)).h_full(), + })) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + *resizing.borrow_mut() = Some(dock.clone()); + }) + } +} + +impl DockAreaRenderer for ShowcaseDockSkin { + fn frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ + let dragging = self.resizing.clone(); + let finished = self.resizing.clone(); + + div() + .id("showcase-dock") + .size_full() + .flex() + .flex_row() + .overflow_hidden() + .bg(rgb(CHROME)) + .on_mouse_move(move |event: &MouseMoveEvent, window, cx| { + // Cloned out before the call, so the borrow is released before + // resizing reaches back into another frame reading this cell. + let dock = dragging.borrow().clone(); + let Some(dock) = dock else { + return; + }; + dock.resize_to(event.position, window, cx); + }) + .on_mouse_up(MouseButton::Left, move |_: &MouseUpEvent, _, _| { + finished.borrow_mut().take(); + }) + } + + fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id("showcase-dock-center") + .flex() + .flex_1() + .flex_col() + .overflow_hidden() + } + + fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id(("showcase-dock-split", node.as_u64())) + .size_full() + .flex_1() + .min_h(px(0.)) + .overflow_hidden() + } + + /// Only the paint: base keeps the hit area, the cursor and the drag. + fn render_split_handle( + &self, + handle: &ResizeHandleContext, + _: &mut Window, + _: &mut App, + ) -> Option { + Some( + div() + .bg(rgb(if handle.is_active() { ACCENT } else { BORDER })) + .map(|line| match handle.axis() { + Axis::Horizontal => line.w(px(1.)).h_full(), + Axis::Vertical => line.h(px(1.)).w_full(), + }) + .into_any_element(), + ) + } + + fn render_dock( + &self, + dock: &DockContext, + content: AnyElement, + _: &mut Window, + _: &mut App, + ) -> AnyElement { + // A closed dock takes no space; the toolbar is what brings it back. + if !dock.is_open() { + return div().into_any_element(); + } + + div() + .flex() + .flex_none() + .relative() + .overflow_hidden() + .map(|this| match dock.placement() { + DockPlacement::Bottom => this.w_full().h(dock.size()).flex_col(), + _ => this.h_full().w(dock.size()).flex_row(), + }) + .child(content) + .child(self.render_resize_strip(dock)) + .into_any_element() + } + + fn tab_group_renderer(&self) -> Rc { + Rc::new(self.clone()) + } + + fn tiles_renderer(&self) -> Rc { + Rc::new(self.clone()) + } +} + +impl TabGroupRenderer for ShowcaseDockSkin { + fn frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id("showcase-tab-group") + .size_full() + .flex() + .flex_col() + .min_h(px(0.)) + .overflow_hidden() + .bg(rgb(SURFACE)) + } + + fn content_frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ + // Relative, because the drop indicator is positioned against it. + div() + .id("showcase-tab-content") + .relative() + .flex_1() + .min_h(px(0.)) + .overflow_hidden() + } + + fn render_tab_bar(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> AnyElement { + div() + .flex() + .flex_row() + .items_center() + .h(TAB_BAR_HEIGHT) + .flex_none() + .overflow_hidden() + .bg(rgb(CHROME)) + .border_b_1() + .border_color(rgb(BORDER)) + .children( + group + .panels() + .iter() + .enumerate() + // A hidden panel keeps its place in the tree and its tab + // slot; it is the skin that leaves it undrawn. + .filter(|(_, panel)| panel.visible(cx)) + .map(|(ix, panel)| { + let selected = ix == group.active_ix(); + let title = panel_title(panel, cx); + div() + .id(("showcase-tab", ix)) + .px_2() + .h_full() + .flex() + .items_center() + .text_xs() + .cursor_pointer() + .map(|this| match selected { + true => this.bg(rgb(SURFACE)).text_color(rgb(ACCENT)), + false => this.text_color(rgb(MUTED)), + }) + .child(title.clone()) + .on_click({ + let group = group.clone(); + move |_, window, cx| group.select_tab(ix, window, cx) + }) + .when_some(group.drag_panel(ix, cx), |this, drag| { + this.on_drag(drag, move |_, _, _, cx| { + cx.new(|_| DragPreview { + title: title.clone(), + }) + }) + }) + }) + .collect::>(), + ) + .into_any_element() + } + + /// Base resolves where a drop would land; painting it is all that is left. + fn render_drop_indicator( + &self, + indicator: DropIndicator, + _: &mut Window, + _: &mut App, + ) -> Option { + let to = indicator.to(); + Some( + div() + .absolute() + .left(to.origin().x) + .top(to.origin().y) + .w(to.size().width) + .h(to.size().height) + .bg(rgba(DROP_TARGET)) + .into_any_element(), + ) + } +} + +/// This example builds no tiles canvas, so none of this is reached. A +/// `DockAreaRenderer` must still name a tiles renderer, because base builds one +/// for any `Tiles` node a layout — or a persisted file — happens to hold. +impl TilesRenderer for ShowcaseDockSkin { + fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement { + div().into_any_element() + } +} + +/// Build the area once, at showcase construction: a `DockArea` is an entity, +/// and rebuilding it every frame would discard the layout the viewer arranged. +pub(in super::super) fn build_dock(window: &mut Window, cx: &mut App) -> Entity { + let explorer = ShowcasePanel::new( + "Explorer", + "Explorer", + "Drag this tab into the other group to move it there.", + cx, + ); + let search = ShowcasePanel::new( + "Search", + "Search", + "Two panels share this tab group. Click a tab to switch.", + cx, + ); + let editor = ShowcasePanel::new( + "Editor", + "Editor", + "Drag a tab towards an edge of this group to split there.", + cx, + ); + let terminal = ShowcasePanel::new( + "Terminal", + "Terminal", + "The bottom dock shares the column with the center region.", + cx, + ); + let problems = ShowcasePanel::new("Problems", "Problems", "Nothing here.", cx); + + let area = cx.new(|cx| { + DockArea::new("showcase-dock", Some(1), window, cx) + .with_renderer(Rc::new(ShowcaseDockSkin::default())) + }); + + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child( + DockLayout::tabs().panel(explorer).panel(search), + Some(px(200.)), + ) + .child(DockLayout::tabs().panel(editor), None), + window, + cx, + ); + area.set_dock( + DockPlacement::Bottom, + DockLayout::tabs().panel(terminal).panel(problems), + window, + cx, + ); + area.set_dock_size(DockPlacement::Bottom, px(140.), window, cx); + }); + + area +} + +impl BaseShowcase { + /// A toggle for one dock, so a closed dock can be brought back. + fn dock_toggle( + &self, + placement: DockPlacement, + label: &'static str, + cx: &Context, + ) -> impl IntoElement { + let open = self.dock.read(cx).is_dock_open(placement); + let area = self.dock.clone(); + + div() + .id(label) + .px_2() + .py_1() + .text_xs() + .cursor_pointer() + .border_1() + .border_color(rgb(BORDER)) + .map(|this| match open { + true => this.bg(rgb(SURFACE)).text_color(rgb(ACCENT)), + false => this.text_color(rgb(MUTED)), + }) + .child(label) + .on_click(move |_, window, cx| { + area.update(cx, |area, cx| area.toggle_dock(placement, window, cx)); + }) + } + + pub(in super::super) fn dock(&self, cx: &Context) -> impl IntoElement { + // Fills whatever the showcase gives it — the surrounding container + // opts this example out of the centered, intrinsically-sized box the + // smaller parts use, so a percentage size resolves here. + div() + .size_full() + .flex() + .flex_col() + .overflow_hidden() + .border_1() + .border_color(rgb(BORDER)) + .child( + div() + .flex() + .flex_none() + .items_center() + .gap_2() + .p_2() + .bg(rgb(CHROME)) + .border_b_1() + .border_color(rgb(BORDER)) + .child(self.dock_toggle(DockPlacement::Bottom, "Bottom", cx)) + .child(div().text_xs().text_color(rgb(MUTED)).child( + "Drag a tab onto another group to merge it, or towards an edge to split", + )), + ) + .child(div().flex_1().min_h(px(0.)).child(self.dock.clone())) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/editor.rs b/vendor/gpui-base/examples/showcase/components/editor.rs new file mode 100644 index 00000000..9e7167a4 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/editor.rs @@ -0,0 +1,31 @@ +use super::*; +use gpui::MouseButton; + +impl BaseShowcase { + pub(in super::super) fn editor(&self) -> impl IntoElement { + let state = self.editor.clone(); + div() + .w_80() + .flex() + .flex_col() + .items_start() + .gap_1() + .text_xs() + .child(div().h(px(16.)).flex().items_center().child("Rust Editor")) + .child( + InputBase::new("example-editor") + .w_full() + .h_32() + .px_2() + .py_2() + .overflow_hidden() + .border_1() + .border_color(rgb(0xd4d4d4)) + .styles(|styles| styles.focused(|style| style.border_color(rgb(0x171717)))) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + }) + .child(div().size_full().child(Editor::new(&self.editor))), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/hover_card.rs b/vendor/gpui-base/examples/showcase/components/hover_card.rs new file mode 100644 index 00000000..3e285a70 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/hover_card.rs @@ -0,0 +1,59 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn hover_card(&self) -> impl IntoElement { + HoverCard::new("example-hover-card") + .trigger( + div() + .id("hover-trigger") + .px_3() + .py_1() + .text_xs() + .text_color(rgb(0x171717)) + .underline() + .child("Hover over gpui-base"), + ) + .content(|_, _, _| { + div() + .id("hover-content") + .w(px(210.)) + .p_2() + .text_xs() + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0xd4d4d4)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .size_7() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .text_sm() + .child("G"), + ) + .child( + div().text_sm().child("gpui-base").child( + div() + .text_sm() + .text_color(rgb(0x737373)) + .child("@gpui-base"), + ), + ), + ) + .child( + div() + .mt_2() + .text_sm() + .text_color(rgb(0x737373)) + .child("Unstyled primitives for GPUI."), + ) + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/input.rs b/vendor/gpui-base/examples/showcase/components/input.rs new file mode 100644 index 00000000..f4e79d41 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/input.rs @@ -0,0 +1,31 @@ +use super::*; +use gpui::MouseButton; + +impl BaseShowcase { + pub(in super::super) fn input(&self) -> impl IntoElement { + let state = self.input.clone(); + div() + .w_56() + .flex() + .flex_col() + .items_start() + .gap_1() + .text_xs() + .child(div().h(px(16.)).flex().items_center().child("Project name")) + .child( + InputBase::new("example-input") + .w_full() + .h_7() + .px_2() + .flex() + .items_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .styles(|styles| styles.focused(|style| style.border_color(rgb(0x171717)))) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + }) + .child(Input::new(&self.input)), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/link.rs b/vendor/gpui-base/examples/showcase/components/link.rs new file mode 100644 index 00000000..a7c5c4cc --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/link.rs @@ -0,0 +1,43 @@ +use gpui::{IntoElement, ParentElement as _, Styled as _, div, rgb}; +use gpui_base::Link; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn link(&self) -> impl IntoElement { + div() + .w_56() + .flex() + .flex_col() + .gap_2() + .text_xs() + .child("Navigation is application-owned") + .child( + Link::new("example-link") + .href("/base/primitives/link") + .open_with(|href, _, _, cx| cx.open_url(href)) + .h_7() + .px_3() + .py_0() + .flex() + .items_center() + .border_1() + .border_color(rgb(0x171717)) + .child("Open Link documentation →"), + ) + .child( + Link::new("disabled-link") + .href("/disabled") + .disabled(true) + .h_7() + .px_3() + .py_0() + .flex() + .items_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .text_color(rgb(0x737373)) + .child("Disabled destination"), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/mod.rs b/vendor/gpui-base/examples/showcase/components/mod.rs new file mode 100644 index 00000000..572f4fda --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/mod.rs @@ -0,0 +1,43 @@ +use super::*; + +mod accordion; +mod alert_dialog; +mod avatar; +mod button; +mod calendar; +mod checkbox; +mod collapsible; +mod color_picker; +mod combobox; +mod date_picker; +mod dialog; +mod dock; +pub(super) use dock::build_dock; +mod editor; +mod hover_card; +mod input; +mod link; +mod number_input; +mod otp_input; +mod pagination; +mod popover; +mod popup; +mod progress; +mod radio; +mod radio_group; +mod resizable; +mod scrollbar; +mod select; +mod sheet; +mod slider; +mod switch; +mod table; +mod tabs; +mod text_selection; +mod textarea; +mod toast; +mod toggle; +mod toggle_group; +mod tooltip; +mod tree; +mod virtual_list; diff --git a/vendor/gpui-base/examples/showcase/components/number_input.rs b/vendor/gpui-base/examples/showcase/components/number_input.rs new file mode 100644 index 00000000..aec2e483 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/number_input.rs @@ -0,0 +1,86 @@ +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement as _, Styled as _, div, px, + relative, rgb, +}; +use gpui_base::{Button, NumberInput}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn number_input(&self, cx: &mut Context) -> impl IntoElement { + let valid = self.input.read(cx).value().parse::().is_ok(); + + fn render_btn(this: Button, icon: AnyElement) -> Button { + this.w(px(24.)) + .flex_1() + .min_h_0() + .line_height(relative(1.)) + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .hover(|this| this.bg(gpui::black().opacity(0.8))) + .child(icon) + } + + fn minus_icon() -> AnyElement { + div() + .w(px(8.)) + .h(px(1.)) + .bg(gpui::white()) + .into_any_element() + } + + fn plus_icon() -> AnyElement { + div() + .relative() + .size(px(8.)) + .child( + div() + .absolute() + .top(px(3.5)) + .left_0() + .w_full() + .h(px(1.)) + .bg(gpui::white()), + ) + .child( + div() + .absolute() + .left(px(3.5)) + .top_0() + .h_full() + .w(px(1.)) + .bg(gpui::white()), + ) + .into_any_element() + } + + div() + .w(px(200.)) + .flex() + .flex_col() + .gap_1() + .text_xs() + .child(div().text_xs().child("Quantity")) + .child( + NumberInput::new(&self.input) + .controls_right() + .w_full() + .h_7() + .flex() + .items_center() + .border_1() + .border_color(if valid { rgb(0x171717) } else { rgb(0x737373) }) + .input(div().w_full().px_2().child(self.input.clone())) + .decrement_button(|button| render_btn(button, minus_icon())) + .increment_button(|button| render_btn(button, plus_icon())), + ) + .child(div().text_xs().text_color(rgb(0x737373)).child(if valid { + "Step: 1" + } else { + "Enter a number" + })) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/otp_input.rs b/vendor/gpui-base/examples/showcase/components/otp_input.rs new file mode 100644 index 00000000..02ef7894 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/otp_input.rs @@ -0,0 +1,46 @@ +use gpui::{Context, IntoElement, ParentElement as _, Styled as _, div, rgb}; +use gpui_base::OtpInput; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn otp_input(&self, cx: &mut Context) -> impl IntoElement { + let value: Vec = self.otp.read(cx).value().chars().collect(); + let active = value.len().min(5); + + div() + .w_56() + .flex() + .flex_col() + .gap_1() + .text_xs() + .child(div().text_xs().child("Verification code")) + .child( + div().child( + OtpInput::new(&self.otp) + .flex() + .gap_1() + .children((0..6).map(|ix| { + div() + .size_7() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(if ix == active { + rgb(0x171717) + } else { + rgb(0xd4d4d4) + }) + .child(value.get(ix).copied().unwrap_or(' ').to_string()) + })), + ), + ) + .child( + div() + .text_xs() + .text_color(rgb(0x737373)) + .child("Enter the 6-digit code."), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/pagination.rs b/vendor/gpui-base/examples/showcase/components/pagination.rs new file mode 100644 index 00000000..226ef44d --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/pagination.rs @@ -0,0 +1,54 @@ +use gpui::{ + Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, + rgb, +}; +use gpui_base::{Button, Pagination, PaginationItem, PaginationState}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn pagination(&self, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().downgrade(); + let state = PaginationState::new(self.page, 8).on_change(move |page, _, cx| { + _ = entity.update(cx, |this, cx| { + this.page = page; + cx.notify(); + }); + }); + let items = state.items(); + Pagination::new("example-pagination", state.clone()) + .flex() + .items_center() + .gap_2() + .text_xs() + .children(items.into_iter().map(move |item| { + match item { + PaginationItem::Page(page) => { + let state = state.clone(); + Button::new(("page", page)) + .size_7() + .p_0() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0xd4d4d4)) + .when(page == state.current_page(), |this| { + this.bg(rgb(0x171717)).text_color(rgb(0xffffff)) + }) + .on_click(move |_, window, cx| state.request_page(page, window, cx)) + .child(page.to_string()) + .into_any_element() + } + PaginationItem::Ellipsis(_) => div() + .w(px(20.)) + .h_7() + .flex() + .items_center() + .justify_center() + .child("…") + .into_any_element(), + } + })) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/popover.rs b/vendor/gpui-base/examples/showcase/components/popover.rs new file mode 100644 index 00000000..362a0ca4 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/popover.rs @@ -0,0 +1,62 @@ +use gpui::{ + InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div, relative, rgb, +}; +use gpui_base::{Button, Popover}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn popover(&self) -> impl IntoElement { + Popover::new("example-popover") + .trigger( + Button::new("popover-trigger") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .child("Open Popover"), + ) + .content(|_, _, cx| { + let state = cx.entity().downgrade(); + div() + .id("popover-content") + .w_64() + .p_2() + .flex() + .flex_col() + .gap_2() + .text_xs() + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0xd4d4d4)) + .child("Workspace access") + .child( + div() + .text_xs() + .text_color(rgb(0x737373)) + .child("Anyone with the link can view."), + ) + .child( + div().mt_1().flex().justify_end().child( + Button::new("popover-done") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .on_click(move |_, window, cx| { + _ = state.update(cx, |state, cx| state.dismiss(window, cx)); + }) + .child("Done"), + ), + ) + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/popup.rs b/vendor/gpui-base/examples/showcase/components/popup.rs new file mode 100644 index 00000000..38ae53ad --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/popup.rs @@ -0,0 +1,52 @@ +use gpui::{ + Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, + relative, rgb, +}; +use gpui_base::{Button, Popup}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn popup(&self, cx: &mut Context) -> impl IntoElement { + let open = self.popup_open; + let entity = cx.entity().downgrade(); + Popup::new( + "example-popup", + Button::new("popup-trigger") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.popup_open = !this.popup_open; + cx.notify(); + }); + }) + .child(if open { "Close popup" } else { "Open popup" }), + ) + .when(open, |this| { + this.content( + div() + .w_64() + .p_2() + .text_xs() + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0x171717)) + .child("Anchored surface") + .child( + div() + .mt_1() + .text_sm() + .text_color(rgb(0x737373)) + .child("Popup positions content relative to its trigger."), + ), + ) + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/progress.rs b/vendor/gpui-base/examples/showcase/components/progress.rs new file mode 100644 index 00000000..ddf1df31 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/progress.rs @@ -0,0 +1,63 @@ +use gpui::{IntoElement, ParentElement as _, Styled as _, div, px, rgb}; +use gpui_base::{Progress, ProgressIndicator, ProgressTrack}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn progress(&self) -> impl IntoElement { + div() + .w_64() + .flex() + .flex_col() + .gap_2() + .text_xs() + .child( + div() + .flex() + .justify_between() + .child("Uploading assets") + .child("68%"), + ) + .child( + Progress::new("example-progress").value(68.).child( + ProgressTrack::new() + .w_full() + .h(px(7.)) + .border_1() + .border_color(rgb(0x171717)) + .child( + ProgressIndicator::new() + .w(px(177.)) + .h_full() + .bg(rgb(0x171717)), + ), + ), + ) + .child( + div() + .flex() + .justify_between() + .text_sm() + .text_color(rgb(0x737373)) + .child("Optimizing bundle") + .child("32%"), + ) + .child( + Progress::new("example-progress-secondary") + .value(32.) + .child( + ProgressTrack::new() + .w_full() + .h(px(6.)) + .border_1() + .border_color(rgb(0xa3a3a3)) + .child( + ProgressIndicator::new() + .w(px(83.)) + .h_full() + .bg(rgb(0x737373)), + ), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/radio.rs b/vendor/gpui-base/examples/showcase/components/radio.rs new file mode 100644 index 00000000..a4fd0a70 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/radio.rs @@ -0,0 +1,49 @@ +use gpui::{ + Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, + rgb, +}; +use gpui_base::Radio; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn radio(&self, cx: &mut Context) -> impl IntoElement { + let checked = self.radio_selected == 0; + let entity = cx.entity().downgrade(); + Radio::new("example-radio") + .text_xs() + .checked(checked) + .on_change(move |next, _, _, cx| { + _ = entity.update(cx, |this, cx| { + if next { + this.radio_selected = 0; + } + cx.notify(); + }); + }) + .flex() + .items_start() + .gap_2() + .child( + div() + .mt(px(2.)) + .flex() + .items_center() + .justify_center() + .size(px(14.)) + .border_1() + .border_color(rgb(0x171717)) + .when(checked, |this| { + this.child(div().size(px(6.)).bg(rgb(0x171717))) + }), + ) + .child( + div().child("Standard").child( + div() + .text_xs() + .text_color(rgb(0x737373)) + .child("3–5 business days"), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/radio_group.rs b/vendor/gpui-base/examples/showcase/components/radio_group.rs new file mode 100644 index 00000000..644758a5 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/radio_group.rs @@ -0,0 +1,76 @@ +use gpui::{ + Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, + rgb, +}; +use gpui_base::{Radio, RadioGroup}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn radio_group(&self, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().downgrade(); + RadioGroup::new("example-radio-group") + .w_56() + .text_xs() + .flex() + .flex_col() + .gap_2() + .child(self.radio(cx)) + .child( + Radio::new("express-radio") + .checked(self.radio_selected == 1) + .on_change(move |next, _, _, cx| { + if next { + _ = entity.update(cx, |this, cx| { + this.radio_selected = 1; + cx.notify(); + }); + } + }) + .flex() + .items_start() + .gap_2() + .child( + div() + .mt(px(2.)) + .flex() + .items_center() + .justify_center() + .size(px(14.)) + .border_1() + .border_color(rgb(0x171717)) + .when(self.radio_selected == 1, |this| { + this.child(div().size(px(6.)).bg(rgb(0x171717))) + }), + ) + .child( + div().child("Express").child( + div() + .text_xs() + .text_color(rgb(0x737373)) + .child("Next business day"), + ), + ), + ) + .child( + Radio::new("pickup-radio") + .disabled(true) + .flex() + .items_start() + .gap_2() + .opacity(0.45) + .child( + div() + .mt(px(2.)) + .size(px(14.)) + .border_1() + .border_color(rgb(0x171717)), + ) + .child( + div() + .child("Local pickup") + .child(div().text_xs().child("Currently unavailable")), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/resizable.rs b/vendor/gpui-base/examples/showcase/components/resizable.rs new file mode 100644 index 00000000..dd236341 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/resizable.rs @@ -0,0 +1,73 @@ +use gpui::{IntoElement, ParentElement as _, Styled as _, div, px, rgb}; +use gpui_base::{h_resizable, resizable_panel}; + +use super::super::BaseShowcase; + +impl BaseShowcase { + pub(in super::super) fn resizable(&self) -> impl IntoElement { + div() + .w_72() + .h_40() + .text_xs() + .border_1() + .border_color(rgb(0x171717)) + .child( + h_resizable("example-resizable") + .child( + resizable_panel() + .size(px(124.)) + .size_range(px(116.)..px(210.)) + .child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .border_r_1() + .border_color(rgb(0x171717)) + .p_2() + .items_start() + .justify_start() + .flex_col() + .gap_1() + .child( + div().text_xs().text_color(rgb(0x737373)).child("PROJECT"), + ) + .children(["Overview", "Components", "Settings"].map( + |label| { + div() + .w_full() + .h(px(26.)) + .px_2() + .flex() + .items_center() + .whitespace_nowrap() + .child(label) + }, + )), + ), + ) + .child( + resizable_panel().child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(rgb(0xffffff)) + .p_2() + .items_start() + .justify_start() + .flex_col() + .gap_2() + .child(div().child("Workspace")) + .child( + div() + .text_color(rgb(0x737373)) + .child("Drag the divider to resize navigation."), + ), + ), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/scrollbar.rs b/vendor/gpui-base/examples/showcase/components/scrollbar.rs new file mode 100644 index 00000000..45b593c9 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/scrollbar.rs @@ -0,0 +1,29 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn scrollbar(&self) -> impl IntoElement { + div() + .id("example-scroll-region") + .relative() + .w_72() + .h_48() + .text_xs() + .border_1() + .border_color(rgb(0x171717)) + .overflow_scroll() + .track_scroll(&self.example_scroll) + .child(div().children((1..=20).map(|row| { + div() + .h_7() + .px_2() + .flex() + .items_center() + .border_b_1() + .border_color(rgb(0xe5e7eb)) + .justify_between() + .child(format!("Activity {row}")) + .child(if row % 3 == 0 { "Completed" } else { "Pending" }) + }))) + .child(Scrollbar::new(&self.example_scroll).mode(ScrollbarMode::Always)) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/select.rs b/vendor/gpui-base/examples/showcase/components/select.rs new file mode 100644 index 00000000..86e563c7 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/select.rs @@ -0,0 +1,85 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn select( + &self, + combobox: bool, + cx: &mut Context, + ) -> impl IntoElement { + let open = self.select_open; + let selected = self.select_index.min(3); + let labels = ["GPUI", "React", "SwiftUI", "Vue"]; + let entity = cx.entity().downgrade(); + let trigger_entity = entity.clone(); + let trigger = div() + .id("select-trigger") + .h_7() + .px_2() + .text_xs() + .flex() + .items_center() + .justify_between() + .border_1() + .border_color(rgb(0x171717)) + .on_click(move |_, _, cx| { + _ = trigger_entity.update(cx, |this, cx| { + this.select_open = !open; + cx.notify(); + }); + }) + .child(labels[selected]) + .child(if open { "⌃" } else { "⌄" }); + let options = div() + .mt_1() + .p_1() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .children(labels.into_iter().enumerate().map(|(ix, label)| { + let entity = entity.clone(); + div() + .id(("select-option", ix)) + .px_2() + .py_1() + .flex() + .justify_between() + .hover(|this| this.bg(rgb(0xf5f5f5))) + .child(label) + .when(ix == selected, |this| this.child("✓")) + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.select_index = ix; + this.select_open = false; + cx.notify(); + }); + }) + })); + if combobox { + let root = Combobox::new("example-combobox") + .open(open) + .w_56() + .child(trigger); + Popup::new("example-combobox-options", root) + .when(open, |this| this.content(options)) + .into_any_element() + } else { + let root = Select::new("example-select") + .open(open) + .on_open_change({ + let entity = entity.clone(); + move |next, _, cx| { + _ = entity.update(cx, |this, cx| { + this.select_open = next; + cx.notify(); + }); + } + }) + .accessibility_label("Framework") + .w_56() + .child(trigger); + Popup::new("example-select-options", root) + .when(open, |this| this.content(options)) + .into_any_element() + } + } +} diff --git a/vendor/gpui-base/examples/showcase/components/sheet.rs b/vendor/gpui-base/examples/showcase/components/sheet.rs new file mode 100644 index 00000000..f913f4ce --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/sheet.rs @@ -0,0 +1,119 @@ +use gpui::relative; + +use super::*; + +impl BaseShowcase { + pub(in super::super) fn sheet(&self, cx: &mut Context) -> impl IntoElement { + let open = self.sheet_open; + let entity = cx.entity().downgrade(); + let open_sheet = entity.clone(); + let trigger = Button::new("open-sheet") + .h_7() + .px_2() + .text_xs() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .child("Open settings") + .on_click(move |_, _, cx| { + _ = open_sheet.update(cx, |this, cx| { + this.sheet_open = true; + cx.notify(); + }); + }); + + div() + .size_full() + .min_h_64() + .text_xs() + .flex() + .items_center() + .justify_center() + .child(trigger) + .when(open, |this| { + this.child( + Sheet::new(cx) + .request_close({ + let entity = entity.clone(); + move |_, cx| { + _ = entity.update(cx, |this, cx| { + this.sheet_open = false; + cx.notify(); + }); + } + }) + .overlay(div().absolute().inset_0().bg(rgb(0x000000)).opacity(0.15)) + .surface( + div() + .absolute() + .right_0() + .top_0() + .h_full() + .w(px(210.)) + .p_3() + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0x171717)) + .child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Settings"), + ) + .child( + div().mt_4().child("Workspace name").child( + div() + .mt_1() + .h_7() + .px_2() + .flex() + .items_center() + .border_1() + .border_color(rgb(0xa3a3a3)) + .child("Acme Studio"), + ), + ) + .child( + div() + .mt_2() + .text_color(rgb(0x525252)) + .child("Update the workspace preferences for your team."), + ) + .child( + div() + .mt_4() + .py_1() + .border_t_1() + .border_color(rgb(0xd4d4d4)) + .child("Notifications · Enabled"), + ) + .child( + div().mt_3().flex().justify_end().child( + Button::new("close-sheet") + .h_7() + .line_height(relative(1.)) + .px_3() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .text_color(gpui::white()) + .child("Done") + .on_click({ + let entity = entity.clone(); + move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.sheet_open = false; + cx.notify(); + }); + } + }), + ), + ), + ), + ) + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/slider.rs b/vendor/gpui-base/examples/showcase/components/slider.rs new file mode 100644 index 00000000..debba5ee --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/slider.rs @@ -0,0 +1,65 @@ +use super::*; +use gpui::relative; + +impl BaseShowcase { + pub(in super::super) fn slider(&self, cx: &mut Context) -> impl IntoElement { + let percentage = self.slider.read(cx).percentage().end; + let thumb_size = 14.; + div() + .w_56() + .text_xs() + .child( + div() + .mb_2() + .flex() + .justify_between() + .child("Volume") + .child("Drag to adjust"), + ) + .child( + Slider::new(&self.slider).w_full().h_7().child( + SliderTrack::new(&self.slider) + .relative() + .w_full() + .h_full() + .child( + div() + .absolute() + .top(px(13.)) + .left_0() + .w_full() + .h(px(2.)) + .bg(rgb(0xd4d4d4)), + ) + .child( + SliderIndicator::new(&self.slider) + .absolute() + .top(px(13.)) + .left_0() + .w_full() + .h(px(2.)) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .right(relative(1. - percentage)) + .bg(rgb(0x171717)), + ), + ) + .child( + SliderThumb::new(&self.slider) + .absolute() + .top(px(7.)) + .left(relative(percentage)) + .ml(px(-thumb_size / 2.)) + .size(px(thumb_size)) + .bg(rgb(0xffffff)) + .border_1() + .border_color(rgb(0x171717)), + ), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/switch.rs b/vendor/gpui-base/examples/showcase/components/switch.rs new file mode 100644 index 00000000..56a80038 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/switch.rs @@ -0,0 +1,51 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn switch(&self, cx: &mut Context) -> impl IntoElement { + let checked = self.switch_checked; + let entity = cx.entity().downgrade(); + div() + .w_64() + .text_xs() + .flex() + .items_center() + .justify_between() + .child( + div().child("Automatic updates").child( + div() + .mt_1() + .text_xs() + .text_color(rgb(0x737373)) + .child("Install stable releases automatically."), + ), + ) + .child( + Switch::new("example-switch") + .checked(checked) + .on_change(move |next, _, _, cx| { + _ = entity.update(cx, |this, cx| { + this.switch_checked = next; + cx.notify(); + }); + }) + .child( + SwitchTrack::new("example-switch-track") + .checked(checked) + .w(px(36.)) + .h(px(20.)) + .p(px(2.)) + .bg(if checked { + rgb(0x171717) + } else { + rgb(0xd4d4d4) + }) + .child( + SwitchThumb::new(checked) + .size_4() + .bg(rgb(0xffffff)) + .ml(if checked { px(16.) } else { px(0.) }), + ), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/table.rs b/vendor/gpui-base/examples/showcase/components/table.rs new file mode 100644 index 00000000..a5e455c4 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/table.rs @@ -0,0 +1,86 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn table(&self) -> impl IntoElement { + Table::new("example-table") + .w_72() + .text_xs() + .border_1() + .border_color(rgb(0xe5e7eb)) + .overflow_hidden() + .child( + TableHeader::new("header").child( + TableRow::new("header-row", 1) + .flex() + .bg(rgb(0xf5f5f5)) + .child( + TableHead::new("name-head", 1) + .w(px(124.)) + .px_2() + .py_1() + .child("Component"), + ) + .child( + TableHead::new("status-head", 2) + .w(px(84.)) + .px_2() + .py_1() + .child("Status"), + ) + .child( + TableHead::new("version-head", 3) + .w(px(92.)) + .px_2() + .py_1() + .child("Version"), + ), + ), + ) + .child( + TableBody::new("body").children( + [ + ("gpui-base", "Stable", "0.4.1"), + ("gpui-component", "Active", "0.4.1"), + ("story-web", "Preview", "0.2.8"), + ("gpui-web", "Beta", "0.1.0"), + ] + .into_iter() + .enumerate() + .map(|(ix, (name, status, version))| { + TableRow::new(("body-row", ix), ix) + .flex() + .border_t_1() + .border_color(rgb(0xe5e7eb)) + .child( + TableCell::new("name", 1) + .w(px(124.)) + .px_2() + .py_1() + .child(name), + ) + .child( + TableCell::new(("status", ix), 2) + .w(px(84.)) + .px_2() + .py_1() + .child( + div() + .px_1() + .border_1() + .border_color(rgb(0xd4d4d4)) + .child(status), + ), + ) + .child( + TableCell::new(("version", ix), 3) + .w(px(92.)) + .px_2() + .py_1() + .text_color(rgb(0x737373)) + .child(version), + ) + }), + ), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/tabs.rs b/vendor/gpui-base/examples/showcase/components/tabs.rs new file mode 100644 index 00000000..e6ee391b --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/tabs.rs @@ -0,0 +1,72 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn tabs(&self, cx: &mut Context) -> impl IntoElement { + let selected = self.selected_tab; + div() + .w_72() + .text_xs() + .border_1() + .border_color(rgb(0xd4d4d4)) + .child( + Tabs::new("example-tabs") + .flex() + .px_2() + .pt_1() + .border_b_1() + .border_color(rgb(0xd4d4d4)) + .children( + ["Overview", "Activity", "Settings"] + .into_iter() + .enumerate() + .map(|(index, label)| { + let entity = cx.entity().downgrade(); + Tab::new(index) + .selected(self.selected_tab == index) + .px_2() + .h_7() + .flex() + .items_center() + .border_b_2() + .border_color(if self.selected_tab == index { + rgb(0x171717) + } else { + rgb(0xffffff) + }) + .when(self.selected_tab == index, |this| { + this.font_weight(gpui::FontWeight::SEMIBOLD) + }) + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.selected_tab = index; + cx.notify(); + }); + }) + .child(label) + }), + ), + ) + .child( + div().min_h_20().p_3().child(match selected { + 0 => div().child("Workspace overview").child( + div() + .mt_1() + .text_color(rgb(0x737373)) + .child("12 components · 4 contributors · updated today"), + ), + 1 => div().child("Recent activity").child( + div() + .mt_1() + .text_color(rgb(0x737373)) + .child("Button example was updated 8 minutes ago."), + ), + _ => div().child("Project settings").child( + div() + .mt_1() + .text_color(rgb(0x737373)) + .child("Manage notifications and member access."), + ), + }), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/text_selection.rs b/vendor/gpui-base/examples/showcase/components/text_selection.rs new file mode 100644 index 00000000..4a09cbc4 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/text_selection.rs @@ -0,0 +1,359 @@ +use std::ops::Range; + +use gpui::{ + App, BorderStyle, Bounds, Context, Corners, Edges, Element, ElementId, GlobalElementId, Hitbox, + InspectorElementId, IntoElement, LayoutId, PaintQuad, ParentElement as _, Pixels, Point, + SharedString, Styled as _, StyledText, Window, transparent_black, +}; +#[cfg(test)] +use gpui_base::ElementExt as _; +use gpui_base::{TextSelection, TextSelectionHandle, TextSelectionRegistration, TextSelectionRun}; + +use super::*; + +const PRODUCT_PARAGRAPH: &str = "Selection should feel like a natural part of reading a product brief. Start in this paragraph, continue into the next renderer, and GPUI preserves the document order while every frame supplies fresh geometry for the same stable selection handle."; +const IMPLEMENTATION_PARAGRAPH: &str = "This second paragraph is deliberately long enough to wrap in the showcase. Drag across the boundary to see one continuous highlight, then use the platform copy shortcut to confirm that the copied result follows the visible reading order rather than renderer ownership."; +const INTERNATIONAL_PARAGRAPH: &str = "International text should remain predictable when a line mixes café, déjà vu, Kraków, naïve, and résumé. Resize the window or drag across several wrapped lines; UTF-8 byte ranges still map back to the correct glyphs without splitting a character."; + +struct PlainSelectableText { + selection: TextSelectionHandle, + text: SharedString, + styled_text: StyledText, + document_order: u64, +} + +fn selection_quad_bounds( + start: Point, + end: Point, + bounds: Bounds, + line_height: Pixels, +) -> Vec> { + if start.y == end.y { + return vec![Bounds::from_corners( + start, + Point::new(end.x, end.y + line_height), + )]; + } + + let mut quads = vec![Bounds::from_corners( + start, + Point::new(bounds.right(), start.y + line_height), + )]; + if end.y > start.y + line_height { + quads.push(Bounds::from_corners( + Point::new(bounds.left(), start.y + line_height), + Point::new(bounds.right(), end.y), + )); + } + quads.push(Bounds::from_corners( + Point::new(bounds.left(), end.y), + Point::new(end.x, end.y + line_height), + )); + quads +} + +impl PlainSelectableText { + fn new( + selection: TextSelectionHandle, + document_order: u64, + text: impl Into, + ) -> Self { + let text = text.into(); + Self { + selection, + styled_text: StyledText::new(text.clone()), + text, + document_order, + } + } + + fn paint_selection(layout: &gpui::TextLayout, range: Range, window: &mut Window) { + let (Some(start), Some(end)) = ( + layout.position_for_index(range.start), + layout.position_for_index(range.end), + ) else { + return; + }; + let color = gpui::hsla(0.58, 0.85, 0.62, 0.35); + for bounds in selection_quad_bounds(start, end, layout.bounds(), layout.line_height()) { + window.paint_quad(PaintQuad { + bounds, + background: color.into(), + corner_radii: Corners::default(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::default(), + }); + } + } +} + +impl IntoElement for PlainSelectableText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for PlainSelectableText { + type RequestLayoutState = (); + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + self.styled_text + .request_layout(id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.styled_text + .prepaint(id, inspector_id, bounds, &mut (), window, cx); + let hitbox = window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal); + self.selection.register( + TextSelectionRegistration::new(hitbox.clone(), bounds) + .with_document_order(self.document_order) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + hitbox + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let layout = self.styled_text.layout().clone(); + let selected_text_before = TextSelection::selected_text(window, cx); + let projection = self.selection.update_runs( + &[ + TextSelectionRun::new(self.text.clone(), layout.clone(), bounds) + .with_document_order(0), + ], + cx, + ); + if selected_text_before != TextSelection::selected_text(window, cx) { + window.refresh(); + } + if let Some(range) = projection + .ranges() + .iter() + .next() + .and_then(|range| range.clone()) + { + Self::paint_selection(&layout, range, window); + } + self.styled_text + .paint(id, inspector_id, bounds, &mut (), &mut (), window, cx); + } +} + +impl BaseShowcase { + pub(in super::super) fn text_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + self.text_selection_active = TextSelection::has_selection(window, cx); + self.text_selection_text = TextSelection::selected_text(window, cx); + + let active = self.text_selection_active; + let selected_text = if active { + self.text_selection_text.clone() + } else { + "Drag across any paragraphs to select text.".to_owned() + }; + let entity = cx.entity().downgrade(); + + let footer = div() + .id("text-selection-footer") + .h(px(150.)) + .flex_none() + .flex() + .flex_col() + .gap_2() + .p_3() + .bg(rgb(0xf5f5f5)) + .border_1() + .border_color(rgb(0xe5e5e5)) + .child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(if active { + "Selection active" + } else { + "No selection" + }), + ) + .child( + div() + .id("text-selection-preview") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .text_color(rgb(0x525252)) + .child(selected_text), + ) + .child( + Button::new("clear-text-selection") + .h_7() + .px_2() + .flex() + .items_center() + .justify_center() + .self_start() + .border_1() + .border_color(rgb(0x171717)) + .child("Clear selection") + .on_click(move |_, window, cx| { + TextSelection::clear(window, cx); + _ = entity.update(cx, |this, cx| { + this.text_selection_active = false; + this.text_selection_text.clear(); + cx.notify(); + }); + }), + ); + #[cfg(test)] + let footer = { + let bounds = self.text_selection_footer_bounds.clone(); + footer.on_prepaint(move |value, _, _| *bounds.borrow_mut() = Some(value)) + }; + + div() + .id("text-selection-example") + .w(px(620.)) + .max_w_full() + .h(px(520.)) + .max_h_full() + .flex() + .flex_col() + .gap_3() + .child( + div() + .id("text-selection-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.text_selection_scroll) + .flex() + .flex_col() + .gap_3() + .p_4() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(PlainSelectableText::new( + self.text_selection_handles[0].clone(), + 0, + "Text selection across renderers", + )), + ) + .child(div().text_color(rgb(0x525252)).line_height(px(22.)).child( + PlainSelectableText::new( + self.text_selection_handles[1].clone(), + 1, + PRODUCT_PARAGRAPH, + ), + )) + .child(div().text_color(rgb(0x525252)).line_height(px(22.)).child( + PlainSelectableText::new( + self.text_selection_handles[2].clone(), + 2, + IMPLEMENTATION_PARAGRAPH, + ), + )) + .child(div().text_color(rgb(0x525252)).line_height(px(22.)).child( + PlainSelectableText::new( + self.text_selection_handles[3].clone(), + 3, + INTERNATIONAL_PARAGRAPH, + ), + )), + ) + .child(footer) + } +} + +#[cfg(test)] +mod tests { + use super::selection_quad_bounds; + use gpui::{Bounds, TestAppContext, point, px, size}; + + use crate::showcase::BaseShowcase; + + #[gpui::test] + fn text_selection_footer_stays_fixed_when_document_scrolls(cx: &mut TestAppContext) { + let (view, window) = + cx.add_window_view(|window, cx| BaseShowcase::new("text-selection", window, cx)); + window.update(|window, cx| window.draw(cx).clear(cx)); + + let (footer_bounds, scroll) = view.read_with(window, |view, _| { + ( + view.text_selection_footer_bounds + .borrow() + .expect("footer should be painted"), + view.text_selection_scroll.clone(), + ) + }); + scroll.set_offset(point(px(0.), px(-80.))); + view.update(window, |_, cx| cx.notify()); + window.update(|window, cx| window.draw(cx).clear(cx)); + + let scrolled_footer_bounds = view.read_with(window, |view, _| { + view.text_selection_footer_bounds + .borrow() + .expect("footer should be painted after scrolling") + }); + assert_eq!(scrolled_footer_bounds, footer_bounds); + } + + #[test] + fn wrapped_selection_paints_full_width_middle_lines() { + let bounds = Bounds::new(point(px(10.), px(20.)), size(px(100.), px(100.))); + let quads = selection_quad_bounds( + point(px(40.), px(20.)), + point(px(30.), px(80.)), + bounds, + px(20.), + ); + + assert_eq!( + quads, + vec![ + Bounds::from_corners(point(px(40.), px(20.)), point(px(110.), px(40.))), + Bounds::from_corners(point(px(10.), px(40.)), point(px(110.), px(80.))), + Bounds::from_corners(point(px(10.), px(80.)), point(px(30.), px(100.))), + ] + ); + } +} diff --git a/vendor/gpui-base/examples/showcase/components/textarea.rs b/vendor/gpui-base/examples/showcase/components/textarea.rs new file mode 100644 index 00000000..ed340249 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/textarea.rs @@ -0,0 +1,31 @@ +use super::*; +use gpui::MouseButton; + +impl BaseShowcase { + pub(in super::super) fn textarea(&self) -> impl IntoElement { + let state = self.textarea.clone(); + div() + .w_56() + .flex() + .flex_col() + .items_start() + .gap_1() + .text_xs() + .child(div().h(px(16.)).flex().items_center().child("Textarea")) + .child( + InputBase::new("example-textarea") + .w_full() + .h_16() + .px_2() + .py_2() + .overflow_hidden() + .border_1() + .border_color(rgb(0xd4d4d4)) + .styles(|styles| styles.focused(|style| style.border_color(rgb(0x171717)))) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + }) + .child(div().size_full().child(Textarea::new(&self.textarea))), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/toast.rs b/vendor/gpui-base/examples/showcase/components/toast.rs new file mode 100644 index 00000000..df75d95c --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/toast.rs @@ -0,0 +1,84 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn toast(&self, cx: &mut Context) -> impl IntoElement { + let visible = self.toast_visible; + let entity = cx.entity().downgrade(); + div() + .w_72() + .h(px(158.)) + .text_xs() + .relative() + .flex() + .items_center() + .justify_center() + .child( + Button::new("show-toast") + .h_7() + .px_2() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .child("Save changes") + .on_click({ + let show_entity = entity.clone(); + move |_, _, cx| { + _ = show_entity.update(cx, |this, cx| { + this.toast_visible = true; + cx.notify(); + }); + } + }), + ) + .when(visible, |this| { + this.child( + Toast::new("example-toast") + .transition_status(ToastTransitionStatus::Present) + .absolute() + .right_0() + .bottom_0() + .w_64() + .p_2() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .child( + div() + .flex() + .justify_between() + .child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Changes saved"), + ) + .child( + Button::new("dismiss-toast") + .size_6() + .flex() + .items_center() + .justify_center() + .child("×") + .on_click({ + let entity = entity.clone(); + move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.toast_visible = false; + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .mt_1() + .text_color(rgb(0x737373)) + .child("Your preferences are now up to date."), + ), + ) + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/toggle.rs b/vendor/gpui-base/examples/showcase/components/toggle.rs new file mode 100644 index 00000000..7840bc29 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/toggle.rs @@ -0,0 +1,29 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn toggle(&self, cx: &mut Context) -> impl IntoElement { + let pressed = self.toggle_pressed; + let entity = cx.entity().downgrade(); + Toggle::new("example-toggle") + .pressed(pressed) + .on_change(move |next, _, _, cx| { + _ = entity.update(cx, |this, cx| { + this.toggle_pressed = next; + cx.notify(); + }); + }) + .size_7() + .text_xs() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .when(pressed, |this| { + this.bg(rgb(0x171717)).text_color(rgb(0xffffff)) + }) + .font_weight(gpui::FontWeight::BOLD) + .accessibility_label("Bold") + .child("B") + } +} diff --git a/vendor/gpui-base/examples/showcase/components/toggle_group.rs b/vendor/gpui-base/examples/showcase/components/toggle_group.rs new file mode 100644 index 00000000..f8791e77 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/toggle_group.rs @@ -0,0 +1,69 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn toggle_group(&self, cx: &mut Context) -> impl IntoElement { + let italic = self.toggle_group_selection & 1 != 0; + let underline = self.toggle_group_selection & 2 != 0; + let entity = cx.entity().downgrade(); + ToggleGroup::new("example-toggle-group") + .flex() + .text_xs() + .gap_0() + .child(self.toggle(cx)) + .child( + Toggle::new("italic-toggle") + .pressed(italic) + .size_7() + .flex() + .items_center() + .justify_center() + .border_1() + .border_l_0() + .border_color(rgb(0x171717)) + .when(italic, |this| { + this.bg(rgb(0x171717)).text_color(rgb(0xffffff)) + }) + .accessibility_label("Italic") + .child("I") + .on_change({ + let entity = entity.clone(); + move |next, _, _, cx| { + _ = entity.update(cx, |this, cx| { + if next { + this.toggle_group_selection |= 1 + } else { + this.toggle_group_selection &= !1 + }; + cx.notify(); + }); + } + }), + ) + .child( + Toggle::new("underline-toggle") + .pressed(underline) + .size_7() + .flex() + .items_center() + .justify_center() + .border_1() + .border_l_0() + .border_color(rgb(0x171717)) + .when(underline, |this| { + this.bg(rgb(0x171717)).text_color(rgb(0xffffff)) + }) + .accessibility_label("Underline") + .child("U") + .on_change(move |next, _, _, cx| { + _ = entity.update(cx, |this, cx| { + if next { + this.toggle_group_selection |= 2 + } else { + this.toggle_group_selection &= !2 + }; + cx.notify(); + }); + }), + ) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/tooltip.rs b/vendor/gpui-base/examples/showcase/components/tooltip.rs new file mode 100644 index 00000000..a617dcc9 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/tooltip.rs @@ -0,0 +1,46 @@ +use super::*; + +impl BaseShowcase { + pub(in super::super) fn tooltip(&self, cx: &mut Context) -> impl IntoElement { + let visible = self.tooltip_visible; + let entity = cx.entity().downgrade(); + let trigger = div() + .id("tooltip-trigger") + .on_hover(move |hovered, _, cx| { + _ = entity.update(cx, |this, cx| { + this.tooltip_visible = *hovered; + cx.notify(); + }); + }) + .child( + Button::new("tooltip-anchor") + .h_7() + .px_2() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0xffffff)) + .child("Command menu"), + ); + + Popup::new("example-tooltip-popup", trigger) + .text_xs() + .when(visible, |this| { + this.content( + Tooltip::new("example-tooltip") + .px_2() + .h_7() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .bg(rgb(0x171717)) + .text_color(rgb(0xffffff)) + .child("Open command menu · ⌘K"), + ) + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/tree.rs b/vendor/gpui-base/examples/showcase/components/tree.rs new file mode 100644 index 00000000..b04a7e1e --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/tree.rs @@ -0,0 +1,58 @@ +use super::*; +use gpui::{Image, ImageFormat, StyleRefinement, img}; +use std::sync::Arc; + +const CHEVRON_RIGHT_SVG: &[u8] = br##""##; +const CHEVRON_DOWN_SVG: &[u8] = br##""##; + +impl BaseShowcase { + pub(in super::super) fn tree(&self) -> impl IntoElement { + Tree::new(&self.tree) + .w_64() + .h_48() + .list_style(StyleRefinement::default().flex_grow_1().size_full()) + .relative() + .text_sm() + .border_1() + .border_color(rgb(0xd4d4d4)) + .py_1() + .item(|_, entry, state, _, _| { + let depth = entry.depth(); + let icon = entry.is_folder().then(|| { + let bytes = if entry.is_expanded() { + CHEVRON_DOWN_SVG + } else { + CHEVRON_RIGHT_SVG + }; + img(Arc::new(Image::from_bytes( + ImageFormat::Svg, + bytes.to_vec(), + ))) + .size_3() + .flex_none() + }); + div() + .h_8() + .mx_1() + .px_2() + .flex() + .items_center() + .gap_1() + .when(state.is_selected(), |this| this.bg(rgb(0xf0f0f0))) + .when(depth > 0, |this| { + this.child(div().flex_none().w(px(depth as f32 * 12.))) + }) + .child( + div() + .size_3() + .flex_none() + .flex() + .items_center() + .justify_center() + .children(icon), + ) + .child(entry.item().label.clone()) + .into_any_element() + }) + } +} diff --git a/vendor/gpui-base/examples/showcase/components/virtual_list.rs b/vendor/gpui-base/examples/showcase/components/virtual_list.rs new file mode 100644 index 00000000..1bc28c66 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/components/virtual_list.rs @@ -0,0 +1,62 @@ +use super::*; + +const ITEM_COUNT: usize = 100_000; + +impl BaseShowcase { + pub(in super::super) fn virtual_list(&self, cx: &mut Context) -> impl IntoElement { + let sizes = Rc::new(vec![size(px(280.), px(32.)); ITEM_COUNT]); + div() + .relative() + .w_72() + .h_48() + .overflow_hidden() + .border_1() + .border_color(rgb(0x171717)) + .child( + v_virtual_list( + cx.entity(), + "example-virtual-list", + sizes, + |_, range, _, _| { + range + .map(|ix| { + div() + .w_full() + .h_8() + .px_2() + .text_xs() + .flex() + .items_center() + .border_b_1() + .border_color(gpui::black()) + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .size(px(18.)) + .flex_none() + .flex() + .items_center() + .justify_center() + .line_height(px(18.)) + .border_1() + .border_color(gpui::black()) + .child(format!("{}", (ix % 9) + 1)), + ) + .child(format!("Customer {:06}", ix + 1)), + ) + .child(format!("ID-{:06}", 100_000 + ix)) + }) + .collect() + }, + ) + .track_scroll(&self.virtual_scroll) + .size_full(), + ) + .child(Scrollbar::vertical(&self.virtual_scroll).mode(ScrollbarMode::Always)) + } +} diff --git a/vendor/gpui-base/examples/showcase/mod.rs b/vendor/gpui-base/examples/showcase/mod.rs new file mode 100644 index 00000000..150a1f9c --- /dev/null +++ b/vendor/gpui-base/examples/showcase/mod.rs @@ -0,0 +1,579 @@ +mod components; +mod syntect_highlighter; + +use gpui::{ + App, AppContext as _, Application, Context, InteractiveElement as _, IntoElement, + ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, Styled as _, Window, + WindowOptions, actions, div, prelude::FluentBuilder as _, px, rgb, size, +}; +#[cfg(not(target_family = "wasm"))] +use gpui::{KeyBinding, WindowBounds}; +use gpui_base::ResizeHandleContext; +use gpui_base::dock::{ + DockArea, DockAreaRenderer, DockContext, DockLayout, DockPlacement, DropIndicator, NodeId, + Panel, PanelEvent, PanelView, TabGroupContext, TabGroupRenderer, TileContext, TilesRenderer, +}; +use gpui_base::input::InputEditorStyle; +use gpui_base::input::{EditorState, InputState, TextareaState}; +use gpui_base::slider::SliderState; +use gpui_base::{ + Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger, AlertDialog, + AlertDialogAction, AlertDialogBackdrop, AlertDialogCancel, AlertDialogDescription, + AlertDialogPopup, AlertDialogTitle, AutoScroll, Avatar, AvatarFallback, Button, Calendar, + CalendarItemKind, CalendarState, Checkbox, CheckboxIndicator, CheckboxState, Collapsible, + ColorPicker, ColorPickerState, ColorSwatch, Combobox, DatePicker, Dialog, DialogBackdrop, + DialogDescription, DialogPopup, DialogTitle, Editor, HoverCard, Input, InputBase, OtpState, + Popup, Scrollbar, ScrollbarMode, Select, Sheet, Slider, SliderIndicator, SliderThumb, + SliderTrack, Switch, SwitchThumb, SwitchTrack, Tab, Table, TableBody, TableCell, TableHead, + TableHeader, TableRow, Tabs, TextSelectionEvent, TextSelectionHandle, TextSelectionLayer, + Textarea, Toast, ToastTransitionStatus, Toggle, ToggleGroup, Tooltip, Tree, TreeItem, + TreeState, VirtualListScrollHandle, v_virtual_list, +}; +#[cfg(target_family = "wasm")] +use std::borrow::Cow; +use std::{rc::Rc, sync::Arc}; +use syntect_highlighter::{ShowcaseHighlightStyles, SyntectHighlighter}; + +actions!(base_showcase, [Quit]); + +const EDITOR_EXAMPLE: &str = r#"use std::collections::HashMap; + +#[derive(Debug, Clone)] +struct Workspace { + name: String, + files: HashMap, +} + +impl Workspace { + fn new(name: impl Into) -> Self { + Self { + name: name.into(), + files: HashMap::new(), + } + } + + fn index(&mut self, path: &str, lines: usize) { + // Keep the latest line count for each source file. + self.files.insert(path.to_owned(), lines); + } + + fn summary(&self) -> String { + let total: usize = self.files.values().sum(); + format!("{}: {} files, {total} lines", self.name, self.files.len()) + } +} + +fn main() { + let mut workspace = Workspace::new("gpui-component"); + workspace.index("src/main.rs", 128); + workspace.index("src/editor.rs", 372); + println!("{}", workspace.summary()); +} +"#; + +pub const COMPONENTS: &[&str] = &[ + "accordion", + "alert-dialog", + "avatar", + "button", + "calendar", + "checkbox", + "collapsible", + "color-picker", + "combobox", + "date-picker", + "dialog", + "dock", + "editor", + "hover-card", + "input", + "link", + "number-input", + "otp-input", + "pagination", + "popover", + "popup", + "progress", + "radio", + "radio-group", + "resizable", + "scrollbar", + "select", + "sheet", + "slider", + "switch", + "table", + "tabs", + "text-selection", + "textarea", + "toast", + "toggle", + "toggle-group", + "tooltip", + "tree", + "virtual-list", +]; + +pub struct BaseShowcase { + component: String, + navigation_enabled: bool, + checkbox_checked: bool, + radio_selected: usize, + switch_checked: bool, + toggle_pressed: bool, + toggle_group_selection: u8, + selected_tab: usize, + select_open: bool, + select_index: usize, + sheet_open: bool, + toast_visible: bool, + tooltip_visible: bool, + accordion_items: [bool; 3], + alert_dialog_open: bool, + collapsible_open: bool, + combobox_open: bool, + combobox_query: gpui::Entity, + combobox_selection: String, + color_picker: gpui::Entity, + date_open: bool, + dialog_open: bool, + popup_open: bool, + page: usize, + slider: gpui::Entity, + input: gpui::Entity, + textarea: gpui::Entity, + editor: gpui::Entity, + otp: gpui::Entity, + calendar: gpui::Entity, + tree: gpui::Entity, + date_focus: gpui::FocusHandle, + scroll: ScrollHandle, + example_scroll: ScrollHandle, + virtual_scroll: VirtualListScrollHandle, + dock: gpui::Entity, + text_selection_handles: [TextSelectionHandle; 4], + text_selection_scroll: ScrollHandle, + text_selection_auto_scroll: AutoScroll, + text_selection_active: bool, + text_selection_text: String, + #[cfg(test)] + text_selection_footer_bounds: Rc>>>, +} + +impl BaseShowcase { + pub fn new(component: impl Into, window: &mut Window, cx: &mut Context) -> Self { + let component = component.into(); + let input = cx.new(|cx| { + let mut state = InputState::new(window, cx) + .placeholder("Type something…") + .default_value(if component == "number-input" { + "12" + } else { + "Hello GPUI" + }); + state.set_editor_style(InputEditorStyle { + foreground: rgb(0x171717).into(), + muted_foreground: rgb(0x737373).into(), + selection: gpui::hsla(0.6, 0.8, 0.7, 0.45), + caret: rgb(0x171717).into(), + ..InputEditorStyle::default() + }); + state + }); + let otp = cx.new(|cx| OtpState::new(6, window, cx).default_value("12")); + let textarea = cx.new(|cx| { + TextareaState::new(window, cx) + .rows(3) + .default_value("Build focused interfaces.\nKeep behavior composable.") + }); + let textarea_base = textarea.clone(); + textarea_base.update(cx, |state, _| { + state.set_editor_style(InputEditorStyle { + foreground: rgb(0x171717).into(), + muted_foreground: rgb(0x737373).into(), + selection: gpui::hsla(0.6, 0.8, 0.7, 0.45), + caret: rgb(0x171717).into(), + ..InputEditorStyle::default() + }); + }); + let editor = cx.new(|cx| { + EditorState::new(window, cx) + .language("rust") + .line_number(true) + .folding(true) + .show_whitespaces(true) + .default_value(EDITOR_EXAMPLE) + }); + let editor_base = editor.clone(); + editor_base.update(cx, |state, cx| { + state.set_highlighter_factory( + Rc::new(|language| { + SyntectHighlighter::new(language) + .map(|highlighter| Box::new(highlighter) as Box<_>) + }), + cx, + ); + state.set_editor_style(InputEditorStyle { + foreground: rgb(0x171717).into(), + muted_foreground: rgb(0x737373).into(), + selection: gpui::hsla(0.6, 0.8, 0.7, 0.45), + caret: rgb(0x171717).into(), + highlight_styles: Arc::new(ShowcaseHighlightStyles), + ..InputEditorStyle::default() + }); + }); + let combobox_query = cx.new(|cx| { + let mut state = InputState::new(window, cx).placeholder("Search frameworks…"); + state.set_editor_style(InputEditorStyle { + foreground: rgb(0x171717).into(), + muted_foreground: rgb(0x737373).into(), + selection: gpui::hsla(0.6, 0.8, 0.7, 0.45), + caret: rgb(0x171717).into(), + ..InputEditorStyle::default() + }); + state + }); + cx.subscribe( + &combobox_query, + |_, _, _: &gpui_base::input::InputEvent, cx| cx.notify(), + ) + .detach(); + if matches!(component.as_str(), "input" | "number-input") { + input.update(cx, |state, cx| state.focus(window, cx)); + } else if component == "textarea" { + textarea.update(cx, |state, cx| state.focus(window, cx)); + } else if component == "editor" { + editor.update(cx, |state, cx| state.focus(window, cx)); + } else if component == "otp-input" { + otp.update(cx, |state, cx| state.focus(window, cx)); + } + + let slider = cx.new(|_| SliderState::new().min(0.).max(100.).default_value(64.)); + cx.observe(&slider, |_, _, cx| cx.notify()).detach(); + + let color_picker = + cx.new(|cx| ColorPickerState::new(window, cx).default_value(rgb(0x2563eb))); + cx.observe(&color_picker, |_, _, cx| cx.notify()).detach(); + + let text_selection_handles = [ + TextSelectionHandle::new("", cx), + TextSelectionHandle::new("", cx), + TextSelectionHandle::new("", cx), + TextSelectionHandle::new("", cx), + ]; + let text_selection_scroll = ScrollHandle::new(); + for selection in &text_selection_handles { + selection.refresh_window_on_change(window, cx).detach(); + let view = cx.entity().downgrade(); + selection + .subscribe( + move |event, cx| { + let TextSelectionEvent::AutoScroll(delta) = event else { + return; + }; + let delta = *delta; + _ = view.update(cx, |this, cx| { + this.text_selection_auto_scroll + .set(delta, cx, |delta, this, cx| { + let offset = this.text_selection_scroll.offset(); + this.text_selection_scroll + .set_offset(gpui::point(offset.x, offset.y - delta)); + cx.notify(); + }); + }); + }, + cx, + ) + .detach(); + } + + Self { + navigation_enabled: component == "overview", + component, + checkbox_checked: true, + radio_selected: 0, + switch_checked: true, + toggle_pressed: true, + toggle_group_selection: 0, + selected_tab: 0, + select_open: false, + select_index: 0, + sheet_open: false, + toast_visible: false, + tooltip_visible: false, + accordion_items: [true, false, false], + alert_dialog_open: false, + collapsible_open: false, + combobox_open: false, + combobox_query, + combobox_selection: "Select framework".into(), + color_picker, + date_open: false, + dialog_open: false, + popup_open: false, + page: 3, + slider, + input, + textarea, + editor, + otp, + calendar: cx.new(|cx| CalendarState::new(window, cx)), + tree: cx.new(|cx| { + TreeState::new(cx).items(vec![ + TreeItem::new("src", "src").expanded(true).children(vec![ + TreeItem::new("components", "components") + .expanded(true) + .children(vec![ + TreeItem::new("button", "button.rs"), + TreeItem::new("tree-file", "tree.rs"), + ]), + TreeItem::new("lib", "lib.rs"), + ]), + TreeItem::new("examples", "examples") + .children(vec![TreeItem::new("showcase", "showcase.rs")]), + TreeItem::new("cargo", "Cargo.toml"), + ]) + }), + date_focus: cx.focus_handle(), + scroll: ScrollHandle::new(), + example_scroll: ScrollHandle::new(), + virtual_scroll: VirtualListScrollHandle::new(), + dock: components::build_dock(window, cx), + text_selection_handles, + text_selection_scroll, + text_selection_auto_scroll: AutoScroll::default(), + text_selection_active: false, + text_selection_text: String::new(), + #[cfg(test)] + text_selection_footer_bounds: Rc::new(std::cell::RefCell::new(None)), + } + } + + fn overview(&self, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().downgrade(); + div() + .w(px(720.)) + .max_w_full() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("GPUI Base"), + ) + .child( + div() + .text_sm() + .text_color(rgb(0x737373)) + .child("Choose a component to open its interactive example."), + ), + ) + .child(div().w_full().grid().grid_cols(3).gap_1().children( + COMPONENTS.iter().enumerate().map(|(ix, name)| { + let entity = entity.clone(); + Button::new(("overview-item", ix)) + .h_9() + .px_3() + .flex() + .items_center() + .justify_start() + .border_1() + .border_color(rgb(0xd4d4d4)) + .bg(rgb(0xffffff)) + .text_xs() + .child(*name) + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.component = (*name).to_owned(); + cx.notify(); + }); + }) + }), + )) + } +} + +impl Render for BaseShowcase { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = match self.component.as_str() { + "accordion" => self.accordion(cx).into_any_element(), + "alert-dialog" => self.alert_dialog(cx).into_any_element(), + "avatar" => self.avatar().into_any_element(), + "button" => self.button().into_any_element(), + "calendar" => self.calendar().into_any_element(), + "checkbox" => self.checkbox(cx).into_any_element(), + "collapsible" => self.collapsible(cx).into_any_element(), + "color-picker" => self.color_picker(window, cx).into_any_element(), + "combobox" => self.combobox(window, cx).into_any_element(), + "date-picker" => self.date_picker(cx).into_any_element(), + "dialog" => self.dialog(cx).into_any_element(), + "editor" => self.editor().into_any_element(), + "hover-card" => self.hover_card().into_any_element(), + "input" => self.input().into_any_element(), + "link" => self.link().into_any_element(), + "number-input" => self.number_input(cx).into_any_element(), + "otp-input" => self.otp_input(cx).into_any_element(), + "pagination" => self.pagination(cx).into_any_element(), + "popover" => self.popover().into_any_element(), + "popup" => self.popup(cx).into_any_element(), + "progress" => self.progress().into_any_element(), + "radio" => self.radio(cx).into_any_element(), + "radio-group" => self.radio_group(cx).into_any_element(), + "resizable" => self.resizable().into_any_element(), + "scrollbar" => self.scrollbar().into_any_element(), + "slider" => self.slider(cx).into_any_element(), + "select" => self.select(false, cx).into_any_element(), + "sheet" => self.sheet(cx).into_any_element(), + "switch" => self.switch(cx).into_any_element(), + "table" => self.table().into_any_element(), + "tabs" => self.tabs(cx).into_any_element(), + "text-selection" => self.text_selection(window, cx).into_any_element(), + "textarea" => self.textarea().into_any_element(), + "toast" => self.toast(cx).into_any_element(), + "toggle" => self.toggle(cx).into_any_element(), + "toggle-group" => self.toggle_group(cx).into_any_element(), + "tooltip" => self.tooltip(cx).into_any_element(), + "tree" => self.tree().into_any_element(), + "dock" => self.dock(cx).into_any_element(), + "virtual-list" => self.virtual_list(cx).into_any_element(), + _ => self.overview(cx).into_any_element(), + }; + let show_back = self.navigation_enabled && self.component != "overview"; + // Surfaces rather than parts: these take the whole viewport. + let fills_viewport = matches!(self.component.as_str(), "dock"); + let entity = cx.entity().downgrade(); + div() + .size_full() + .flex() + .flex_col() + .bg(rgb(0xffffff)) + .text_color(rgb(0x171717)) + .text_xs() + .font_family("Inter Variable") + .child(TextSelectionLayer) + .when(show_back, |this| { + this.child( + div() + .h_10() + .flex_none() + .px_3() + .flex() + .items_center() + .border_b_1() + .border_color(rgb(0xe5e5e5)) + .child( + Button::new("back-to-overview") + .h_7() + .px_2() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(rgb(0x171717)) + .child("All components") + .on_click(move |_, _, cx| { + _ = entity.update(cx, |this, cx| { + this.component = "overview".to_owned(); + cx.notify(); + }); + }), + ), + ) + }) + .child( + div() + .id("showcase-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scroll) + .child( + div() + .min_h_full() + .w_full() + .flex() + // Most examples are small parts, centered in the + // viewport. A few are whole surfaces that have to + // fill it instead: centering them inside a + // `flex_none` box leaves a percentage size with + // nothing to resolve against, and it collapses. + .when(!fills_viewport, |this| { + this.items_center().justify_center() + }) + .p_4() + .child( + div() + .map(|this| match fills_viewport { + true => this.flex_1().size_full().min_h(px(420.)), + false => this.flex_none(), + }) + .child(content), + ), + ), + ) + } +} + +pub fn run(app: Application, component: impl Into) { + let component = component.into(); + app.run(move |cx: &mut App| { + gpui_base::init(cx); + #[cfg(not(target_family = "wasm"))] + { + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.on_window_closed(|cx, _| { + if cx.windows().is_empty() { + cx.quit(); + } + }) + .detach(); + } + #[cfg(target_family = "wasm")] + cx.text_system() + .add_fonts(vec![Cow::Borrowed( + include_bytes!("../../../story-web/fonts/Inter-Regular.ttf").as_slice(), + )]) + .expect("failed to load gpui-base example font"); + let options = WindowOptions { + #[cfg(not(target_family = "wasm"))] + window_bounds: Some(WindowBounds::centered(size(px(840.), px(640.)), cx)), + ..WindowOptions::default() + }; + cx.open_window(options, move |window, cx| { + cx.new(|cx| BaseShowcase::new(component, window, cx)) + }) + .expect("failed to open gpui-base example window"); + cx.activate(true); + }); +} + +#[cfg(target_family = "wasm")] +pub fn run_embedded(app: Application, component: impl Into) -> gpui::ApplicationHandle { + let component = component.into(); + app.run_embedded(move |cx: &mut App| { + gpui_base::init(cx); + cx.text_system() + .add_fonts(vec![Cow::Borrowed( + include_bytes!("../../../story-web/fonts/Inter-Regular.ttf").as_slice(), + )]) + .expect("failed to load gpui-base example font"); + cx.open_window(WindowOptions::default(), move |window, cx| { + cx.new(|cx| BaseShowcase::new(component, window, cx)) + }) + .expect("failed to open gpui-base example window"); + cx.activate(true); + }) +} + +#[cfg(not(target_family = "wasm"))] +pub fn run_native(component: &str) { + run(gpui_platform::application(), component.to_owned()); +} diff --git a/vendor/gpui-base/examples/showcase/syntect_highlighter.rs b/vendor/gpui-base/examples/showcase/syntect_highlighter.rs new file mode 100644 index 00000000..66f7e565 --- /dev/null +++ b/vendor/gpui-base/examples/showcase/syntect_highlighter.rs @@ -0,0 +1,264 @@ +use std::{collections::HashMap, ops::Range, sync::LazyLock}; + +use gpui::{Context, HighlightStyle, SharedString, Window, rgb}; +use gpui_base::input::{ + EditorState, FoldRange, HighlightStyleResolver, InputEdit, InputHighlighter, Rope, +}; +use syntect::{ + parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet}, + util::LinesWithEndings, +}; + +/// Loading the default syntax definitions deserializes a few megabytes, so share +/// one set across every highlighter instance. +static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); + +/// A small, WASM-compatible example adapter for Base's parser-independent +/// highlighting API. Syntect identifies scopes; the application-owned resolver +/// supplies all colors and font styles. +pub(super) struct SyntectHighlighter { + language: SharedString, + /// Non-overlapping highlights, ordered by start offset. + highlights: Vec<(Range, &'static str)>, + fold_ranges: Vec, + /// Scope ids are cheap to compare but expensive to stringify, so remember + /// the semantic name each one maps to. + semantic_names: HashMap>, +} + +impl SyntectHighlighter { + pub(super) fn new(language: &str) -> Option { + find_syntax(language)?; + + Some(Self { + language: language.to_owned().into(), + highlights: Vec::new(), + fold_ranges: Vec::new(), + semantic_names: HashMap::new(), + }) + } + + fn push_highlight(&mut self, range: Range, scopes: &ScopeStack) { + if range.is_empty() { + return; + } + + let name = scopes.scopes.iter().rev().find_map(|scope| { + *self + .semantic_names + .entry(*scope) + .or_insert_with(|| semantic_name(*scope)) + }); + if let Some(name) = name { + self.highlights.push((range, name)); + } + } +} + +fn find_syntax(language: &str) -> Option<&'static SyntaxReference> { + SYNTAX_SET + .find_syntax_by_token(language) + .or_else(|| SYNTAX_SET.find_syntax_by_extension(language)) +} + +impl InputHighlighter for SyntectHighlighter { + fn language(&self) -> SharedString { + self.language.clone() + } + + fn update( + &mut self, + _edit: Option, + text: &Rope, + folding: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + // `syntect` has no incremental mode, so the whole document is reparsed. + // Read the rope once and reuse that string for folding too. + let text = text.to_string(); + let syntax = find_syntax(self.language.as_ref()) + .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + let mut parser = ParseState::new(syntax); + let mut scopes = ScopeStack::new(); + let mut offset = 0; + self.highlights.clear(); + + for line in LinesWithEndings::from(&text) { + if let Ok(operations) = parser.parse_line(line, &SYNTAX_SET) { + let mut cursor = 0; + for (index, operation) in operations { + self.push_highlight(offset + cursor..offset + index, &scopes); + let _ = scopes.apply(&operation); + cursor = index; + } + self.push_highlight(offset + cursor..offset + line.len(), &scopes); + } + offset += line.len(); + } + + self.fold_ranges = if folding { + brace_fold_ranges(&text) + } else { + Vec::new() + }; + } + + fn styles( + &self, + range: &Range, + resolver: &dyn HighlightStyleResolver, + ) -> Vec<(Range, HighlightStyle)> { + resolve_styles(&self.highlights, range, resolver) + } + + fn fold_ranges(&self, _: &Rope) -> Vec { + self.fold_ranges.clone() + } + + fn fold_ranges_for_edit(&self, _: Range, _: &Rope) -> Vec { + self.fold_ranges.clone() + } +} + +/// Turn the highlights overlapping `range` into gap-free style runs. +/// +/// `highlights` is ordered and non-overlapping, so the first candidate is found +/// by binary search instead of scanning the whole document on every frame. +fn resolve_styles( + highlights: &[(Range, &'static str)], + range: &Range, + resolver: &dyn HighlightStyleResolver, +) -> Vec<(Range, HighlightStyle)> { + let first = highlights.partition_point(|(highlight, _)| highlight.end <= range.start); + let mut runs = Vec::new(); + let mut cursor = range.start; + + for (highlight_range, name) in &highlights[first..] { + if highlight_range.start >= range.end { + break; + } + + let start = highlight_range.start.max(range.start); + let end = highlight_range.end.min(range.end); + if start >= end || end <= cursor { + continue; + } + if cursor < start { + runs.push((cursor..start, HighlightStyle::default())); + } + runs.push((start..end, resolver.style(name).unwrap_or_default())); + cursor = end; + } + + if cursor < range.end { + runs.push((cursor..range.end, HighlightStyle::default())); + } + runs +} + +fn semantic_name(scope: Scope) -> Option<&'static str> { + let scope = scope.build_string(); + let name = if scope.starts_with("comment") { + "comment" + } else if scope.starts_with("constant.character.escape") { + "string.escape" + } else if scope.starts_with("string") { + "string" + } else if scope.starts_with("constant.numeric") { + "number" + } else if scope.starts_with("constant.language.boolean") { + "boolean" + } else if scope.starts_with("keyword.operator") { + "operator" + } else if scope.starts_with("keyword") || scope.starts_with("storage") { + "keyword" + } else if scope.starts_with("entity.name.function") || scope.starts_with("support.function") { + "function" + } else if scope.starts_with("entity.name.type") + || scope.starts_with("entity.name.class") + || scope.starts_with("support.type") + { + "type" + } else if scope.starts_with("variable") { + "variable" + } else if scope.starts_with("constant") { + "constant" + } else if scope.starts_with("punctuation") { + "punctuation" + } else { + return None; + }; + Some(name) +} + +fn brace_fold_ranges(text: &str) -> Vec { + let mut starts = Vec::new(); + let mut ranges = Vec::new(); + + for (line_number, line) in text.lines().enumerate() { + let mut chars = line.chars().peekable(); + let mut quoted = false; + let mut escaped = false; + while let Some(character) = chars.next() { + if !quoted && character == '/' && chars.peek() == Some(&'/') { + break; + } + if character == '"' && !escaped { + quoted = !quoted; + } else if !quoted && character == '{' { + starts.push(line_number); + } else if !quoted && character == '}' { + if let Some(start_line) = starts.pop() { + if start_line < line_number { + ranges.push(FoldRange::new(start_line, line_number)); + } + } + } + escaped = quoted && character == '\\' && !escaped; + if character != '\\' { + escaped = false; + } + } + } + ranges +} + +#[derive(Default)] +pub(super) struct ShowcaseHighlightStyles; + +impl HighlightStyleResolver for ShowcaseHighlightStyles { + fn style(&self, name: &str) -> Option { + let color = match name.split('.').next()? { + "comment" => 0x007fff, + "string" => 0x036a07, + "number" | "keyword" => 0x0433ff, + "boolean" | "constant" => 0xc5060b, + "function" => 0x0000a2, + "type" => 0x6f42c1, + "variable" => 0x333333, + _ => return None, + }; + Some(HighlightStyle { + color: Some(rgb(color).into()), + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_styles_cover_the_requested_range_without_gaps() { + let runs = resolve_styles( + &[(2..4, "keyword"), (6..8, "string")], + &(0..10), + &ShowcaseHighlightStyles, + ); + let ranges: Vec<_> = runs.into_iter().map(|(range, _)| range).collect(); + + assert_eq!(ranges, vec![0..2, 2..4, 4..6, 6..8, 8..10]); + } +} diff --git a/vendor/gpui-base/examples/wasm/Cargo.toml b/vendor/gpui-base/examples/wasm/Cargo.toml new file mode 100644 index 00000000..2a457e51 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "gpui-base-examples-wasm" +version = "0.5.2" +publish = false +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +gpui.workspace = true +gpui-base = { path = "../.." } +gpui_platform.workspace = true +console_error_panic_hook = "0.1" +console_log = "1.0" +log.workspace = true +syntect.workspace = true +tracing-wasm = "0.2" +wasm-bindgen = "=0.2.121" + +[lints] +workspace = true + +# The shared showcase is included with `#[path]`, which cargo-machete cannot +# follow when detecting its `gpui_base` and `syntect` imports. +[package.metadata.cargo-machete] +ignored = ["gpui-base", "syntect"] diff --git a/vendor/gpui-base/examples/wasm/Makefile b/vendor/gpui-base/examples/wasm/Makefile new file mode 100644 index 00000000..2d4a0075 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/Makefile @@ -0,0 +1,11 @@ +.PHONY: dev build build-wasm build-web clean +build-wasm: + ./scripts/build.sh --release +build-web: + cd www && bun install && bun run build +build: build-wasm build-web +dev: + ./scripts/build.sh + cd www && bun install && bun run dev +clean: + rm -rf www/dist www/src/wasm diff --git a/vendor/gpui-base/examples/wasm/scripts/build.sh b/vendor/gpui-base/examples/wasm/scripts/build.sh new file mode 100755 index 00000000..b9a58bed --- /dev/null +++ b/vendor/gpui-base/examples/wasm/scripts/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +crate_dir="$(cd "$script_dir/.." && pwd)" +workspace_dir="$(cd "$crate_dir/../../../.." && pwd)" +profile="debug" +cargo_args=(+nightly build --manifest-path "$crate_dir/Cargo.toml" --target wasm32-unknown-unknown) +if [[ "${1:-}" == "--release" ]]; then profile="release"; cargo_args+=(--release); fi +cargo "${cargo_args[@]}" +wasm-bindgen "$workspace_dir/target/wasm32-unknown-unknown/$profile/gpui_base_examples_wasm.wasm" --out-dir "$crate_dir/www/src/wasm" --target web --no-typescript diff --git a/vendor/gpui-base/examples/wasm/src/lib.rs b/vendor/gpui-base/examples/wasm/src/lib.rs new file mode 100644 index 00000000..3236e8b6 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/src/lib.rs @@ -0,0 +1,35 @@ +#[cfg(target_family = "wasm")] +use gpui::{Application, ApplicationHandle}; +#[cfg(target_family = "wasm")] +use std::cell::RefCell; +#[cfg(target_family = "wasm")] +use wasm_bindgen::prelude::*; + +#[path = "../../showcase/mod.rs"] +#[allow(dead_code)] +mod showcase; + +#[cfg(target_family = "wasm")] +thread_local! { + static APPLICATION: RefCell> = const { RefCell::new(None) }; +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen] +pub fn run(component: Option) -> Result<(), JsValue> { + console_error_panic_hook::set_once(); + let _ = console_log::init_with_level(log::Level::Info); + tracing_wasm::set_as_global_default(); + gpui_platform::web_init(); + let handle = showcase::run_embedded( + web_application(), + component.unwrap_or_else(|| "overview".to_owned()), + ); + APPLICATION.with(|application| *application.borrow_mut() = Some(handle)); + Ok(()) +} + +#[cfg(target_family = "wasm")] +fn web_application() -> Application { + gpui_platform::single_threaded_web() +} diff --git a/vendor/gpui-base/examples/wasm/www/.gitignore b/vendor/gpui-base/examples/wasm/www/.gitignore new file mode 100644 index 00000000..0f63c2c7 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/www/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +src/wasm/ diff --git a/vendor/gpui-base/examples/wasm/www/bun.lock b/vendor/gpui-base/examples/wasm/www/bun.lock new file mode 100644 index 00000000..7b08ecac --- /dev/null +++ b/vendor/gpui-base/examples/wasm/www/bun.lock @@ -0,0 +1,91 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "gpui-base-examples-wasm", + "devDependencies": { + "vite": "^8", + }, + }, + }, + "packages": { + "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "fdir": ["fdir@6.5.0", "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "nanoid": ["nanoid@3.3.18", "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "picocolors": ["picocolors@1.1.1", "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.26", "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="], + + "source-map-js": ["source-map-js@1.2.1", "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + } +} diff --git a/vendor/gpui-base/examples/wasm/www/index.html b/vendor/gpui-base/examples/wasm/www/index.html new file mode 100644 index 00000000..ee12f0b2 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/www/index.html @@ -0,0 +1 @@ +gpui-base example
Loading Rust example…
diff --git a/vendor/gpui-base/examples/wasm/www/package.json b/vendor/gpui-base/examples/wasm/www/package.json new file mode 100644 index 00000000..fc3e7173 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/www/package.json @@ -0,0 +1 @@ +{"name":"gpui-base-examples-wasm","private":true,"type":"module","scripts":{"dev":"bun --bun vite","build":"bun --bun vite build","preview":"bun --bun vite preview"},"devDependencies":{"vite":"^8"}} diff --git a/vendor/gpui-base/examples/wasm/www/src/main.js b/vendor/gpui-base/examples/wasm/www/src/main.js new file mode 100644 index 00000000..54c6dfc1 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/www/src/main.js @@ -0,0 +1,62 @@ +const embedded = window.parent !== window; + +// `gpui_web` reads keyboard and IME input through a 1x1 transparent `` +// it appends to the body, and focuses that element when the window opens and +// again on every `pointerdown`. On a phone, focusing a text field raises the +// on-screen keyboard over the page — so an example embedded in the docs pops up +// the iOS keyboard as soon as the page loads, or when a button is tapped. +// +// Touch-only devices therefore get that element marked `readonly` with +// `inputmode="none"`: iOS leaves the keyboard closed for a read-only field, and +// the element stays focusable, so gpui still tracks window activation and key +// events. Typing into a canvas through an off-screen input is not usable on a +// phone regardless. Devices with a real pointer are left alone, so desktop +// keyboards and IME composition behave exactly as before. +const touchOnly = + window.matchMedia?.('(hover: none) and (pointer: coarse)').matches ?? false; + +function keepKeyboardClosed(node) { + if (node.tagName !== 'INPUT') return; + + if (touchOnly) { + node.readOnly = true; + node.setAttribute('inputmode', 'none'); + } + + // The focus on window creation lands before any interaction. Embedded, it + // also takes focus away from the page hosting this example, which moves the + // reader's caret and Tab order into the iframe. Hand it back; the next + // `pointerdown` inside the canvas focuses it again. + if ((touchOnly || embedded) && document.activeElement === node) { + node.blur(); + } +} + +// Watch from before the module boots, so the element is handled as soon as gpui +// appends it rather than after the keyboard has had a chance to appear. +function watchPlatformInput() { + document.querySelectorAll('body > input').forEach(keepKeyboardClosed); + new MutationObserver((records) => { + for (const record of records) { + record.addedNodes.forEach(keepKeyboardClosed); + } + }).observe(document.body, { childList: true }); +} + +async function init() { + const loading = document.getElementById('loading'); + + watchPlatformInput(); + + try { + const wasm = await import('./wasm/gpui_base_examples_wasm.js'); + await wasm.default(); + const component = new URLSearchParams(window.location.search).get('component'); + await wasm.run(component || undefined); + loading?.remove(); + } catch (error) { + console.error('Failed to initialize gpui-base example:', error); + if (loading) loading.textContent = `Failed to load example: ${error?.message || error}`; + } +} +init(); diff --git a/vendor/gpui-base/examples/wasm/www/vite.config.js b/vendor/gpui-base/examples/wasm/www/vite.config.js new file mode 100644 index 00000000..0a6aebf5 --- /dev/null +++ b/vendor/gpui-base/examples/wasm/www/vite.config.js @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite'; +export default defineConfig({base:'/gpui-component/examples/base/',build:{target:'esnext',minify:true,sourcemap:false},server:{port:3001}}); diff --git a/vendor/gpui-base/src/accordion.rs b/vendor/gpui-base/src/accordion.rs new file mode 100644 index 00000000..746a78ee --- /dev/null +++ b/vendor/gpui-base/src/accordion.rs @@ -0,0 +1,491 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, Interactivity, IntoElement, + ParentElement, RenderOnce, Role, StatefulInteractiveElement, StyleRefinement, Styled, Window, + div, prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::StyledExt as _; + +type ChangeHandler = Rc; + +/// An unstyled accordion root for application-owned items. +#[derive(IntoElement)] +pub struct Accordion { + base: gpui::Stateful
, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl Accordion { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Styled for Accordion { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Accordion { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Accordion { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Accordion {} + +impl RenderOnce for Accordion { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::Group) + .children(self.children) + .refine_style(&self.style) + } +} + +/// An unstyled accordion item connecting one trigger with its panel content. +#[derive(IntoElement)] +pub struct AccordionItem { + base: Div, + style: StyleRefinement, + open: bool, + disabled: bool, + header: Option, + panel: Option, + children: SmallVec<[AnyElement; 2]>, +} + +impl AccordionItem { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + open: false, + disabled: false, + header: None, + panel: None, + children: SmallVec::new(), + } + } + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn header(mut self, header: AccordionHeader) -> Self { + self.header = Some(header); + self + } + + pub fn panel(mut self, panel: AccordionPanel) -> Self { + self.panel = Some(panel); + self + } +} + +impl Styled for AccordionItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for AccordionItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for AccordionItem { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .when_some(self.header, |this, header| { + this.child(header.open(self.open).disabled(self.disabled)) + }) + .when_some(self.panel, |this, panel| this.child(panel.open(self.open))) + .children(self.children) + .refine_style(&self.style) + } +} + +/// An unstyled heading that owns the trigger for one accordion item. +#[derive(IntoElement)] +pub struct AccordionHeader { + id: Option, + style: StyleRefinement, + level: usize, + trigger: AccordionTrigger, + children: SmallVec<[AnyElement; 1]>, +} + +impl AccordionHeader { + pub fn new(trigger: AccordionTrigger) -> Self { + Self { + id: None, + style: StyleRefinement::default(), + level: 3, + trigger, + children: SmallVec::new(), + } + } + pub fn level(mut self, level: usize) -> Self { + self.level = level; + self + } + + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + fn open(mut self, open: bool) -> Self { + self.trigger = self.trigger.open(open); + self + } + + fn disabled(mut self, disabled: bool) -> Self { + self.trigger = self.trigger.disabled(disabled); + self + } +} + +impl Styled for AccordionHeader { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for AccordionHeader { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for AccordionHeader { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let content = div() + .child(self.trigger) + .children(self.children) + .refine_style(&self.style); + match self.id { + Some(id) => content + .id(id) + .role(Role::Heading) + .aria_level(self.level) + .into_any_element(), + None => content.into_any_element(), + } + } +} + +/// An unstyled accordion panel with controlled mounting. +#[derive(IntoElement)] +pub struct AccordionPanel { + id: Option, + style: StyleRefinement, + open: bool, + keep_mounted: bool, + children: SmallVec<[AnyElement; 2]>, +} + +impl AccordionPanel { + pub fn new() -> Self { + Self { + id: None, + style: StyleRefinement::default(), + open: false, + keep_mounted: false, + children: SmallVec::new(), + } + } + + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + pub fn keep_mounted(mut self, keep_mounted: bool) -> Self { + self.keep_mounted = keep_mounted; + self + } + + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } +} + +impl Styled for AccordionPanel { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for AccordionPanel { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for AccordionPanel { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + if !self.open && !self.keep_mounted { + return gpui::Empty.into_any_element(); + } + + let content = div().children(self.children).refine_style(&self.style); + match self.id { + Some(id) => content.id(id).role(Role::Region).into_any_element(), + None => content.into_any_element(), + } + } +} + +/// An unstyled accordion trigger with controlled expanded state. +/// +/// The application owns the title, disclosure icon, content, layout, and +/// animation. Activating the trigger requests the opposite of `open`. +#[derive(IntoElement)] +pub struct AccordionTrigger { + base: gpui::Stateful
, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, + open: bool, + disabled: bool, + on_change: Option, +} + +impl AccordionTrigger { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + children: SmallVec::new(), + open: false, + disabled: false, + on_change: None, + } + } + + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Handles a requested change to the controlled expanded state. + pub fn on_change( + mut self, + handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl Styled for AccordionTrigger { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for AccordionTrigger { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for AccordionTrigger { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for AccordionTrigger {} + +impl RenderOnce for AccordionTrigger { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let next_open = !self.open; + + self.base + .role(Role::Button) + .aria_expanded(self.open) + .when_some( + (!self.disabled).then_some(self.on_change).flatten(), + move |this, on_change| { + this.on_click(move |event, window, cx| on_change(next_open, event, window, cx)) + }, + ) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{cell::RefCell, rc::Rc}; + + use gpui::{ + Context, Element as _, Modifiers, Render, Role, VisualTestContext, accesskit, canvas, + point, px, + }; + + #[gpui::test] + fn trigger_projects_expanded_accessibility_state(cx: &mut gpui::TestAppContext) { + let requested = Rc::new(RefCell::new(Vec::new())); + let captured = Rc::new(RefCell::new(None)); + + struct Probe { + requested: Rc>>, + captured: Rc>>, + } + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let requested = self.requested.clone(); + let captured = self.captured.clone(); + canvas( + move |_, window, cx| { + let mut node = accesskit::Node::new(Role::Button); + AccordionTrigger::new("trigger") + .open(true) + .on_change(move |open, _, _, _| requested.borrow_mut().push(open)) + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + *captured.borrow_mut() = Some(node); + }, + |_, _, _, _| {}, + ) + } + } + + let (_, window) = cx.add_window_view({ + let requested = requested.clone(); + let captured = captured.clone(); + move |_, _| Probe { + requested, + captured, + } + }); + window.update(|window, cx| window.draw(cx).clear(cx)); + + let node = captured.borrow_mut().take().unwrap(); + assert_eq!(node.role(), Role::Button); + assert_eq!(node.is_expanded(), Some(true)); + + assert!(requested.borrow().is_empty()); + } + + struct TriggerHarness { + open: bool, + disabled: bool, + requested: Rc>>, + } + + impl Render for TriggerHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let requested = self.requested.clone(); + AccordionTrigger::new("trigger") + .open(self.open) + .disabled(self.disabled) + .size(px(100.)) + .on_change(move |open, _, _, _| requested.borrow_mut().push(open)) + } + } + + fn harness( + cx: &mut gpui::TestAppContext, + open: bool, + disabled: bool, + ) -> (&mut VisualTestContext, Rc>>) { + let requested = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let requested = requested.clone(); + move |_, _| TriggerHarness { + open, + disabled, + requested, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, requested) + } + + #[gpui::test] + fn pointer_requests_next_controlled_state_and_respects_disabled(cx: &mut gpui::TestAppContext) { + let (cx, requested) = harness(cx, false, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(&*requested.borrow(), &[true]); + + let (cx, requested) = harness(cx, true, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert!(requested.borrow().is_empty()); + } + + #[gpui::test] + fn header_and_panel_project_structural_roles(cx: &mut gpui::TestAppContext) { + let captured = Rc::new(RefCell::new(Vec::new())); + + struct Probe(Rc>>); + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.0.clone(); + canvas( + move |_, window, cx| { + let mut header_node = accesskit::Node::new(Role::Heading); + AccordionHeader::new(AccordionTrigger::new("trigger")) + .id("header") + .level(2) + .render(window, cx) + .into_element() + .write_a11y_info(&mut header_node); + + let mut panel_node = accesskit::Node::new(Role::Region); + AccordionPanel::new() + .id("panel") + .open(true) + .render(window, cx) + .into_element() + .write_a11y_info(&mut panel_node); + *captured.borrow_mut() = vec![header_node, panel_node]; + }, + |_, _, _, _| {}, + ) + } + } + + let (_, view) = cx.add_window_view({ + let captured = captured.clone(); + move |_, _| Probe(captured) + }); + view.update(|window, cx| window.draw(cx).clear(cx)); + let nodes = captured.borrow(); + assert_eq!(nodes[0].role(), Role::Heading); + assert_eq!(nodes[1].role(), Role::Region); + } +} diff --git a/vendor/gpui-base/src/actions.rs b/vendor/gpui-base/src/actions.rs new file mode 100644 index 00000000..a329050e --- /dev/null +++ b/vendor/gpui-base/src/actions.rs @@ -0,0 +1,39 @@ +//! Common actions shared by keyboard-driven GPUI controls. + +use gpui::{Action, actions}; +use serde::Deserialize; + +#[derive(Clone, Action, PartialEq, Eq, Deserialize)] +#[action(namespace = ui, no_json)] +pub struct Confirm { + /// Whether the confirmation uses the secondary action. + pub secondary: bool, +} + +actions!( + ui, + [ + Cancel, + SelectUp, + SelectDown, + SelectLeft, + SelectRight, + SelectFirst, + SelectLast, + SelectPrevColumn, + SelectNextColumn, + SelectPageUp, + SelectPageDown + ] +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confirm_keeps_its_secondary_payload() { + assert!(Confirm { secondary: true }.secondary); + assert!(!Confirm { secondary: false }.secondary); + } +} diff --git a/vendor/gpui-base/src/alert_dialog.rs b/vendor/gpui-base/src/alert_dialog.rs new file mode 100644 index 00000000..e428229d --- /dev/null +++ b/vendor/gpui-base/src/alert_dialog.rs @@ -0,0 +1,314 @@ +use gpui::{ + App, ClickEvent, FocusHandle, InteractiveElement as _, IntoElement, MouseButton, ParentElement, + Pixels, RenderOnce, Role, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, + div, +}; +use smallvec::SmallVec; + +use crate::StyledExt as _; +use crate::{Dialog, DialogChangeReason, DialogHandle}; + +macro_rules! alert_part { + ($name:ident, $id:literal) => { + #[derive(IntoElement)] + pub struct $name { + style: StyleRefinement, + children: SmallVec<[gpui::AnyElement; 2]>, + } + impl $name { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } + } + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } + } + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + impl RenderOnce for $name { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id($id) + .children(self.children) + .refine_style(&self.style) + } + } + }; +} + +alert_part!(AlertDialogBackdrop, "alert-dialog-backdrop"); +alert_part!(AlertDialogPopup, "alert-dialog-popup"); +alert_part!(AlertDialogTitle, "alert-dialog-title"); +alert_part!(AlertDialogDescription, "alert-dialog-description"); + +#[derive(IntoElement)] +pub struct AlertDialogTrigger { + trigger: gpui::AnyElement, + open: std::rc::Rc, + handle: Option, +} +impl AlertDialogTrigger { + pub fn new(trigger: impl IntoElement) -> Self { + Self { + trigger: trigger.into_any_element(), + open: std::rc::Rc::new(|_, _| {}), + handle: None, + } + } + pub fn on_open(mut self, open: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.open = std::rc::Rc::new(open); + self + } + pub fn handle(mut self, handle: DialogHandle) -> Self { + self.handle = Some(handle); + self + } +} +impl RenderOnce for AlertDialogTrigger { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + if let Some(handle) = self.handle.as_ref() { + handle.set_open(true, DialogChangeReason::TriggerPress, window, cx); + } + (self.open)(window, cx); + cx.stop_propagation(); + }) + .child(self.trigger) + } +} + +macro_rules! alert_close_part { + ($name:ident, $id:literal) => { + #[derive(IntoElement)] + pub struct $name { + style: StyleRefinement, + children: SmallVec<[gpui::AnyElement; 1]>, + } + impl $name { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } + } + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } + } + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + impl RenderOnce for $name { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id($id) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::actions::Cancel), cx) + }) + .children(self.children) + .refine_style(&self.style) + } + } + }; +} +alert_close_part!(AlertDialogClose, "alert-dialog-close"); +alert_close_part!(AlertDialogCancel, "alert-dialog-cancel"); + +/// Wrapper that dispatches the alert dialog's confirm action. +#[derive(IntoElement)] +pub struct AlertDialogAction { + style: StyleRefinement, + children: SmallVec<[gpui::AnyElement; 1]>, +} + +impl AlertDialogAction { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Default for AlertDialogAction { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for AlertDialogAction { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for AlertDialogAction { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for AlertDialogAction { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id("alert-dialog-action") + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::actions::Confirm { secondary: false }), cx) + }) + .children(self.children) + .refine_style(&self.style) + } +} + +/// Alert-dialog specialization of the Base modal host. +pub struct AlertDialog(Dialog); + +impl AlertDialog { + pub fn new(cx: &mut App) -> Self { + Self( + Dialog::new(cx) + .role(Role::AlertDialog) + .close_on_backdrop_press(false), + ) + } + pub fn open(mut self, open: bool) -> Self { + self.0 = self.0.open(open); + self + } + pub fn handle(mut self, handle: DialogHandle) -> Self { + self.0 = self.0.handle(handle); + self + } + pub fn on_open_change( + mut self, + handler: impl Fn(bool, DialogChangeReason, &mut Window, &mut App) + 'static, + ) -> Self { + self.0 = self.0.on_open_change(handler); + self + } + pub fn backdrop(mut self, element: impl IntoElement) -> Self { + self.0 = self.0.backdrop(element); + self + } + pub fn popup(mut self, element: impl IntoElement) -> Self { + self.0 = self.0.popup(element); + self + } + pub fn close_on_escape(mut self, value: bool) -> Self { + self.0 = self.0.close_on_escape(value); + self + } + pub fn dismiss_below_y(mut self, value: Pixels) -> Self { + self.0 = self.0.dismiss_below_y(value); + self + } + pub fn on_ok( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.0 = self.0.on_ok(handler); + self + } + pub fn on_cancel( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.0 = self.0.on_cancel(handler); + self + } + pub fn on_close( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.0 = self.0.on_close(handler); + self + } + #[doc(hidden)] + pub fn layer(mut self, index: usize, topmost: bool) -> Self { + self.0 = self.0.layer(index, topmost); + self + } + #[doc(hidden)] + pub fn focus_handle(mut self, value: FocusHandle) -> Self { + self.0 = self.0.focus_handle(value); + self + } + #[doc(hidden)] + pub fn request_close( + mut self, + handler: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.0 = self.0.request_close(handler); + self + } +} + +impl ParentElement for AlertDialog { + fn extend(&mut self, elements: impl IntoIterator) { + self.0.extend(elements); + } +} +impl IntoElement for AlertDialog { + type Element = ::Element; + fn into_element(self) -> Self::Element { + self.0.into_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, div, point, px}; + use std::{cell::Cell, rc::Rc}; + + struct Harness { + close_requested: Rc>, + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let close_requested = self.close_requested.clone(); + AlertDialog::new(cx) + .request_close(move |_, _, _| close_requested.set(true)) + .backdrop(div().size(px(200.))) + } + } + + #[gpui::test] + fn backdrop_is_not_closable_by_default(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let close_requested = Rc::new(Cell::new(false)); + let (_, cx) = cx.add_window_view({ + let close_requested = close_requested.clone(); + move |_, _| Harness { close_requested } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_click(point(px(20.), px(20.)), Default::default()); + + assert!(!close_requested.get()); + } +} diff --git a/vendor/gpui-base/src/animation.rs b/vendor/gpui-base/src/animation.rs new file mode 100644 index 00000000..26dfc622 --- /dev/null +++ b/vendor/gpui-base/src/animation.rs @@ -0,0 +1,396 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + Animation, AnimationExt, ElementId, Hsla, IntoElement, Pixels, Point, Styled, point, + prelude::FluentBuilder, px, +}; +use smallvec::SmallVec; + +/// A cubic bezier function like CSS `cubic-bezier`. +/// +/// Builder: +/// +/// https://cubic-bezier.com +pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 { + // Polynomial form of the unit bezier, where p0 = (0, 0) and p3 = (1, 1). + let (cx, cy) = (3.0 * x1, 3.0 * y1); + let (bx, by) = (3.0 * (x2 - x1) - cx, 3.0 * (y2 - y1) - cy); + let (ax, ay) = (1.0 - cx - bx, 1.0 - cy - by); + let sample_x = move |t: f32| ((ax * t + bx) * t + cx) * t; + let sample_y = move |t: f32| ((ay * t + by) * t + cy) * t; + let slope_x = move |t: f32| (3.0 * ax * t + 2.0 * bx) * t + cx; + + // Solve `x(s) = t` for the curve parameter `s`. + let solve_s = move |t: f32| { + let mut s = t; + for _ in 0..8 { + let error = sample_x(s) - t; + if error.abs() < 1e-6 { + return s; + } + let slope = slope_x(s); + if slope.abs() < 1e-6 { + break; + } + s = (s - error / slope).clamp(0.0, 1.0); + } + + let (mut low, mut high) = (0.0, 1.0); + let mut s = t; + for _ in 0..32 { + let x = sample_x(s); + if (x - t).abs() < 1e-6 { + break; + } + if x < t { + low = s; + } else { + high = s; + } + s = (low + high) / 2.0; + } + s + }; + + move |t: f32| { + let t = t.clamp(0.0, 1.0); + // `t` is elapsed progress along x, not the curve parameter: solve + // `x(s) = t` before sampling y, otherwise the curve reads much slower + // than the same control points do in CSS. GPUI asserts easing deltas + // stay within [0, 1], so clamp away solver and rounding error. + sample_y(solve_s(t)).clamp(0.0, 1.0) + } +} + +// ── Easing presets ────────────────────────────────────────────────────────── + +/// Cubic ease-out — fast start, slow end. Good for enter animations. +pub fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) +} + +/// Cubic ease-in — slow start, fast end. Good for exit animations. +pub fn ease_in_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * t +} + +/// Cubic ease-in-out — slow start and end. Good for position transitions. +pub fn ease_in_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } +} + +// ── Lerp trait ────────────────────────────────────────────────────────────── + +/// Trait for types that support linear interpolation. +pub trait Lerp: Clone { + fn lerp(&self, target: &Self, t: f32) -> Self; +} + +impl Lerp for f32 { + fn lerp(&self, target: &Self, t: f32) -> Self { + self + (target - self) * t + } +} + +impl Lerp for Pixels { + fn lerp(&self, target: &Self, t: f32) -> Self { + let a: f32 = (*self).into(); + let b: f32 = (*target).into(); + px(a + (b - a) * t) + } +} + +impl Lerp for Point { + fn lerp(&self, target: &Self, t: f32) -> Self { + point( + Lerp::lerp(&self.x, &target.x, t), + Lerp::lerp(&self.y, &target.y, t), + ) + } +} + +impl Lerp for Hsla { + /// Interpolate each channel linearly. Intended for transitions between + /// near-grayscale UI colors (e.g. text colors), where hue interpolation is + /// irrelevant. + fn lerp(&self, target: &Self, t: f32) -> Self { + Hsla { + h: self.h.lerp(&target.h, t), + s: self.s.lerp(&target.s, t), + l: self.l.lerp(&target.l, t), + a: self.a.lerp(&target.a, t), + } + } +} + +// ── Transition combinator ─────────────────────────────────────────────────── + +/// A composable transition that applies concrete fade, slide, and size effects +/// to an element. +/// +/// This is distinct from [`crate::motion::Transition`], which is a timing +/// policy for a caller-chosen value and never picks a visual property. Prefer +/// `motion` for new code. +/// +/// # Example +/// +/// ```ignore +/// EffectTransition::new(Duration::from_millis(150)) +/// .ease(ease_out_cubic) +/// .slide_y(px(-4.), px(0.)) +/// .fade(0.0, 1.0) +/// .apply(element, "enter-anim") +/// ``` +#[derive(Clone)] +pub struct EffectTransition { + pub duration: Duration, + easing: Rc f32>, + effects: SmallVec<[TransitionEffect; 2]>, +} + +#[derive(Clone, Copy)] +enum TransitionEffect { + SlideY(Pixels, Pixels), + SlideX(Pixels, Pixels), + Fade(f32, f32), + Width(Pixels, Pixels), + Height(Pixels, Pixels), +} + +impl EffectTransition { + pub fn new(duration: Duration) -> Self { + Self { + duration, + easing: Rc::new(ease_out_cubic), + effects: SmallVec::new(), + } + } + + /// Set the easing function. + pub fn ease(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self { + self.easing = Rc::new(easing); + self + } + + /// Animate vertical offset from `from` to `to`. + pub fn slide_y(mut self, from: Pixels, to: Pixels) -> Self { + self.effects.push(TransitionEffect::SlideY(from, to)); + self + } + + /// Animate horizontal offset from `from` to `to`. + pub fn slide_x(mut self, from: Pixels, to: Pixels) -> Self { + self.effects.push(TransitionEffect::SlideX(from, to)); + self + } + + /// Animate opacity from `from` to `to`. + pub fn fade(mut self, from: f32, to: f32) -> Self { + self.effects.push(TransitionEffect::Fade(from, to)); + self + } + + /// Animate width from `from` to `to`. + pub fn width(mut self, from: Pixels, to: Pixels) -> Self { + self.effects.push(TransitionEffect::Width(from, to)); + self + } + + /// Animate height from `from` to `to`. + pub fn height(mut self, from: Pixels, to: Pixels) -> Self { + self.effects.push(TransitionEffect::Height(from, to)); + self + } + + /// Apply this transition to a Styled element, returning an AnimationElement. + pub fn apply( + self, + element: E, + id: impl Into, + ) -> gpui::AnimationElement { + let animation = Animation::new(self.duration).with_easing({ + let easing = self.easing.clone(); + move |t| easing(t) + }); + let effects = self.effects; + element.with_animation(id, animation, move |el, delta| { + let mut el = el; + for effect in &effects { + match effect { + TransitionEffect::SlideY(from, to) => { + el = el.top(Lerp::lerp(from, to, delta)); + } + TransitionEffect::SlideX(from, to) => { + el = el.left(Lerp::lerp(from, to, delta)); + } + TransitionEffect::Fade(from, to) => { + el = el.opacity(Lerp::lerp(from, to, delta)); + } + TransitionEffect::Width(from, to) => { + el = el.w(Lerp::lerp(from, to, delta)); + } + TransitionEffect::Height(from, to) => { + el = el.h(Lerp::lerp(from, to, delta)); + } + } + } + el + }) + } +} + +impl FluentBuilder for EffectTransition {} + +/// Former name of [`EffectTransition`]. +/// +/// Renamed because `motion::Transition` and this type were two different +/// concepts sharing one name. +#[deprecated(since = "0.5.2", note = "renamed to `EffectTransition`")] +pub type Transition = EffectTransition; + +#[cfg(test)] +mod tests { + use super::cubic_bezier; + + #[test] + fn cubic_bezier_matches_engine_published_values() { + // Reference values sampled from the CSS `ease` curve. + let ease = cubic_bezier(0.25, 0.1, 0.25, 1.); + for (t, expected) in [ + (0.0, 0.0), + (0.2, 0.295), + (0.5, 0.802), + (0.8, 0.976), + (1.0, 1.0), + ] { + assert!( + (ease(t) - expected).abs() < 1e-3, + "ease({t}) = {}, expected {expected}", + ease(t) + ); + } + + // Chromium's CubicBezier(0.25, 0, 0.75, 1) expectations, from + // ui/gfx/geometry/cubic_bezier_unittest.cc (epsilon 0.00015 there; + // widened for our f32 arithmetic). + let curve = cubic_bezier(0.25, 0., 0.75, 1.); + for (t, expected) in [ + (0.05, 0.01136), + (0.1, 0.03978), + (0.15, 0.07978), + (0.2, 0.12803), + (0.25, 0.18235), + (0.3, 0.24115), + (0.35, 0.30323), + (0.4, 0.36761), + (0.45, 0.43345), + (0.5, 0.5), + (0.6, 0.63238), + (0.65, 0.69676), + (0.7, 0.75884), + (0.75, 0.81764), + (0.8, 0.87196), + (0.85, 0.92021), + (0.9, 0.96021), + (0.95, 0.98863), + ] { + assert!( + (curve(t) - expected).abs() < 3e-4, + "curve({t}) = {}, Chromium says {expected}", + curve(t) + ); + } + } + + #[test] + fn cubic_bezier_with_thirds_x_maps_time_identically() { + // x1 = 1/3, x2 = 2/3 collapse the x solve to the identity, making the + // output the plain y polynomial; Dialog relies on this to keep the + // trajectory it was tuned with before `cubic_bezier` solved for x. + let ease = cubic_bezier(1. / 3., 0.72, 2. / 3., 1.); + for step in 0..=100 { + let t = step as f32 / 100.; + let one_t = 1. - t; + let expected = 3. * 0.72 * one_t * one_t * t + 3. * one_t * t * t + t * t * t; + assert!( + (ease(t) - expected).abs() < 1e-4, + "ease({t}) = {}, expected {expected}", + ease(t) + ); + } + } + + #[test] + fn cubic_bezier_matches_the_css_definition() { + // Reference solver written straight off the CSS Easing Functions + // definition — solve `x(s) = t` by bisection in f64, then sample + // `y(s)` — independent of the production Newton solver. + fn css_reference(x1: f64, y1: f64, x2: f64, y2: f64, t: f64) -> f64 { + let sample = |p1: f64, p2: f64, s: f64| { + 3. * p1 * (1. - s) * (1. - s) * s + 3. * p2 * (1. - s) * s * s + s * s * s + }; + let (mut low, mut high) = (0f64, 1f64); + for _ in 0..64 { + let mid = (low + high) / 2.; + if sample(x1, x2, mid) < t { + low = mid; + } else { + high = mid; + } + } + sample(y1, y2, (low + high) / 2.) + } + + let curves = [ + // The runtime curves in this repo. + (0.25, 0.1, 0.25, 1.), + (1. / 3., 0.72, 2. / 3., 1.), + // CSS keyword curves. + (0.42, 0., 1., 1.), + (0., 0., 0.58, 1.), + (0.42, 0., 0.58, 1.), + (0., 0., 1., 1.), + // Degenerate x slopes: zero at s = 0.5, 0, and 1, forcing the + // Newton solve to fall back to bisection. + (1., 0., 0., 1.), + (0., 0., 0., 1.), + (1., 0.5, 1., 0.5), + ]; + for (x1, y1, x2, y2) in curves { + let ease = cubic_bezier(x1, y1, x2, y2); + for step in 0..=1000 { + let t = step as f32 / 1000.; + let y = ease(t); + let expected = + css_reference(x1 as f64, y1 as f64, x2 as f64, y2 as f64, t as f64) as f32; + assert!( + (y - expected).abs() < 5e-4, + "cubic_bezier({x1}, {y1}, {x2}, {y2})({t}) = {y}, CSS = {expected}" + ); + // GPUI panics when an easing delta leaves [0, 1]. + assert!((0.0..=1.0).contains(&y), "ease({t}) = {y} out of range"); + } + } + } + + #[test] + fn cubic_bezier_is_monotonic_and_clamped() { + let ease = cubic_bezier(0.32, 0.72, 0., 1.); + assert_eq!(ease(-1.), 0.); + assert_eq!(ease(2.), 1.); + + let mut previous = 0.; + for step in 0..=100 { + let current = ease(step as f32 / 100.); + assert!(current >= previous - 1e-4, "not monotonic at {step}"); + previous = current; + } + } +} diff --git a/vendor/gpui-base/src/async_util.rs b/vendor/gpui-base/src/async_util.rs new file mode 100644 index 00000000..8efc225a --- /dev/null +++ b/vendor/gpui-base/src/async_util.rs @@ -0,0 +1,7 @@ +//! Cross-platform async channel primitives for native and WASM targets. + +#[cfg(not(target_family = "wasm"))] +pub use smol::channel::{Receiver, Sender, unbounded}; + +#[cfg(target_family = "wasm")] +pub use async_channel::{Receiver, Sender, unbounded}; diff --git a/vendor/gpui-base/src/auto_scroll.rs b/vendor/gpui-base/src/auto_scroll.rs new file mode 100644 index 00000000..96094b9e --- /dev/null +++ b/vendor/gpui-base/src/auto_scroll.rs @@ -0,0 +1,155 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use gpui::{AsyncApp, Bounds, Context, Pixels, Point, Task, WeakEntity, px}; + +/// Manages timer-based auto-scrolling during drag interactions. +/// +/// Delta convention: positive moves toward the bottom and negative moves +/// toward the top. +pub struct AutoScroll { + /// Shared between the main thread and the background task. + /// Writing `None` is the stop signal; the task exits on its next tick. + shared: Arc>>, + task: Option>, + /// Last drag position, available to the interaction that owns selection. + pub last_drag_position: Option>, +} + +impl Default for AutoScroll { + fn default() -> Self { + Self { + shared: Arc::new(Mutex::new(None)), + task: None, + last_drag_position: None, + } + } +} + +impl AutoScroll { + /// Returns the current scroll delta. + pub fn delta(&self) -> Option { + *self.shared.lock().unwrap() + } + + /// Computes the scroll delta for a pointer Y position within the viewport. + pub fn compute_delta(y: Pixels, bounds: Bounds) -> Option { + const MIN_SPEED: f32 = 12.0; + const MAX_SPEED: f32 = 64.0; + // Trigger starts this far inside the bounds so scrolling works even in + // full-screen where the mouse can't travel far outside the element. + const INNER_ZONE: f32 = 16.0; + // Distance from the bounds edge to reach MAX_SPEED. + // Total ramp = INNER_ZONE + OUTER_RAMP, giving a single smooth curve + // with no flat sections or discontinuities. + const OUTER_RAMP: f32 = 80.0; + + let bottom_trigger = bounds.bottom() - px(INNER_ZONE); + let top_trigger = bounds.top() + px(INNER_ZONE); + + if y > bottom_trigger { + let t = ((y - bottom_trigger) / px(INNER_ZONE + OUTER_RAMP)).min(1.0); + Some(px(MIN_SPEED + t * (MAX_SPEED - MIN_SPEED))) + } else if y < top_trigger { + let t = ((top_trigger - y) / px(INNER_ZONE + OUTER_RAMP)).min(1.0); + Some(px(-(MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)))) + } else { + None + } + } + + /// Updates the scroll delta and starts the background task when needed. + /// + /// `tick` is called each frame (~60 fps) with the current delta. + /// It should perform the actual scroll action for this entity. + pub fn set(&mut self, delta: Option, cx: &mut Context, tick: F) + where + T: 'static, + F: Fn(Pixels, &mut T, &mut Context) + Send + 'static, + { + let was_idle = self.task.is_none(); + *self.shared.lock().unwrap() = delta; + + if delta.is_none() { + self.task = None; + return; + } + + if was_idle { + let shared = Arc::clone(&self.shared); + self.task = Some(cx.spawn(Self::task_loop(shared, tick))); + } + } + + fn task_loop( + shared: Arc>>, + tick: F, + ) -> impl AsyncFnOnce(WeakEntity, &mut AsyncApp) + 'static + where + T: 'static, + F: Fn(Pixels, &mut T, &mut Context) + Send + 'static, + { + async move |this: WeakEntity, cx: &mut AsyncApp| { + loop { + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + let Some(delta) = *shared.lock().unwrap() else { + break; + }; + let alive = this + .update(cx, |state, cx| { + tick(delta, state, cx); + true + }) + .unwrap_or(false); + if !alive { + break; + } + } + } + } + + /// Returns whether automatic scrolling is active. + pub fn is_active(&self) -> bool { + self.delta().is_some() + } + + /// Stops automatic scrolling and clears the last drag position. + pub fn stop(&mut self) { + *self.shared.lock().unwrap() = None; + self.task = None; + self.last_drag_position = None; + } +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, size}; + + use super::*; + + #[test] + fn delta_uses_dead_zone_and_symmetric_edge_ramps() { + let bounds = Bounds::new(point(px(0.), px(100.)), size(px(200.), px(100.))); + + assert_eq!(AutoScroll::compute_delta(px(150.), bounds), None); + + let top = AutoScroll::compute_delta(px(80.), bounds).unwrap(); + let bottom = AutoScroll::compute_delta(px(220.), bounds).unwrap(); + assert_eq!(top, -bottom); + assert!(top < px(-12.)); + } + + #[test] + fn stop_clears_delta_and_drag_position() { + let mut scroll = AutoScroll::default(); + *scroll.shared.lock().unwrap() = Some(px(20.)); + scroll.last_drag_position = Some(point(px(1.), px(2.))); + + scroll.stop(); + + assert!(!scroll.is_active()); + assert_eq!(scroll.last_drag_position, None); + } +} diff --git a/vendor/gpui-base/src/avatar.rs b/vendor/gpui-base/src/avatar.rs new file mode 100644 index 00000000..f36205d7 --- /dev/null +++ b/vendor/gpui-base/src/avatar.rs @@ -0,0 +1,187 @@ +use gpui::{ + AnyElement, App, Div, ImageSource, InteractiveElement, Interactivity, IntoElement, + ParentElement, RenderOnce, StyleRefinement, Styled, Window, div, img, +}; +use smallvec::SmallVec; + +use crate::StyledExt as _; + +/// An unstyled avatar root that renders its image slot or fallback slot. +#[derive(IntoElement)] +pub struct Avatar { + base: Div, + style: StyleRefinement, + image: Option, + fallback: Option, +} + +impl Avatar { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + image: None, + fallback: None, + } + } + + pub fn image(mut self, image: AvatarImage) -> Self { + self.image = Some(image); + self + } + + pub fn fallback(mut self, fallback: AvatarFallback) -> Self { + self.fallback = Some(fallback); + self + } +} + +impl Default for Avatar { + fn default() -> Self { + Self::new() + } +} + +impl Styled for Avatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for Avatar { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl RenderOnce for Avatar { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let content = self + .image + .map(IntoElement::into_any_element) + .or_else(|| self.fallback.map(IntoElement::into_any_element)); + self.base.children(content).refine_style(&self.style) + } +} + +/// An unstyled avatar image slot. +#[derive(IntoElement)] +pub struct AvatarImage { + image: gpui::Img, + style: StyleRefinement, +} + +impl AvatarImage { + pub fn new(source: impl Into) -> Self { + Self { + image: img(source), + style: StyleRefinement::default(), + } + } +} + +impl Styled for AvatarImage { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for AvatarImage { + fn interactivity(&mut self) -> &mut Interactivity { + self.image.interactivity() + } +} + +impl RenderOnce for AvatarImage { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.image.refine_style(&self.style) + } +} + +/// An unstyled avatar fallback slot for initials or an application-owned icon. +#[derive(IntoElement)] +pub struct AvatarFallback { + base: Div, + style: StyleRefinement, + children: SmallVec<[AnyElement; 1]>, +} + +impl AvatarFallback { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Default for AvatarFallback { + fn default() -> Self { + Self::new() + } +} + +impl Styled for AvatarFallback { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for AvatarFallback { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for AvatarFallback { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base.children(self.children).refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, prelude::FluentBuilder as _, px}; + + struct Harness { + image: bool, + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Avatar::new() + .when(self.image, |this| { + this.image( + AvatarImage::new("avatar.png") + .debug_selector(|| "avatar-image".into()) + .size(px(20.)), + ) + }) + .fallback( + AvatarFallback::new().child( + div() + .debug_selector(|| "avatar-fallback".into()) + .size(px(20.)), + ), + ) + } + } + + #[gpui::test] + fn image_slot_takes_precedence_over_fallback(cx: &mut gpui::TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| Harness { image: true }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("avatar-image").is_some()); + assert!(cx.debug_bounds("avatar-fallback").is_none()); + } + + #[gpui::test] + fn fallback_renders_without_an_image(cx: &mut gpui::TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| Harness { image: false }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("avatar-image").is_none()); + assert!(cx.debug_bounds("avatar-fallback").is_some()); + } +} diff --git a/vendor/gpui-base/src/bin/components.rs b/vendor/gpui-base/src/bin/components.rs new file mode 100644 index 00000000..8950a60a --- /dev/null +++ b/vendor/gpui-base/src/bin/components.rs @@ -0,0 +1,10 @@ +#[path = "../../examples/showcase/mod.rs"] +mod showcase; + +fn main() { + let component = std::env::args() + .nth(1) + .unwrap_or_else(|| "overview".to_string()); + + showcase::run_native(&component); +} diff --git a/vendor/gpui-base/src/button.rs b/vendor/gpui-base/src/button.rs new file mode 100644 index 00000000..a713b7e3 --- /dev/null +++ b/vendor/gpui-base/src/button.rs @@ -0,0 +1,494 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString, + Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{RoleOverride, Selectable, StateStyle, StyledExt as _}; + +type ClickHandler = Rc; + +/// An unstyled button that owns interaction, focus, keyboard, and accessibility behavior. +/// +/// Layout and visual states are intentionally supplied by the application through +/// GPUI's [`Styled`] API. +#[derive(IntoElement)] +pub struct Button { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + semantic_styles: ButtonStyles, + selected: bool, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_click: Option, + accessibility_label: Option, + role: RoleOverride, + provided_focus_handle: Option, + tab_index: isize, + tab_stop: bool, + focusable: bool, +} + +impl Button { + pub fn new(id: impl Into) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + semantic_styles: ButtonStyles::default(), + selected: false, + disabled: false, + children: SmallVec::new(), + on_click: None, + accessibility_label: None, + role: RoleOverride::Implicit, + provided_focus_handle: None, + tab_index: 0, + tab_stop: true, + focusable: true, + } + } + + /// Sets whether the button ignores pointer and keyboard activation. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Sets the application-controlled selected presentation state. + /// + /// This supports persistent trigger presentation while an associated menu + /// or popover is open. It is distinct from momentary `active`, Toggle's + /// `pressed` state, and accessibility toggle metadata; selecting a Button + /// does not automatically set `aria_toggled`. + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + /// Defines application-owned styles for the button's semantic states. + pub fn styles(mut self, build: impl FnOnce(ButtonStyles) -> ButtonStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + /// Sets the label exposed to accessibility clients. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Overrides the accessibility role. The default is [`Role::Button`]. + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + /// Uses a caller-owned focus handle instead of creating keyed state. + pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { + self.provided_focus_handle = Some(focus_handle.clone()); + self + } + + /// Sets the activation handler for pointer, Enter, and Space input. + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } + + /// Sets the focus traversal index. The default is `0`. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + /// Sets whether the button participates in keyboard focus traversal. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + /// Sets whether pressing the button moves focus onto it. The default is `true`. + /// + /// A non-focusable button leaves focus wherever it already is, which keeps a + /// composed control from flickering its focus ring on every press. It also + /// gives up Enter and Space activation, so only use it for a button that a + /// focused sibling already exposes to the keyboard, such as a number input's + /// step buttons. + pub fn focusable(mut self, focusable: bool) -> Self { + self.focusable = focusable; + self + } + + fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle { + self.provided_focus_handle.clone().unwrap_or_else(|| { + window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + }) + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.selected.then_some(&self.semantic_styles.selected), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +impl Selectable for Button { + fn selected(self, selected: bool) -> Self { + Button::selected(self, selected) + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +/// Semantic styles supported by [`Button`]. +#[derive(Default)] +pub struct ButtonStyles { + selected: StyleRefinement, + disabled: StyleRefinement, +} + +impl ButtonStyles { + /// Refines the root style when the button is selected. + pub fn selected(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.selected + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + /// Refines the root style when the button is disabled. + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl Styled for Button { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Button { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Button { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Button {} + +impl RenderOnce for Button { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle(window, cx); + let disabled = self.disabled; + let style = self.resolved_style(); + let on_click = self.on_click; + + self.base + .when_some(self.role.resolve(|| Role::Button), |this, role| { + this.role(role) + }) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .when(!disabled && self.focusable, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when(disabled, |this| { + this.on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + }) + .when_some( + (!disabled).then_some(on_click).flatten(), + |this, on_click| { + this.on_click(move |event, window, cx| { + on_click(event, window, cx); + }) + }, + ) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::Cell, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + ClickEvent, Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + TestAppContext, VisualTestContext, accesskit, canvas, point, px, + }; + + struct ButtonHarness { + disabled: bool, + button_clicks: Rc>, + parent_clicks: Rc>, + keyboard_events: Rc>, + } + + impl Render for ButtonHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let button_clicks = self.button_clicks.clone(); + let keyboard_events = self.keyboard_events.clone(); + let parent_clicks = self.parent_clicks.clone(); + + div() + .id("button-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Button::new("button") + .disabled(self.disabled) + .size_full() + .on_click(move |event, _, _| { + button_clicks.set(button_clicks.get() + 1); + if matches!(event, ClickEvent::Keyboard(_)) { + keyboard_events.set(keyboard_events.get() + 1); + } + }), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + disabled: bool, + ) -> ( + &mut VisualTestContext, + Rc>, + Rc>, + Rc>, + ) { + let button_clicks = Rc::new(Cell::new(0)); + let parent_clicks = Rc::new(Cell::new(0)); + let keyboard_events = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let button_clicks = button_clicks.clone(); + let parent_clicks = parent_clicks.clone(); + let keyboard_events = keyboard_events.clone(); + move |_, _| ButtonHarness { + disabled, + button_clicks, + parent_clicks, + keyboard_events, + } + }); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, button_clicks, parent_clicks, keyboard_events) + } + + #[gpui::test] + fn pointer_activation_fires_once(cx: &mut TestAppContext) { + let (cx, button_clicks, _, keyboard_events) = harness(cx, false); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + + assert_eq!(button_clicks.get(), 1); + assert_eq!(keyboard_events.get(), 0); + } + + #[gpui::test] + fn enter_and_space_use_one_native_keyboard_click_each(cx: &mut TestAppContext) { + let (cx, button_clicks, _, keyboard_events) = harness(cx, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + button_clicks.set(0); + cx.update(|window, cx| { + assert!(window.focused(cx).is_some()); + window.draw(cx).clear(cx); + }); + + for key in ["enter", "space"] { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + assert_eq!(button_clicks.get(), 2); + assert_eq!(keyboard_events.get(), 2); + } + + #[gpui::test] + fn disabled_button_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) { + let (cx, button_clicks, parent_clicks, _) = harness(cx, true); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.update(|window, cx| window.focus_next(cx)); + cx.simulate_keystrokes("enter space"); + + assert_eq!(button_clicks.get(), 0); + assert_eq!(parent_clicks.get(), 0); + } + + #[test] + fn state_styling_methods_are_available_to_applications() { + let _ = Button::new("states") + .styles(|styles| { + styles.disabled(|style| { + style + .opacity(0.5) + .when(true, |style| style.border_1()) + .when_some(Some(0.4), |style, opacity| style.opacity(opacity)) + .when_none(&None::, |style| style.rounded_sm()) + }) + }) + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.8)) + .focus_visible(|style| style.opacity(0.7)); + } + + #[test] + fn disabled_style_applies_only_while_disabled_and_then_wins() { + let enabled = Button::new("enabled") + .opacity(0.9) + .styles(|styles| styles.disabled(|style| style.opacity(0.5))); + assert_eq!(enabled.resolved_style().opacity, Some(0.9)); + + let disabled = Button::new("disabled") + .styles(|styles| styles.disabled(|style| style.opacity(0.5))) + .opacity(0.9) + .disabled(true); + assert_eq!(disabled.resolved_style().opacity, Some(0.5)); + + let semantic_only = Button::new("semantic-only") + .styles(|styles| styles.disabled(|style| style.opacity(0.5))) + .disabled(true); + assert_eq!(semantic_only.resolved_style().opacity, Some(0.5)); + } + + #[test] + fn selected_disabled_and_instance_styles_follow_the_shared_priority() { + let selected_color = gpui::hsla(0.6, 0.7, 0.5, 1.0); + let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5); + let button = |selected, disabled| { + Button::new("button") + .selected(selected) + .disabled(disabled) + .styles(|styles| { + styles + .selected(|style| style.bg(selected_color)) + .disabled(|style| style.bg(disabled_color)) + }) + }; + + assert_eq!(button(false, false).resolved_style().background, None); + assert_eq!( + button(true, false).resolved_style().background, + Some(selected_color.into()) + ); + assert_eq!( + button(true, true).resolved_style().background, + Some(disabled_color.into()) + ); + assert_eq!( + button(true, true) + .bg(selected_color) + .resolved_style() + .background, + Some(disabled_color.into()) + ); + } + + #[gpui::test] + fn accessibility_role_label_and_disabled_action_surface(cx: &mut TestAppContext) { + type Captured = Arc>>; + + struct A11yProbe { + captured: Captured, + } + + impl Render for A11yProbe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.captured.clone(); + canvas( + move |_, window, cx| { + let mut info = |button: Button| { + let mut node = accesskit::Node::new(Role::Button); + button + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + node + }; + let enabled = info( + Button::new("enabled") + .accessibility_label("Save") + .on_click(|_, _, _| {}), + ); + let disabled = info( + Button::new("disabled") + .disabled(true) + .accessibility_label("Save") + .on_click(|_, _, _| {}), + ); + *captured.lock().unwrap() = Some((enabled, disabled)); + }, + |_, _, _, _| {}, + ) + } + } + + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured }); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + }); + let (enabled, disabled) = result.lock().unwrap().take().unwrap(); + assert_eq!(enabled.role(), Role::Button); + assert_eq!(enabled.label(), Some("Save")); + assert!(enabled.supports_action(accesskit::Action::Click)); + + assert_eq!(disabled.role(), Role::Button); + assert!(!disabled.supports_action(accesskit::Action::Click)); + + // GPUI's current StatefulInteractiveElement interface has no + // aria-disabled setter even though AccessKit can represent it. This + // assertion records that upstream gap instead of claiming support. + assert!(!disabled.is_disabled()); + } +} diff --git a/vendor/gpui-base/src/calendar.rs b/vendor/gpui-base/src/calendar.rs new file mode 100644 index 00000000..5ec36348 --- /dev/null +++ b/vendor/gpui-base/src/calendar.rs @@ -0,0 +1,1009 @@ +use std::rc::Rc; + +use crate::{h_flex, styled::StyledExt as _, v_flex}; +use chrono::{Datelike, Local, NaiveDate, Weekday}; +use gpui::{ + AnyElement, App, Context, ElementId, Empty, Entity, EventEmitter, FocusHandle, + InteractiveElement, IntoElement, ParentElement, Render, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, +}; + +/// A controlled calendar value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Date { + Single(Option), + Range(Option, Option), +} + +impl std::fmt::Display for Date { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Single(Some(date)) => write!(f, "{date}"), + Self::Single(None) | Self::Range(None, None) => write!(f, "nil"), + Self::Range(Some(start), Some(end)) => write!(f, "{start} - {end}"), + Self::Range(Some(start), None) => write!(f, "{start} - nil"), + Self::Range(None, Some(end)) => write!(f, "nil - {end}"), + } + } +} + +impl From for Date { + fn from(value: NaiveDate) -> Self { + Self::Single(Some(value)) + } +} +impl From<(NaiveDate, NaiveDate)> for Date { + fn from((start, end): (NaiveDate, NaiveDate)) -> Self { + Self::Range(Some(start), Some(end)) + } +} + +impl Date { + pub fn is_some(&self) -> bool { + matches!(self, Self::Single(Some(_)) | Self::Range(Some(_), _)) + } + pub fn is_complete(&self) -> bool { + matches!(self, Self::Single(Some(_)) | Self::Range(Some(_), Some(_))) + } + pub fn start(&self) -> Option { + match self { + Self::Single(Some(v)) | Self::Range(Some(v), _) => Some(*v), + _ => None, + } + } + pub fn end(&self) -> Option { + match self { + Self::Range(_, Some(v)) => Some(*v), + _ => None, + } + } + pub fn format(&self, format: &str) -> Option { + match self { + Self::Single(Some(v)) => Some(v.format(format).to_string().into()), + Self::Range(Some(a), Some(b)) => { + Some(format!("{} - {}", a.format(format), b.format(format)).into()) + } + _ => None, + } + } + pub fn is_active(&self, value: &NaiveDate) -> bool { + match self { + Self::Single(v) => *v == Some(*value), + Self::Range(a, b) => *a == Some(*value) || *b == Some(*value), + } + } + pub fn is_single(&self) -> bool { + matches!(self, Self::Single(_)) + } + pub fn is_in_range(&self, value: &NaiveDate) -> bool { + matches!(self, Self::Range(Some(a), Some(b)) if value >= a && value <= b) + } +} + +pub struct IntervalMatcher { + before: Option, + after: Option, +} +pub struct RangeMatcher { + from: Option, + to: Option, +} +pub enum Matcher { + DayOfWeek(Vec), + Interval(IntervalMatcher), + Range(RangeMatcher), + Custom(Box bool + Send + Sync>), +} +impl From> for Matcher { + fn from(v: Vec) -> Self { + Self::DayOfWeek(v) + } +} +impl bool + Send + Sync + 'static> From for Matcher { + fn from(v: F) -> Self { + Self::Custom(Box::new(v)) + } +} +impl Matcher { + pub fn interval(before: Option, after: Option) -> Self { + Self::Interval(IntervalMatcher { before, after }) + } + pub fn range(from: Option, to: Option) -> Self { + Self::Range(RangeMatcher { from, to }) + } + pub fn custom bool + Send + Sync + 'static>(f: F) -> Self { + Self::Custom(Box::new(f)) + } + pub fn is_match(&self, date: &Date) -> bool { + match date { + Date::Single(Some(v)) => self.matched(v), + Date::Range(Some(a), Some(b)) => self.matched(a) || self.matched(b), + _ => false, + } + } + pub fn matched(&self, date: &NaiveDate) -> bool { + match self { + Self::DayOfWeek(days) => days.contains(&date.weekday().num_days_from_sunday()), + Self::Interval(v) => { + v.before.is_some_and(|x| date < &x) || v.after.is_some_and(|x| date > &x) + } + Self::Range(v) => { + !v.from.is_some_and(|x| date < &x) && !v.to.is_some_and(|x| date > &x) + } + Self::Custom(f) => f(date), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CalendarView { + Day, + Month, + Year, +} +impl CalendarView { + pub fn is_day(self) -> bool { + self == Self::Day + } + pub fn is_month(self) -> bool { + self == Self::Month + } + pub fn is_year(self) -> bool { + self == Self::Year + } +} + +fn picker_grid_layout(view: CalendarView) -> Option<(u16, f32)> { + match view { + CalendarView::Day => None, + CalendarView::Month => Some((3, 4.)), + CalendarView::Year => Some((5, 4.)), + } +} + +pub enum CalendarEvent { + Selected(Date), +} + +pub struct CalendarState { + pub focus_handle: FocusHandle, + view: CalendarView, + date: Date, + current_year: i32, + current_month: u8, + years: Vec>, + year_page: i32, + today: NaiveDate, + number_of_months: usize, + disabled_matcher: Option>, +} + +impl CalendarState { + pub fn new(_: &mut Window, cx: &mut Context) -> Self { + let today = Local::now().date_naive(); + Self { + focus_handle: cx.focus_handle(), + view: CalendarView::Day, + date: Date::Single(None), + current_year: today.year(), + current_month: today.month() as u8, + years: vec![], + year_page: 0, + today, + number_of_months: 1, + disabled_matcher: None, + } + .year_range((today.year() - 50, today.year() + 50)) + } + pub fn disabled_matcher(mut self, matcher: impl Into) -> Self { + self.disabled_matcher = Some(Rc::new(matcher.into())); + self + } + pub fn set_disabled_matcher( + &mut self, + matcher: impl Into, + _: &mut Window, + _: &mut Context, + ) { + self.disabled_matcher = Some(Rc::new(matcher.into())); + } + pub fn set_disabled_matcher_shared(&mut self, matcher: Option>) { + self.disabled_matcher = matcher; + } + pub fn disabled_matcher_ref(&self) -> Option<&Matcher> { + self.disabled_matcher.as_deref() + } + pub fn set_date(&mut self, date: impl Into, _: &mut Window, cx: &mut Context) { + if self.apply_date(date.into()) { + cx.notify(); + } + } + pub fn apply_date(&mut self, date: Date) -> bool { + if self + .disabled_matcher + .as_ref() + .is_some_and(|m| m.is_match(&date)) + { + return false; + } + self.date = date; + if let Some(v) = date.start() { + self.current_month = v.month() as u8; + self.current_year = v.year(); + } + true + } + pub fn select_date(&mut self, value: NaiveDate) -> bool { + if self + .disabled_matcher + .as_ref() + .is_some_and(|m| m.matched(&value)) + { + return false; + } + let next = match self.date { + Date::Single(_) => Date::Single(Some(value)), + Date::Range(None, None) | Date::Range(None, Some(_)) => Date::Range(Some(value), None), + Date::Range(Some(start), None) if value >= start => { + Date::Range(Some(start), Some(value)) + } + Date::Range(Some(_), None) | Date::Range(Some(_), Some(_)) => { + Date::Range(Some(value), None) + } + }; + self.apply_date(next); + self.date.is_complete() + } + /// Activates a day item and emits [`CalendarEvent::Selected`] once the + /// controlled value is complete. This is the single pointer/keyboard + /// activation path used by the calendar root. + pub fn activate_date(&mut self, value: NaiveDate, cx: &mut Context) -> bool { + let complete = self.select_date(value); + if complete { + cx.emit(CalendarEvent::Selected(self.date())); + } + cx.notify(); + complete + } + pub fn date(&self) -> Date { + self.date + } + pub fn set_number_of_months(&mut self, n: usize, _: &mut Window, cx: &mut Context) { + self.number_of_months = n; + cx.notify(); + } + pub fn number_of_months(&self) -> usize { + self.number_of_months + } + pub fn year_range(mut self, range: (i32, i32)) -> Self { + self.apply_year_range(range); + self + } + pub fn set_year_range(&mut self, range: (i32, i32), cx: &mut Context) { + self.apply_year_range(range); + cx.notify(); + } + fn apply_year_range(&mut self, range: (i32, i32)) { + self.years = (range.0..range.1) + .collect::>() + .chunks(20) + .map(<[_]>::to_vec) + .collect(); + self.year_page = self + .years + .iter() + .position(|v| v.contains(&self.current_year)) + .unwrap_or(0) as i32; + } + pub fn offset_year_month(&self, offset: usize) -> (i32, u32) { + let n = self.current_month as i64 - 1 + offset as i64; + ( + self.current_year + n.div_euclid(12) as i32, + n.rem_euclid(12) as u32 + 1, + ) + } + pub fn days(&self) -> Vec> { + self.month_days().into_iter().flatten().collect() + } + /// Calendar weeks grouped by visible month. This preserves six-week + /// months and is the preferred rendering API. + pub fn month_days(&self) -> Vec>> { + (0..self.number_of_months) + .map(|n| { + days_in_month( + self.current_year, + self.current_month as u32 + n as u32, + Weekday::Sun, + ) + }) + .collect() + } + pub fn has_prev_year_page(&self) -> bool { + self.year_page > 0 + } + pub fn has_next_year_page(&self) -> bool { + self.year_page < self.years.len() as i32 - 1 + } + pub fn prev_year_page(&mut self) -> bool { + if !self.has_prev_year_page() { + false + } else { + self.year_page -= 1; + true + } + } + pub fn next_year_page(&mut self) -> bool { + if !self.has_next_year_page() { + false + } else { + self.year_page += 1; + true + } + } + pub fn prev_month(&mut self) { + if self.current_month == 1 { + self.current_year -= 1; + self.current_month = 12; + } else { + self.current_month -= 1; + } + } + pub fn next_month(&mut self) { + if self.current_month == 12 { + self.current_year += 1; + self.current_month = 1; + } else { + self.current_month += 1; + } + } + pub fn view(&self) -> CalendarView { + self.view + } + pub fn set_view(&mut self, view: CalendarView) { + self.view = view; + } + pub fn current_year(&self) -> i32 { + self.current_year + } + pub fn current_month(&self) -> u8 { + self.current_month + } + pub fn today(&self) -> NaiveDate { + self.today + } + pub fn years_on_page(&self) -> &[i32] { + self.years + .get(self.year_page as usize) + .map(Vec::as_slice) + .unwrap_or_default() + } + pub fn select_month(&mut self, month: u8) { + self.current_month = month; + self.view = CalendarView::Day; + } + pub fn select_year(&mut self, year: i32) { + self.current_year = year; + self.view = CalendarView::Day; + } +} +impl EventEmitter for CalendarState {} +impl Render for CalendarState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +/// The semantic kind of a calendar control rendered by [`Calendar`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CalendarItemKind { + Previous, + MonthToggle, + YearToggle, + Next, + Weekday, + Day, + Month, + Year, +} + +/// State exposed to a calendar item slot. Applications may use it solely to +/// decorate the unstyled primitive; all interaction remains owned by base. +/// +/// The fields are private and reached through the methods below, so that a new +/// one can be added without breaking the item slots. +#[derive(Clone, Copy, Debug)] +pub struct CalendarItemState { + kind: CalendarItemKind, + active: bool, + in_range: bool, + muted: bool, + disabled: bool, + today: bool, +} + +impl CalendarItemState { + /// Create a state for `kind`, with every flag off. + pub fn new(kind: CalendarItemKind) -> Self { + Self { + kind, + active: false, + in_range: false, + muted: false, + disabled: false, + today: false, + } + } + + pub fn active(mut self, active: bool) -> Self { + self.active = active; + self + } + + /// Set whether the item is between the two ends of a range selection. + pub fn in_range(mut self, in_range: bool) -> Self { + self.in_range = in_range; + self + } + + /// Set whether the item is shown as secondary, e.g.: a weekday header or a + /// day that belongs to the neighboring month. + pub fn muted(mut self, muted: bool) -> Self { + self.muted = muted; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn today(mut self, today: bool) -> Self { + self.today = today; + self + } + + pub fn kind(&self) -> CalendarItemKind { + self.kind + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn is_in_range(&self) -> bool { + self.in_range + } + + pub fn is_muted(&self) -> bool { + self.muted + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn is_today(&self) -> bool { + self.today + } +} + +/// An unstyled, pre-wired calendar item passed to the item slot. +#[derive(IntoElement)] +pub struct CalendarItem { + base: gpui::Stateful, + state: CalendarItemState, + style: StyleRefinement, + children: Vec, +} + +impl CalendarItem { + fn new(id: impl Into, state: CalendarItemState) -> Self { + Self { + base: div().id(id.into()), + state, + style: StyleRefinement::default(), + children: vec![], + } + } + pub fn item_state(&self) -> CalendarItemState { + self.state + } + + /// Remove the default label so a styled facade can provide custom content. + pub fn clear_children(mut self) -> Self { + self.children.clear(); + self + } +} +impl ParentElement for CalendarItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} +impl Styled for CalendarItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl InteractiveElement for CalendarItem { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} +impl StatefulInteractiveElement for CalendarItem {} +impl RenderOnce for CalendarItem { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base.children(self.children).refine_style(&self.style) + } +} + +type ItemRenderer = + Rc AnyElement>; +type Labeler = Rc SharedString>; + +/// Complete unstyled calendar structure and behavior. +/// +/// Base owns navigation, view switching, grids, disabled/selection state and +/// click handling. The UI crate only decorates the pre-wired item slot. +#[derive(IntoElement)] +pub struct Calendar { + id: ElementId, + state: Entity, + number_of_months: usize, + first_day_of_week: Weekday, + style: StyleRefinement, + item: ItemRenderer, + label: Labeler, +} + +impl Calendar { + pub fn new(id: impl Into, state: &Entity) -> Self { + Self { + id: id.into(), + state: state.clone(), + number_of_months: 1, + first_day_of_week: Weekday::Sun, + style: StyleRefinement::default(), + item: Rc::new(|item, _, _, _| item.into_any_element()), + label: Rc::new(|kind, value| match kind { + CalendarItemKind::Previous => "‹".into(), + CalendarItemKind::Next => "›".into(), + CalendarItemKind::Weekday => value.to_string().into(), + _ => value.to_string().into(), + }), + } + } + pub fn number_of_months(mut self, count: usize) -> Self { + self.number_of_months = count.max(1); + self + } + pub fn first_day_of_week(mut self, day: Weekday) -> Self { + self.first_day_of_week = day; + self + } + pub fn item( + mut self, + render: impl Fn(CalendarItem, CalendarItemState, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.item = Rc::new(render); + self + } + pub fn label( + mut self, + label: impl Fn(CalendarItemKind, i32) -> SharedString + 'static, + ) -> Self { + self.label = Rc::new(label); + self + } + + fn render_item( + &self, + id: impl Into, + state: CalendarItemState, + value: i32, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let label = (self.label)(state.kind(), value); + (self.item)(CalendarItem::new(id, state).child(label), state, window, cx) + } +} +impl Styled for Calendar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Calendar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.number_of_months; + self.state + .update(cx, |s, cx| s.set_number_of_months(count, window, cx)); + let view = self.state.read(cx).view(); + let mut header = h_flex().items_center().justify_between().child({ + let st = CalendarItemState::new(CalendarItemKind::Previous).disabled( + view.is_month() || (view.is_year() && !self.state.read(cx).has_prev_year_page()), + ); + let mut item = CalendarItem::new("calendar-prev", st).child((self.label)(st.kind(), 0)); + if !st.is_disabled() { + let entity = self.state.clone(); + item = item.on_click(move |_, _window, cx| { + entity.update(cx, |s, cx| { + if s.view().is_day() { + s.prev_month(); + } else { + s.prev_year_page(); + } + cx.notify(); + }) + }); + } + (self.item)(item, st, window, cx) + }); + if count == 1 { + let (month, year) = { + let s = self.state.read(cx); + (s.current_month() as i32, s.current_year()) + }; + for (kind, value, active) in [ + (CalendarItemKind::MonthToggle, month, view.is_month()), + (CalendarItemKind::YearToggle, year, view.is_year()), + ] { + let st = CalendarItemState::new(kind).active(active); + let entity = self.state.clone(); + let mut item = CalendarItem::new(format!("calendar-{kind:?}"), st) + .child((self.label)(kind, value)); + item = item.on_click(move |_, _, cx| { + entity.update(cx, |s, cx| { + s.set_view( + if s.view() + == match kind { + CalendarItemKind::MonthToggle => CalendarView::Month, + _ => CalendarView::Year, + } + { + CalendarView::Day + } else { + match kind { + CalendarItemKind::MonthToggle => CalendarView::Month, + _ => CalendarView::Year, + } + }, + ); + cx.notify(); + }) + }); + header = header.child((self.item)(item, st, window, cx)); + } + } else { + for offset in 0..count { + let (y, m) = self.state.read(cx).offset_year_month(offset); + header = header.child( + div().text_sm().font_medium().child( + v_flex() + .items_center() + .child((self.label)(CalendarItemKind::MonthToggle, m as i32)) + .child(y.to_string()), + ), + ); + } + } + header = header.child({ + let st = CalendarItemState::new(CalendarItemKind::Next).disabled( + view.is_month() || (view.is_year() && !self.state.read(cx).has_next_year_page()), + ); + let mut item = CalendarItem::new("calendar-next", st).child((self.label)(st.kind(), 0)); + if !st.is_disabled() { + let entity = self.state.clone(); + item = item.on_click(move |_, _, cx| { + entity.update(cx, |s, cx| { + if s.view().is_day() { + s.next_month() + } else { + s.next_year_page(); + } + cx.notify(); + }) + }); + } + (self.item)(item, st, window, cx) + }); + + let mut body = match picker_grid_layout(view) { + None => h_flex().justify_around(), + Some((columns, horizontal_gap)) => { + div().grid().grid_cols(columns).gap_x(px(horizontal_gap)) + } + }; + if view.is_day() { + for offset in 0..count { + let (year, month_number) = self.state.read(cx).offset_year_month(offset); + let weeks = days_in_month(year, month_number, self.first_day_of_week); + let mut month = v_flex(); + let mut header_row = h_flex(); + for weekday in 0..7 { + let st = CalendarItemState::new(CalendarItemKind::Weekday) + .muted(true) + .disabled(true); + header_row = header_row.child(self.render_item( + format!("weekday-{offset}-{weekday}"), + st, + (weekday + self.first_day_of_week.num_days_from_sunday() as i32) % 7, + window, + cx, + )); + } + month = month.child(header_row); + for (week_index, week) in weeks.iter().enumerate() { + let mut week_row = h_flex(); + for date in week { + let date = *date; + let st = { + let s = self.state.read(cx); + let (_, m) = s.offset_year_month(offset); + let disabled = + s.disabled_matcher_ref().is_some_and(|x| x.matched(&date)); + CalendarItemState::new(CalendarItemKind::Day) + .active(s.date().is_active(&date)) + .in_range(s.date().is_in_range(&date)) + .muted(date.month() != m || disabled) + .disabled(disabled) + .today(date == s.today()) + }; + let mut item = + CalendarItem::new(format!("calendar-{date}-{offset}-{week_index}"), st) + .child((self.label)(st.kind(), date.day() as i32)); + if !st.is_disabled() { + let entity = self.state.clone(); + item = item.on_click(move |_, _, cx| { + entity.update(cx, |s, cx| { + s.activate_date(date, cx); + }) + }); + } + week_row = week_row.child((self.item)(item, st, window, cx)); + } + month = month.child(week_row); + } + body = body.child(month); + } + } else if view.is_month() { + let current = self.state.read(cx).current_month(); + for month in 1..=12u8 { + let st = CalendarItemState::new(CalendarItemKind::Month).active(month == current); + let entity = self.state.clone(); + let item = CalendarItem::new(format!("calendar-month-{month}"), st) + .child((self.label)(st.kind(), month as i32)) + .on_click(move |_, _, cx| { + entity.update(cx, |s, cx| { + s.select_month(month); + cx.notify(); + }) + }); + body = body.child((self.item)(item, st, window, cx)); + } + } else { + let current = self.state.read(cx).current_year(); + let years = self.state.read(cx).years_on_page().to_vec(); + for year in years { + let st = CalendarItemState::new(CalendarItemKind::Year).active(year == current); + let entity = self.state.clone(); + let item = CalendarItem::new(format!("calendar-year-{year}"), st) + .child((self.label)(st.kind(), year)) + .on_click(move |_, _, cx| { + entity.update(cx, |s, cx| { + s.select_year(year); + cx.notify(); + }) + }); + body = body.child((self.item)(item, st, window, cx)); + } + } + v_flex() + .id(self.id) + .track_focus(&self.state.read(cx).focus_handle) + .child(header) + .child(body) + .refine_style(&self.style) + } +} + +fn days_in_month(year: i32, month: u32, first_day: Weekday) -> Vec> { + let total = year as i64 * 12 + month as i64 - 1; + let year = total.div_euclid(12) as i32; + let month = total.rem_euclid(12) as u32 + 1; + let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let next = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() + }; + let offset = + (first.weekday().num_days_from_sunday() + 7 - first_day.num_days_from_sunday()) % 7; + let start = first - chrono::Duration::days(offset as i64); + let count = ((next - start).num_days() as usize).div_ceil(7) * 7; + (0..count) + .map(|n| start + chrono::Duration::days(n as i64)) + .collect::>() + .chunks(7) + .map(<[_]>::to_vec) + .collect() +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc}; + + use gpui::{AppContext as _, Context, Entity, IntoElement, Render, Subscription, Window}; + + use super::*; + + struct EventHarness { + calendar: Entity, + events: Rc>>, + _subscription: Option, + } + impl EventHarness { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let calendar = cx.new(|cx| CalendarState::new(window, cx)); + let events = Rc::new(RefCell::new(Vec::new())); + let mut this = Self { + calendar: calendar.clone(), + events: events.clone(), + _subscription: None, + }; + this._subscription = Some(cx.subscribe(&calendar, move |_, _, event, _| { + let CalendarEvent::Selected(date) = event; + events.borrow_mut().push(*date); + })); + this + } + } + impl Render for EventHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } + } + fn state(cx: &mut gpui::TestAppContext, date: Date) -> gpui::Entity { + let (state, _) = cx.add_window_view(CalendarState::new); + state.update(cx, |state, _| { + state.date = date; + }); + state + } + #[gpui::test] + fn range_selection_restarts_and_completes(cx: &mut gpui::TestAppContext) { + let s = state(cx, Date::Range(None, None)); + let a = NaiveDate::from_ymd_opt(2025, 2, 10).unwrap(); + let b = NaiveDate::from_ymd_opt(2025, 2, 12).unwrap(); + s.update(cx, |s, _| { + assert!(!s.select_date(a)); + assert!(s.select_date(b)); + }); + assert_eq!( + s.read_with(cx, |s, _| s.date()), + Date::Range(Some(a), Some(b)) + ); + s.update(cx, |s, _| assert!(!s.select_date(a))); + assert_eq!(s.read_with(cx, |s, _| s.date()), Date::Range(Some(a), None)); + } + #[gpui::test] + fn disabled_date_is_rejected(cx: &mut gpui::TestAppContext) { + let s = state(cx, Date::Single(None)); + s.update(cx, |s, _| { + s.disabled_matcher = Some(Rc::new(Matcher::range( + Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), + Some(NaiveDate::from_ymd_opt(2025, 1, 31).unwrap()), + ))) + }); + s.update(cx, |s, _| { + assert!(!s.select_date(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap())) + }); + } + #[gpui::test] + fn month_navigation_crosses_year(cx: &mut gpui::TestAppContext) { + let s = state( + cx, + Date::Single(Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap())), + ); + s.update(cx, |s, _| { + s.apply_date(Date::Single(Some( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + ))); + s.prev_month(); + }); + assert_eq!( + s.read_with(cx, |s, _| (s.current_year(), s.current_month())), + (2024, 12) + ); + s.update(cx, |s, _| s.next_month()); + assert_eq!( + s.read_with(cx, |s, _| (s.current_year(), s.current_month())), + (2025, 1) + ); + } + + #[gpui::test] + fn six_week_month_is_not_truncated(cx: &mut gpui::TestAppContext) { + let s = state( + cx, + Date::Single(Some(NaiveDate::from_ymd_opt(2025, 8, 1).unwrap())), + ); + s.update(cx, |s, _| { + s.apply_date(Date::Single(Some( + NaiveDate::from_ymd_opt(2025, 8, 1).unwrap(), + ))); + assert_eq!(s.month_days().len(), 1); + assert_eq!(s.month_days()[0].len(), 6); + assert_eq!(s.days().len(), 6); + assert_eq!(s.month_days()[0][5][0].day(), 31); + }); + } + + #[gpui::test] + fn day_month_and_year_views_have_complete_transitions(cx: &mut gpui::TestAppContext) { + let s = state(cx, Date::Single(None)); + s.update(cx, |s, _| { + assert_eq!(s.view(), CalendarView::Day); + s.set_view(CalendarView::Month); + s.select_month(11); + assert_eq!((s.view(), s.current_month()), (CalendarView::Day, 11)); + s.set_view(CalendarView::Year); + s.select_year(2032); + assert_eq!((s.view(), s.current_year()), (CalendarView::Day, 2032)); + }); + } + + #[test] + fn picker_views_use_stable_grid_layouts() { + assert_eq!(picker_grid_layout(CalendarView::Month), Some((3, 4.))); + assert_eq!(picker_grid_layout(CalendarView::Year), Some((5, 4.))); + assert_eq!(picker_grid_layout(CalendarView::Day), None); + } + + #[gpui::test] + fn year_page_navigation_respects_both_bounds(cx: &mut gpui::TestAppContext) { + let s = state(cx, Date::Single(None)); + s.update(cx, |s, _| { + s.apply_year_range((2000, 2041)); + while s.prev_year_page() {} + assert!(!s.has_prev_year_page()); + assert!(!s.prev_year_page()); + assert!(s.next_year_page()); + while s.next_year_page() {} + assert!(!s.has_next_year_page()); + assert!(!s.next_year_page()); + }); + } + + #[gpui::test] + fn activation_emits_only_for_complete_enabled_values(cx: &mut gpui::TestAppContext) { + let (harness, _) = cx.add_window_view(EventHarness::new); + let s = harness.read_with(cx, |h, _| h.calendar.clone()); + s.update(cx, |s, _| s.date = Date::Range(None, None)); + let start = NaiveDate::from_ymd_opt(2025, 4, 4).unwrap(); + let end = NaiveDate::from_ymd_opt(2025, 4, 8).unwrap(); + s.update(cx, |s, cx| { + assert!(!s.activate_date(start, cx)); + assert!(s.activate_date(end, cx)); + assert_eq!(s.date(), Date::Range(Some(start), Some(end))); + s.set_disabled_matcher_shared(Some(Rc::new(Matcher::custom(move |d| *d == start)))); + assert!(!s.activate_date(start, cx)); + assert_eq!(s.date(), Date::Range(Some(start), Some(end))); + }); + assert_eq!( + harness.read_with(cx, |h, _| h.events.borrow().clone()), + vec![Date::Range(Some(start), Some(end))] + ); + + s.update(cx, |s, cx| { + s.date = Date::Single(None); + assert!(s.activate_date(end, cx)); + assert_eq!(s.date(), Date::Single(Some(end))); + }); + assert_eq!( + harness.read_with(cx, |h, _| h.events.borrow().clone()), + vec![Date::Range(Some(start), Some(end)), Date::Single(Some(end))] + ); + } +} diff --git a/vendor/gpui-base/src/checkbox.rs b/vendor/gpui-base/src/checkbox.rs new file mode 100644 index 00000000..7c8330a8 --- /dev/null +++ b/vendor/gpui-base/src/checkbox.rs @@ -0,0 +1,704 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, ParentElement, Refineable as _, RenderOnce, Role, SharedString, Stateful, + StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{RoleOverride, StateStyle, StyledExt as _}; + +type ChangeHandler = Rc; + +/// The semantic value exposed by an unstyled [`Checkbox`]. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CheckboxState { + #[default] + Unchecked, + Checked, + Indeterminate, +} + +impl CheckboxState { + fn toggled(self) -> Toggled { + match self { + Self::Unchecked => Toggled::False, + Self::Checked => Toggled::True, + Self::Indeterminate => Toggled::Mixed, + } + } + + fn activated(self) -> Self { + match self { + Self::Unchecked | Self::Indeterminate => Self::Checked, + Self::Checked => Self::Unchecked, + } + } +} + +/// An unstyled checkbox that owns toggle, focus, keyboard, and accessibility behavior. +/// +/// The application owns all layout and visual state rendering. Child elements can +/// present the check mark, indeterminate mark, label, or any other design-system UI. +#[derive(IntoElement)] +pub struct Checkbox { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + semantic_styles: CheckboxStyles, + state: CheckboxState, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_change: Option, + accessibility_label: Option, + tab_index: isize, + tab_stop: bool, + provided_focus_handle: Option, + role: RoleOverride, +} + +impl Checkbox { + /// Creates an unchecked checkbox with a stable element identifier. + pub fn new(id: impl Into) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + semantic_styles: CheckboxStyles::default(), + state: CheckboxState::Unchecked, + disabled: false, + children: SmallVec::new(), + on_change: None, + accessibility_label: None, + tab_index: 0, + tab_stop: true, + provided_focus_handle: None, + role: RoleOverride::Implicit, + } + } + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + /// Sets the controlled semantic state. + pub fn state(mut self, state: CheckboxState) -> Self { + self.state = state; + self + } + + /// Sets the checked state, clearing any indeterminate state. + pub fn checked(self, checked: bool) -> Self { + self.state(if checked { + CheckboxState::Checked + } else { + CheckboxState::Unchecked + }) + } + + /// Sets or clears the indeterminate state. + /// + /// Clearing indeterminate leaves the checkbox unchecked. Applications with a + /// controlled value can call [`Self::state`] when another fallback is desired. + pub fn indeterminate(self, indeterminate: bool) -> Self { + if indeterminate { + self.state(CheckboxState::Indeterminate) + } else if self.state == CheckboxState::Indeterminate { + self.state(CheckboxState::Unchecked) + } else { + self + } + } + + /// Sets whether pointer and keyboard activation are ignored. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Defines application-owned styles for the checkbox's semantic states. + pub fn styles(mut self, build: impl FnOnce(CheckboxStyles) -> CheckboxStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + /// Sets the label exposed to accessibility clients. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Handles an activation with the next controlled state. + /// + /// An indeterminate checkbox becomes checked when activated. The activating + /// [`ClickEvent`] is reported so callers can read its modifiers, for example + /// to extend a selection. + pub fn on_change( + mut self, + handler: impl Fn(CheckboxState, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Sets the focus traversal index. The default is `0`. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + /// Sets whether this checkbox participates in keyboard focus traversal. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + /// Uses a caller-owned focus handle instead of creating keyed state. + pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { + self.provided_focus_handle = Some(focus_handle.clone()); + self + } + + fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle { + self.provided_focus_handle.clone().unwrap_or_else(|| { + window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + }) + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + match self.state { + CheckboxState::Unchecked => None, + CheckboxState::Checked => Some(&self.semantic_styles.checked), + CheckboxState::Indeterminate => Some(&self.semantic_styles.indeterminate), + }, + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +/// Semantic styles supported by [`Checkbox`]. +#[derive(Default)] +pub struct CheckboxStyles { + checked: StyleRefinement, + indeterminate: StyleRefinement, + disabled: StyleRefinement, +} + +impl CheckboxStyles { + /// Refines the root style when the checkbox is checked. + pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.checked + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + /// Refines the root style when the checkbox is indeterminate. + pub fn indeterminate(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.indeterminate + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + /// Refines the root style when the checkbox is disabled. + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +/// An unstyled checkbox indicator part with typed checkbox-state projection. +/// +/// This renders its `Div` directly. Applications own its geometry, visual +/// presentation, and children. +#[derive(IntoElement)] +pub struct CheckboxIndicator { + base: Div, + style: StyleRefinement, + semantic_styles: CheckboxIndicatorStyles, + state: CheckboxState, + disabled: bool, + children: SmallVec<[AnyElement; 1]>, +} + +/// Semantic styles supported by [`CheckboxIndicator`]. +#[derive(Default)] +pub struct CheckboxIndicatorStyles { + checked: StyleRefinement, + indeterminate: StyleRefinement, + disabled: StyleRefinement, +} + +impl CheckboxIndicatorStyles { + pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.checked + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn indeterminate(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.indeterminate + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl CheckboxIndicator { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + semantic_styles: CheckboxIndicatorStyles::default(), + state: CheckboxState::Unchecked, + disabled: false, + children: SmallVec::new(), + } + } + + pub fn state(mut self, state: CheckboxState) -> Self { + self.state = state; + self + } + + pub fn checked(self, checked: bool) -> Self { + self.state(if checked { + CheckboxState::Checked + } else { + CheckboxState::Unchecked + }) + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn styles( + mut self, + build: impl FnOnce(CheckboxIndicatorStyles) -> CheckboxIndicatorStyles, + ) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + match self.state { + CheckboxState::Unchecked => None, + CheckboxState::Checked => Some(&self.semantic_styles.checked), + CheckboxState::Indeterminate => Some(&self.semantic_styles.indeterminate), + }, + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +impl Default for CheckboxIndicator { + fn default() -> Self { + Self::new() + } +} + +impl Styled for CheckboxIndicator { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for CheckboxIndicator { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for CheckboxIndicator { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let style = self.resolved_style(); + self.base.children(self.children).refine_style(&style) + } +} + +impl Styled for Checkbox { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Checkbox { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Checkbox { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Checkbox {} + +impl RenderOnce for Checkbox { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle(window, cx); + let disabled = self.disabled; + let next_state = self.state.activated(); + let style = self.resolved_style(); + let on_change = self.on_change; + + self.base + .when_some(self.role.resolve(|| Role::CheckBox), |this, role| { + this.role(role) + }) + .aria_toggled(self.state.toggled()) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .when(!disabled, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when_some( + (!disabled).then_some(on_change).flatten(), + |this, on_change| { + this.on_click(move |event, window, cx| { + on_change(next_state, event, window, cx); + }) + }, + ) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + TestAppContext, VisualTestContext, accesskit, canvas, point, px, + }; + + #[test] + fn indicator_projects_state_styles_over_the_instance_layer() { + let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0); + let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5); + + let indicator = |state, disabled| { + CheckboxIndicator::new() + .state(state) + .disabled(disabled) + .styles(|styles| { + styles + .checked(|style| style.border_color(checked_color)) + .indeterminate(|style| style.opacity(0.7)) + .disabled(|style| style.border_color(disabled_color)) + }) + }; + + assert_eq!( + indicator(CheckboxState::Checked, false) + .resolved_style() + .border_color, + Some(checked_color) + ); + assert_eq!( + indicator(CheckboxState::Checked, true) + .resolved_style() + .border_color, + Some(disabled_color) + ); + assert_eq!( + indicator(CheckboxState::Indeterminate, false) + .resolved_style() + .opacity, + Some(0.7) + ); + assert_eq!( + indicator(CheckboxState::Checked, true) + .border_color(checked_color) + .resolved_style() + .border_color, + Some(disabled_color) + ); + } + + struct CheckboxHarness { + state: CheckboxState, + disabled: bool, + changes: Rc>>, + parent_clicks: Rc>, + } + + impl Render for CheckboxHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let parent_clicks = self.parent_clicks.clone(); + div() + .id("checkbox-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Checkbox::new("checkbox") + .state(self.state) + .disabled(self.disabled) + .size_full() + .on_change(move |state, _, _, _| changes.borrow_mut().push(state)), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + state: CheckboxState, + disabled: bool, + ) -> ( + &mut VisualTestContext, + Rc>>, + Rc>, + ) { + let changes = Rc::new(RefCell::new(Vec::new())); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let changes = changes.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| CheckboxHarness { + state, + disabled, + changes, + parent_clicks, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, changes, parent_clicks) + } + + #[gpui::test] + fn pointer_activation_emits_the_next_state_once(cx: &mut TestAppContext) { + let (cx, changes, _) = harness(cx, CheckboxState::Unchecked, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(&*changes.borrow(), &[CheckboxState::Checked]); + } + + #[gpui::test] + fn indeterminate_activation_becomes_checked(cx: &mut TestAppContext) { + let (cx, changes, _) = harness(cx, CheckboxState::Indeterminate, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(&*changes.borrow(), &[CheckboxState::Checked]); + } + + #[gpui::test] + fn enter_and_space_each_emit_once(cx: &mut TestAppContext) { + let (cx, changes, _) = harness(cx, CheckboxState::Checked, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + changes.borrow_mut().clear(); + cx.update(|window, cx| { + assert!(window.focused(cx).is_some()); + window.draw(cx).clear(cx); + }); + + for key in ["enter", "space"] { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + assert_eq!( + &*changes.borrow(), + &[CheckboxState::Unchecked, CheckboxState::Unchecked] + ); + } + + #[gpui::test] + fn disabled_checkbox_is_inert_and_allows_pointer_events_to_bubble(cx: &mut TestAppContext) { + let (cx, changes, parent_clicks) = harness(cx, CheckboxState::Unchecked, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.update(|window, cx| window.focus_next(cx)); + cx.simulate_keystrokes("enter space"); + assert!(changes.borrow().is_empty()); + assert_eq!(parent_clicks.get(), 1); + } + + #[test] + fn controlled_state_and_styling_are_application_owned() { + let _ = Checkbox::new("states") + .checked(true) + .indeterminate(true) + .styles(|styles| { + styles + .checked(|style| style.opacity(0.8)) + .indeterminate(|style| style.opacity(0.7)) + .disabled(|style| style.when(true, |style| style.opacity(0.5))) + }) + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.8)) + .focus_visible(|style| style.opacity(0.7)); + } + + #[test] + fn semantic_root_styles_follow_checkbox_priority() { + let styles = |checkbox: Checkbox| { + checkbox.styles(|styles| { + styles + .checked(|style| style.opacity(0.8)) + .indeterminate(|style| style.opacity(0.7)) + .disabled(|style| style.opacity(0.5)) + }) + }; + + assert_eq!( + styles(Checkbox::new("normal")).resolved_style().opacity, + None + ); + assert_eq!( + styles(Checkbox::new("checked").checked(true)) + .resolved_style() + .opacity, + Some(0.8) + ); + assert_eq!( + styles(Checkbox::new("indeterminate").indeterminate(true)) + .resolved_style() + .opacity, + Some(0.7) + ); + assert_eq!( + styles(Checkbox::new("disabled").disabled(true)) + .resolved_style() + .opacity, + Some(0.5) + ); + assert_eq!( + styles( + Checkbox::new("checked-disabled") + .checked(true) + .disabled(true), + ) + .resolved_style() + .opacity, + Some(0.5) + ); + + let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0); + let combined = Checkbox::new("combined") + .checked(true) + .disabled(true) + .styles(|styles| { + styles + .checked(|style| style.border_color(checked_color)) + .disabled(|style| style.opacity(0.5)) + }) + .resolved_style(); + assert_eq!(combined.border_color, Some(checked_color)); + assert_eq!(combined.opacity, Some(0.5)); + + let state_over_instance = styles( + Checkbox::new("state-over-instance") + .checked(true) + .disabled(true) + .opacity(0.9), + ); + assert_eq!(state_over_instance.resolved_style().opacity, Some(0.5)); + } + + #[gpui::test] + fn accessibility_exposes_role_label_and_all_toggle_states(cx: &mut TestAppContext) { + type Captured = Arc>>; + + struct A11yProbe { + captured: Captured, + } + + impl Render for A11yProbe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.captured.clone(); + canvas( + move |_, window, cx| { + let mut info = |checkbox: Checkbox| { + let mut node = accesskit::Node::new(Role::CheckBox); + checkbox + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + node + }; + *captured.lock().unwrap() = Some([ + info( + Checkbox::new("unchecked") + .accessibility_label("Remember me") + .on_change(|_, _, _, _| {}), + ), + info( + Checkbox::new("checked") + .checked(true) + .on_change(|_, _, _, _| {}), + ), + info( + Checkbox::new("mixed") + .indeterminate(true) + .on_change(|_, _, _, _| {}), + ), + info( + Checkbox::new("disabled") + .disabled(true) + .on_change(|_, _, _, _| {}), + ), + ]); + }, + |_, _, _, _| {}, + ) + } + } + + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let [unchecked, checked, mixed, disabled] = result.lock().unwrap().take().unwrap(); + + assert_eq!(unchecked.role(), Role::CheckBox); + assert_eq!(unchecked.label(), Some("Remember me")); + assert_eq!(unchecked.toggled(), Some(Toggled::False)); + assert_eq!(checked.toggled(), Some(Toggled::True)); + assert_eq!(mixed.toggled(), Some(Toggled::Mixed)); + assert!(unchecked.supports_action(accesskit::Action::Click)); + assert!(!disabled.supports_action(accesskit::Action::Click)); + } +} diff --git a/vendor/gpui-base/src/collapsible.rs b/vendor/gpui-base/src/collapsible.rs new file mode 100644 index 00000000..b3d190a3 --- /dev/null +++ b/vendor/gpui-base/src/collapsible.rs @@ -0,0 +1,95 @@ +use gpui::{AnyElement, App, Div, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; + +enum Child { + Element(AnyElement), + Content(AnyElement), +} + +/// An unstyled controlled region whose content can be expanded or collapsed. +#[derive(IntoElement)] +pub struct Collapsible { + base: Div, + children: Vec, + open: bool, +} + +impl Collapsible { + pub fn new() -> Self { + Self { + base: div(), + children: Vec::new(), + open: false, + } + } + + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + pub fn content(mut self, content: impl IntoElement) -> Self { + self.children + .push(Child::Content(content.into_any_element())); + self + } +} + +impl Default for Collapsible { + fn default() -> Self { + Self::new() + } +} + +impl Styled for Collapsible { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Collapsible { + fn extend(&mut self, elements: impl IntoIterator) { + self.children + .extend(elements.into_iter().map(Child::Element)); + } +} + +impl RenderOnce for Collapsible { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .children(self.children.into_iter().filter_map(|child| match child { + Child::Element(element) => Some(element), + Child::Content(content) => self.open.then_some(content), + })) + } +} + +#[cfg(test)] +mod tests { + use gpui::{Context, InteractiveElement as _, Render, TestAppContext, px}; + + use super::*; + + struct Harness(bool); + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Collapsible::new() + .open(self.0) + .child(div().debug_selector(|| "trigger".into()).size(px(10.))) + .content(div().debug_selector(|| "content".into()).size(px(10.))) + } + } + + #[gpui::test] + fn content_is_only_rendered_while_open(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| Harness(false)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("trigger").is_some()); + assert!(cx.debug_bounds("content").is_none()); + + let (_, cx) = cx.add_window_view(|_, _| Harness(true)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("trigger").is_some()); + assert!(cx.debug_bounds("content").is_some()); + } +} diff --git a/vendor/gpui-base/src/color_picker.rs b/vendor/gpui-base/src/color_picker.rs new file mode 100644 index 00000000..66015e01 --- /dev/null +++ b/vendor/gpui-base/src/color_picker.rs @@ -0,0 +1,995 @@ +use crate::input::InputState; +use std::rc::Rc; + +use gpui::{ + AnyElement, App, AppContext as _, ClickEvent, Context, Div, ElementId, Entity, EventEmitter, + FocusHandle, Focusable, Hsla, InteractiveElement, Interactivity, IntoElement, KeyBinding, + ParentElement, Render, RenderOnce, Rgba, Role, SharedString, Stateful, + StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Toggled, Window, div, hsla, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{ + RoleOverride, StyledExt as _, + actions::{Cancel, Confirm}, + input::InputEvent, + slider::{SliderEvent, SliderState}, +}; + +const CONTEXT: &str = "ColorPicker"; + +/// The hex values this picker's text field accepts while being typed. +const HEX_PATTERN: &str = r"^#[0-9a-fA-F]{0,8}$"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + ]); +} + +/// Parses `#rgb`, `#rgba`, `#rrggbb`, and `#rrggbbaa`, with or without the `#`. +fn parse_hex(value: &str) -> Option { + let value = value.strip_prefix('#').unwrap_or(value); + // `from_str_radix` accepts a leading sign, so reject anything that is not + // purely hexadecimal before slicing components out of it. + if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + + let (width, has_alpha) = match value.len() { + 3 => (1, false), + 4 => (1, true), + 6 => (2, false), + 8 => (2, true), + _ => return None, + }; + + let component = |index: usize| { + let start = index * width; + let raw = u8::from_str_radix(&value[start..start + width], 16).ok()?; + // A single digit repeats itself rather than scaling, so `#fff` is white. + let raw = if width == 1 { raw * 0x11 } else { raw }; + Some(raw as f32 / 255.0) + }; + + Some( + Rgba { + r: component(0)?, + g: component(1)?, + b: component(2)?, + a: if has_alpha { component(3)? } else { 1.0 }, + } + .into(), + ) +} + +/// Formats a color as `#RRGGBB`, or `#RRGGBBAA` when it is translucent. +fn hex_string(color: Hsla) -> String { + let rgba = Rgba::from(color); + let channel = |value: f32| (value * 255.) as u32; + if rgba.a < 1. { + format!( + "#{:02X}{:02X}{:02X}{:02X}", + channel(rgba.r), + channel(rgba.g), + channel(rgba.b), + channel(rgba.a) + ) + } else { + format!( + "#{:02X}{:02X}{:02X}", + channel(rgba.r), + channel(rgba.g), + channel(rgba.b) + ) + } +} + +/// Events emitted by a [`ColorPickerState`]. +#[derive(Clone)] +pub enum ColorPickerEvent { + /// The committed color changed. + Change(Option), +} + +/// The four component sliders owned by a [`ColorPickerState`]. +/// +/// Applications render these with their own slider presentation; the picker +/// keeps them in sync with the committed color. +#[derive(Clone)] +pub struct HslaSliders { + hue: Entity, + saturation: Entity, + lightness: Entity, + alpha: Entity, +} + +impl HslaSliders { + fn new(cx: &mut App) -> Self { + let component = |cx: &mut App| { + cx.new(|_| { + SliderState::new() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.) + }) + }; + + Self { + hue: component(cx), + saturation: component(cx), + lightness: component(cx), + alpha: component(cx), + } + } + + /// The hue slider, in `0..=1`. + pub fn hue(&self) -> &Entity { + &self.hue + } + + /// The saturation slider, in `0..=1`. + pub fn saturation(&self) -> &Entity { + &self.saturation + } + + /// The lightness slider, in `0..=1`. + pub fn lightness(&self) -> &Entity { + &self.lightness + } + + /// The alpha slider, in `0..=1`. + pub fn alpha(&self) -> &Entity { + &self.alpha + } + + fn read(&self, cx: &App) -> Hsla { + hsla( + self.hue.read(cx).value().start(), + self.saturation.read(cx).value().start(), + self.lightness.read(cx).value().start(), + self.alpha.read(cx).value().start(), + ) + } + + fn write(&self, color: Hsla, window: &mut Window, cx: &mut App) { + let components = [ + (&self.hue, color.h), + (&self.saturation, color.s), + (&self.lightness, color.l), + (&self.alpha, color.a), + ]; + for (slider, value) in components { + slider.update(cx, |slider, cx| slider.set_value(value, window, cx)); + } + } +} + +/// State and interaction model for a color picker. +/// +/// This owns the committed color, the transient preview shown while the user +/// hovers or edits, the controlled open state, the active panel, and the hex +/// field and component sliders that stay in sync with all of them. The +/// application owns the palette, popup, layout, and every visual decision. +pub struct ColorPickerState { + focus_handle: FocusHandle, + value: Option, + preview: Option, + open: bool, + active_tab: usize, + hex_input: Entity, + sliders: HslaSliders, + /// A builder-supplied value cannot reach the sliders without a `Window`, + /// so the first render flushes it through [`Self::sync_pending_value`]. + needs_slider_sync: bool, + suppress_input_change: bool, + _subscriptions: Vec, +} + +impl ColorPickerState { + /// Creates an empty, closed picker. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let hex_input = cx + .new(|cx| InputState::new(window, cx).pattern(regex::Regex::new(HEX_PATTERN).unwrap())); + let sliders = HslaSliders::new(cx); + + let mut subscriptions = vec![cx.subscribe_in( + &hex_input, + window, + |this, input, event: &InputEvent, window, cx| match event { + InputEvent::Change => { + if this.suppress_input_change { + this.suppress_input_change = false; + return; + } + let value = input.read(cx).value(); + if this.preview_hex(value.as_str(), window, cx) { + let color = this.preview.expect("valid hex leaves a preview"); + this.sliders.write(color, window, cx); + } + } + InputEvent::PressEnter { .. } => { + let value = input.read(cx).value(); + this.commit_hex(value.as_str(), window, cx); + } + _ => {} + }, + )]; + + subscriptions.extend( + [ + sliders.hue.clone(), + sliders.saturation.clone(), + sliders.lightness.clone(), + sliders.alpha.clone(), + ] + .iter() + .map(|slider| { + cx.subscribe_in(slider, window, |this, _, _: &SliderEvent, window, cx| { + let color = this.sliders.read(cx); + this.update_value_from_slider(color, window, cx); + }) + }), + ); + + Self { + focus_handle: cx.focus_handle(), + value: None, + preview: None, + open: false, + active_tab: 0, + hex_input, + sliders, + needs_slider_sync: false, + suppress_input_change: false, + _subscriptions: subscriptions, + } + } + + /// Sets the initial committed and previewed color. + pub fn default_value(mut self, value: impl Into) -> Self { + let value = value.into(); + self.value = Some(value); + self.preview = Some(value); + self.needs_slider_sync = true; + self + } + + /// Returns the committed color. + pub fn value(&self) -> Option { + self.value + } + + /// Returns the color currently being previewed. + pub fn preview(&self) -> Option { + self.preview + } + + /// Returns the previewed color, falling back to the committed color. + pub fn displayed_color(&self) -> Option { + self.preview.or(self.value) + } + + /// The hex text field. Render it with an application-owned input element. + pub fn hex_input(&self) -> &Entity { + &self.hex_input + } + + /// The HSLA component sliders. + pub fn sliders(&self) -> &HslaSliders { + &self.sliders + } + + /// Replaces the committed color without emitting a change. + pub fn set_value( + &mut self, + value: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + self.update_value(Some(value.into()), false, window, cx); + } + + /// Clears the committed color without emitting a change. + pub fn clear_value(&mut self, window: &mut Window, cx: &mut Context) { + self.update_value(None, false, window, cx); + } + + /// Applies a value supplied to [`Self::default_value`] to the hex field and + /// sliders. Call this from render; it is a no-op once nothing is pending. + pub fn sync_pending_value(&mut self, window: &mut Window, cx: &mut Context) { + if self.needs_slider_sync { + self.update_value(self.value, false, window, cx); + } + } + + /// Updates the transient preview color and the hex field that shows it. + pub fn preview_color(&mut self, value: Hsla, window: &mut Window, cx: &mut Context) { + self.preview = Some(value); + self.write_hex_input(Some(value), window, cx); + cx.notify(); + } + + /// Drops the transient preview, restoring the committed color. + pub fn clear_preview(&mut self, window: &mut Window, cx: &mut Context) { + if self.preview == self.value { + return; + } + self.preview = self.value; + self.write_hex_input(self.value, window, cx); + cx.notify(); + } + + /// Parses a hex color into the transient preview. + /// + /// Invalid or incomplete input leaves the current preview unchanged. This + /// does not write the hex field, so it is safe to call while typing in it. + pub fn preview_hex(&mut self, value: &str, _: &mut Window, cx: &mut Context) -> bool { + let Some(value) = parse_hex(value) else { + return false; + }; + self.preview = Some(value); + cx.notify(); + true + } + + /// Parses and commits a hex color, closing the picker on success. + pub fn commit_hex( + &mut self, + value: &str, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let value = parse_hex(value)?; + self.select_color(value, window, cx); + Some(value) + } + + /// Commits a color and closes the picker, as a palette selection does. + pub fn select_color(&mut self, value: Hsla, window: &mut Window, cx: &mut Context) { + self.open = false; + self.update_value(Some(value), true, window, cx); + } + + /// Commits a color without changing the open state, as a slider drag does. + pub fn update_color(&mut self, value: Hsla, window: &mut Window, cx: &mut Context) { + self.update_value(Some(value), true, window, cx); + } + + /// Sets whether the picker popup is open. + pub fn set_open(&mut self, open: bool, cx: &mut Context) { + if self.open == open { + return; + } + self.open = open; + cx.notify(); + } + + /// Toggles the picker popup. + pub fn toggle_open(&mut self, cx: &mut Context) { + self.open = !self.open; + cx.notify(); + } + + /// Returns whether the picker popup is open. + pub fn is_open(&self) -> bool { + self.open + } + + /// Selects the application-defined picker panel. + pub fn set_active_tab(&mut self, tab: usize, cx: &mut Context) { + if self.active_tab == tab { + return; + } + self.active_tab = tab; + cx.notify(); + } + + /// Returns the selected application-defined picker panel. + pub fn active_tab(&self) -> usize { + self.active_tab + } + + fn update_value( + &mut self, + value: Option, + emit: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.needs_slider_sync = false; + self.value = value; + self.preview = value; + self.write_hex_input(value, window, cx); + // Drive the sliders from the full-precision color rather than letting + // the hex round-trip do it. + if let Some(value) = value { + self.sliders.write(value, window, cx); + } + if emit { + cx.emit(ColorPickerEvent::Change(value)); + } + cx.notify(); + } + + fn update_value_from_slider( + &mut self, + value: Hsla, + window: &mut Window, + cx: &mut Context, + ) { + self.needs_slider_sync = false; + self.value = Some(value); + self.preview = Some(value); + // Write the hex field, but leave the sliders alone: they are the source + // of this change and rewriting them would fight the drag in progress. + self.write_hex_input(Some(value), window, cx); + cx.emit(ColorPickerEvent::Change(Some(value))); + cx.notify(); + } + + /// Writes the hex field, suppressing the change it would otherwise feed + /// back through `Hsla` → hex → `Hsla` and lose precision to. + fn write_hex_input(&mut self, value: Option, window: &mut Window, cx: &mut App) { + self.suppress_input_change = true; + let text = value.map(hex_string).unwrap_or_default(); + self.hex_input + .update(cx, |input, cx| input.set_value(text, window, cx)); + } +} + +impl EventEmitter for ColorPickerState {} + +impl Focusable for ColorPickerState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ColorPickerState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.hex_input.clone() + } +} + +type OpenChangeHandler = Rc; + +/// An unstyled controlled color-picker root. +/// +/// Applications own the trigger, palette, popup, and every visual decision. +/// This root owns focus, accessibility semantics, and the keyboard behavior +/// that opens and dismisses the picker. +#[derive(IntoElement)] +pub struct ColorPicker { + base: Stateful
, + open: bool, + disabled: bool, + focus_handle: Option, + accessibility_label: Option, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, + on_open_change: Option, + key_context: &'static str, + role: RoleOverride, +} + +impl ColorPicker { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + open: false, + disabled: false, + focus_handle: None, + accessibility_label: None, + style: StyleRefinement::default(), + children: SmallVec::new(), + on_open_change: None, + key_context: CONTEXT, + role: RoleOverride::Implicit, + } + } + + /// Sets the application-controlled open state. + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + /// Prevents keyboard interaction and removes the trigger from tab traversal. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Supplies the focus handle for the picker trigger. + pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { + self.focus_handle = Some(focus_handle.clone()); + self + } + + /// Sets the accessible name exposed by the controlled root. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Overrides the accessibility role. The default is [`Role::Button`]. + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + /// Handles requests to update the controlled open state. + /// + /// Confirm toggles the picker and Cancel dismisses an open one. + pub fn on_open_change( + mut self, + handler: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_change = Some(Rc::new(handler)); + self + } + + #[doc(hidden)] + pub fn key_context(mut self, key_context: &'static str) -> Self { + self.key_context = key_context; + self + } +} + +impl Styled for ColorPicker { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for ColorPicker { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for ColorPicker { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for ColorPicker {} + +impl RenderOnce for ColorPicker { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let open = self.open; + let disabled = self.disabled; + let handler = self.on_open_change; + + self.base + .when_some(self.role.resolve(|| Role::Button), |this, role| { + this.role(role) + }) + .aria_expanded(open) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .key_context(self.key_context) + .when_some( + self.focus_handle.filter(|_| !disabled), + |this, focus_handle| this.track_focus(&focus_handle.tab_stop(true)), + ) + .on_action({ + let handler = handler.clone(); + move |_: &Confirm, window, cx| { + if disabled { + cx.propagate(); + return; + } + if let Some(handler) = handler.as_ref() { + handler(!open, window, cx); + } + } + }) + .on_action(move |_: &Cancel, window, cx| { + if !open { + cx.propagate(); + return; + } + cx.stop_propagation(); + if let Some(handler) = handler.as_ref() { + handler(false, window, cx); + } + }) + .children(self.children) + .refine_style(&self.style) + } +} + +type SwatchClickHandler = Rc; +type SwatchHoverHandler = Rc; + +/// An unstyled selectable color in a picker's palette. +/// +/// The application paints the color; this part carries the radio semantics, +/// the accessible hex name, and the hover and activation callbacks a picker +/// uses to preview and commit a color. +#[derive(IntoElement)] +pub struct ColorSwatch { + id: ElementId, + base: Stateful
, + color: Hsla, + selected: bool, + disabled: bool, + style: StyleRefinement, + children: SmallVec<[AnyElement; 1]>, + accessibility_label: Option, + on_click: Option, + on_hover: Option, + tab_index: isize, + tab_stop: bool, + role: RoleOverride, +} + +impl ColorSwatch { + pub fn new(id: impl Into, color: Hsla) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + color, + selected: false, + disabled: false, + style: StyleRefinement::default(), + children: SmallVec::new(), + accessibility_label: None, + on_click: None, + on_hover: None, + tab_index: 0, + tab_stop: true, + role: RoleOverride::Implicit, + } + } + + /// The color this swatch represents. + pub fn color(&self) -> Hsla { + self.color + } + + /// Marks this swatch as the picker's current color. + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + /// Sets whether pointer and keyboard activation are ignored. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Overrides the accessible name. The default is the color's hex value. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Overrides the accessibility role. The default is [`Role::RadioButton`]. + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + /// Handles activation with the swatch's color. + pub fn on_click( + mut self, + handler: impl Fn(Hsla, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } + + /// Handles hover enter and exit with the swatch's color. + pub fn on_hover( + mut self, + handler: impl Fn(Hsla, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_hover = Some(Rc::new(handler)); + self + } + + /// Sets the focus traversal index. The default is `0`. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + /// Sets whether this swatch participates in keyboard focus traversal. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } +} + +impl Styled for ColorSwatch { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for ColorSwatch { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for ColorSwatch { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for ColorSwatch {} + +impl RenderOnce for ColorSwatch { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let color = self.color; + let disabled = self.disabled; + let selected = self.selected; + let label = self + .accessibility_label + .unwrap_or_else(|| hex_string(color).into()); + let focus_handle = window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone(); + + self.base + .when_some(self.role.resolve(|| Role::RadioButton), |this, role| { + this.role(role) + }) + .aria_label(label) + // A palette color is both "toggled" and "selected"; assistive + // technology reads one or the other, so state both. + .aria_toggled(if selected { + Toggled::True + } else { + Toggled::False + }) + .aria_selected(selected) + .when(!disabled, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when_some( + (!disabled).then_some(self.on_hover).flatten(), + |this, on_hover| { + this.on_hover(move |entered, window, cx| on_hover(color, *entered, window, cx)) + }, + ) + .when_some( + (!disabled).then_some(self.on_click).flatten(), + |this, on_click| { + this.on_click(move |event, window, cx| on_click(color, event, window, cx)) + }, + ) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, hsla, px}; + use std::{cell::RefCell, rc::Rc}; + + #[test] + fn parses_every_supported_hex_width() { + let white = parse_hex("#fff").unwrap(); + assert_eq!(hex_string(white), "#FFFFFF"); + assert_eq!(parse_hex("ffffff"), Some(white)); + + let half = parse_hex("#ff000080").unwrap(); + assert!((half.a - 0.5).abs() < 0.01); + assert_eq!(parse_hex("#f008").map(|c| c.h), Some(half.h)); + } + + #[test] + fn rejects_malformed_hex() { + for value in ["#nope", "#12", "#1234567", "", "#+f0000", "#-fffff"] { + assert_eq!(parse_hex(value), None, "{value} should not parse"); + } + } + + #[test] + fn formats_alpha_only_when_translucent() { + assert_eq!(hex_string(hsla(0., 1., 0.5, 1.)), "#FF0000"); + assert_eq!(hex_string(hsla(0., 1., 0.5, 0.5)), "#FF00007F"); + } + + fn state(cx: &mut TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { + let (state, cx) = cx.add_window_view(ColorPickerState::new); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (state, cx) + } + + #[gpui::test] + fn default_value_reaches_the_hex_field_and_sliders(cx: &mut TestAppContext) { + let (state, cx) = cx.add_window_view(|window, cx| { + ColorPickerState::new(window, cx).default_value(hsla(0., 1., 0.5, 1.)) + }); + cx.update(|window, cx| { + state.update(cx, |state, cx| state.sync_pending_value(window, cx)); + }); + + state.read_with(cx, |state, cx| { + assert_eq!(state.hex_input().read(cx).value().as_str(), "#FF0000"); + assert_eq!(state.sliders().lightness().read(cx).value().start(), 0.5); + }); + } + + #[gpui::test] + fn preview_does_not_change_the_committed_value(cx: &mut TestAppContext) { + let (state, cx) = state(cx); + let committed = hsla(0.1, 0.2, 0.3, 1.); + let previewed = hsla(0.6, 0.7, 0.8, 1.); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_value(committed, window, cx); + state.preview_color(previewed, window, cx); + }); + }); + + state.read_with(cx, |state, _| { + assert_eq!(state.value(), Some(committed)); + assert_eq!(state.displayed_color(), Some(previewed)); + }); + + cx.update(|window, cx| { + state.update(cx, |state, cx| state.clear_preview(window, cx)); + }); + state.read_with(cx, |state, _| { + assert_eq!(state.displayed_color(), Some(committed)); + }); + } + + #[gpui::test] + fn invalid_hex_leaves_preview_and_value_alone(cx: &mut TestAppContext) { + let (state, cx) = state(cx); + let color = hsla(0.1, 0.2, 0.3, 1.); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_value(color, window, cx); + assert!(!state.preview_hex("#nope", window, cx)); + assert_eq!(state.commit_hex("#12", window, cx), None); + }); + }); + + state.read_with(cx, |state, _| { + assert_eq!(state.preview(), Some(color)); + assert_eq!(state.value(), Some(color)); + }); + } + + #[gpui::test] + fn palette_selection_closes_but_a_slider_update_stays_open(cx: &mut TestAppContext) { + let (state, cx) = state(cx); + let changes = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|_, cx| { + let changes = changes.clone(); + cx.subscribe(&state, move |_, event: &ColorPickerEvent, _| { + let ColorPickerEvent::Change(color) = event; + changes.borrow_mut().push(*color); + }) + }); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_open(true, cx); + state.update_color(hsla(0.2, 0.3, 0.4, 1.), window, cx); + }); + }); + assert!(state.read_with(cx, |state, _| state.is_open())); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.select_color(hsla(0.5, 0.6, 0.7, 1.), window, cx); + }); + }); + assert!(!state.read_with(cx, |state, _| state.is_open())); + assert_eq!(changes.borrow().len(), 2); + } + + #[gpui::test] + fn committing_hex_updates_the_value_and_closes(cx: &mut TestAppContext) { + let (state, cx) = state(cx); + + let committed = cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_open(true, cx); + state.commit_hex("#ff0000", window, cx) + }) + }); + + state.read_with(cx, |state, _| { + assert_eq!(state.value(), committed); + assert_eq!(state.preview(), committed); + assert!(!state.is_open()); + }); + } + + #[gpui::test] + fn open_and_active_panel_are_controlled(cx: &mut TestAppContext) { + let (state, cx) = state(cx); + + cx.update(|_, cx| { + state.update(cx, |state, cx| { + state.toggle_open(cx); + state.set_active_tab(1, cx); + }); + }); + + state.read_with(cx, |state, _| { + assert!(state.is_open()); + assert_eq!(state.active_tab(), 1); + }); + } + + struct PickerHarness { + open: bool, + focus_handle: FocusHandle, + changes: Rc>>, + } + + impl Render for PickerHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity(); + let changes = self.changes.clone(); + ColorPicker::new("picker") + .open(self.open) + .track_focus(&self.focus_handle) + .size(px(20.)) + .on_open_change(move |open, _, cx| { + changes.borrow_mut().push(open); + entity.update(cx, |this, cx| { + this.open = open; + cx.notify(); + }); + }) + } + } + + #[gpui::test] + fn confirm_toggles_and_cancel_dismisses(cx: &mut TestAppContext) { + cx.update(crate::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let (state, cx) = cx.add_window_view({ + let changes = changes.clone(); + move |_, cx| PickerHarness { + open: false, + focus_handle: cx.focus_handle(), + changes, + } + }); + cx.update(|window, cx| { + state.read(cx).focus_handle.clone().focus(window, cx); + window.draw(cx).clear(cx); + }); + + cx.simulate_keystrokes("enter"); + assert!(state.read_with(cx, |state, _| state.open)); + + cx.simulate_keystrokes("enter"); + assert!(!state.read_with(cx, |state, _| state.open)); + + cx.simulate_keystrokes("enter escape"); + assert!(!state.read_with(cx, |state, _| state.open)); + assert_eq!(&*changes.borrow(), &[true, false, true, false]); + } +} diff --git a/vendor/gpui-base/src/combobox.rs b/vendor/gpui-base/src/combobox.rs new file mode 100644 index 00000000..f72ae757 --- /dev/null +++ b/vendor/gpui-base/src/combobox.rs @@ -0,0 +1,246 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FocusHandle, IntoElement, KeyBinding, ParentElement, RenderOnce, + StyleRefinement, Styled, Window, prelude::FluentBuilder as _, +}; + +use crate::{ + Select, + actions::{Cancel, Confirm, SelectDown, SelectUp}, + styled::StyledExt as _, +}; + +const CONTEXT: &str = "Combobox"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + KeyBinding::new( + "secondary-enter", + Confirm { secondary: true }, + Some(CONTEXT), + ), + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + ]); +} + +type OpenChangeHandler = Rc; +type ActionHandler = Rc; + +/// An unstyled controlled combobox root. +/// +/// Applications own the trigger, popup, searchable collection, selection, and +/// appearance. This root owns combobox semantics and keyboard focus transfer. +#[derive(IntoElement)] +pub struct Combobox { + id: ElementId, + open: bool, + disabled: bool, + focus_handle: Option, + content_focus_handle: Option, + style: StyleRefinement, + children: Vec, + on_open_change: Option, + on_confirm: Option, + on_dismiss: Option, +} + +impl Combobox { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + open: false, + disabled: false, + focus_handle: None, + content_focus_handle: None, + style: StyleRefinement::default(), + children: Vec::new(), + on_open_change: None, + on_confirm: None, + on_dismiss: None, + } + } + + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self { + self.focus_handle = Some(focus_handle.clone()); + self + } + + pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self { + self.content_focus_handle = Some(focus_handle.clone()); + self + } + + pub fn on_open_change( + mut self, + handler: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_change = Some(Rc::new(handler)); + self + } + + /// Handles the Confirm action while the combobox is open. + pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_confirm = Some(Rc::new(handler)); + self + } + + /// Handles a dismissal requested through the Cancel action. + /// + /// This runs before the controlled open state is asked to close, so a + /// combobox that commits its pending value on dismissal can still read + /// that value here. + pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_dismiss = Some(Rc::new(handler)); + self + } +} + +impl Styled for Combobox { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Combobox { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Combobox { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + Select::new(self.id) + .open(self.open) + .disabled(self.disabled) + .key_context(CONTEXT) + .when_some(self.focus_handle, |this, handle| this.focus_handle(&handle)) + .when_some(self.content_focus_handle, |this, handle| { + this.content_focus_handle(&handle) + }) + .when_some(self.on_open_change, |this, handler| { + this.on_open_change(move |open, window, cx| handler(open, window, cx)) + }) + .when_some(self.on_confirm, |this, handler| { + this.on_confirm(move |window, cx| handler(window, cx)) + }) + .when_some(self.on_dismiss, |this, handler| { + this.on_dismiss(move |window, cx| handler(window, cx)) + }) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ + Context, Focusable, InteractiveElement as _, Render, TestAppContext, VisualTestContext, + div, px, + }; + use std::sync::{Arc, Mutex}; + + struct Harness { + open: bool, + trigger_focus: FocusHandle, + content_focus: FocusHandle, + events: Arc>>, + } + + impl Harness { + fn new(cx: &mut Context) -> Self { + Self { + open: true, + trigger_focus: cx.focus_handle(), + content_focus: cx.focus_handle(), + events: Arc::new(Mutex::new(Vec::new())), + } + } + } + + impl Focusable for Harness { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.trigger_focus.clone() + } + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = cx.entity(); + let confirm_events = self.events.clone(); + let dismiss_events = self.events.clone(); + let open_events = self.events.clone(); + + Combobox::new("combobox") + .open(self.open) + .focus_handle(&self.trigger_focus) + .content_focus_handle(&self.content_focus) + .on_confirm(move |_, _| confirm_events.lock().unwrap().push("confirm")) + .on_dismiss(move |_, _| dismiss_events.lock().unwrap().push("dismiss")) + .on_open_change(move |open, _, cx| { + open_events.lock().unwrap().push("close"); + state.update(cx, |state, cx| { + state.open = open; + cx.notify(); + }); + }) + .child(div().track_focus(&self.content_focus).size(px(20.))) + } + } + + fn harness(cx: &mut TestAppContext) -> (&mut VisualTestContext, gpui::Entity) { + cx.update(crate::init); + let (state, cx) = cx.add_window_view(|_, cx| Harness::new(cx)); + cx.update(|window, cx| { + let content_focus = state.read(cx).content_focus.clone(); + content_focus.focus(window, cx); + window.draw(cx).clear(cx); + }); + (cx, state) + } + + #[gpui::test] + fn escape_dismisses_then_closes_and_restores_trigger_focus(cx: &mut TestAppContext) { + let (cx, state) = harness(cx); + cx.simulate_keystrokes("escape"); + + cx.update(|window, cx| { + assert!(!state.read(cx).open); + assert!(state.read(cx).trigger_focus.is_focused(window)); + // Dismissal runs before the close request so a caller can still + // commit its pending value. + assert_eq!( + state.read(cx).events.lock().unwrap().as_slice(), + &["dismiss", "close"] + ); + }); + } + + #[gpui::test] + fn enter_confirms_without_dismissing_while_open(cx: &mut TestAppContext) { + let (cx, state) = harness(cx); + cx.simulate_keystrokes("enter"); + + cx.update(|_, cx| { + assert!(state.read(cx).open); + assert_eq!( + state.read(cx).events.lock().unwrap().as_slice(), + &["confirm"] + ); + }); + } +} diff --git a/vendor/gpui-base/src/component_traits.rs b/vendor/gpui-base/src/component_traits.rs new file mode 100644 index 00000000..21eaa3dc --- /dev/null +++ b/vendor/gpui-base/src/component_traits.rs @@ -0,0 +1,61 @@ +/// An element or component that exposes controlled selected state. +#[allow(patterns_in_fns_without_body)] +pub trait Selectable: Sized { + fn selected(mut self, selected: bool) -> Self; + fn is_selected(&self) -> bool; + + fn secondary_selected(self, _: bool) -> Self { + self + } +} + +/// An element or component that can be disabled. +#[allow(patterns_in_fns_without_body)] +pub trait Disableable { + fn disabled(mut self, disabled: bool) -> Self; +} + +/// A component that exposes whether its UI layer should draw a focus ring. +/// +/// This trait carries state only. Focus-ring geometry and presentation belong +/// to the component's visual layer. +pub trait FocusableExt: Sized { + fn focus_ring(self, enabled: bool) -> Self; + fn is_focus_ring_enabled(&self) -> bool; +} + +/// An element or component that exposes collapsed state. +pub trait Collapsible { + fn collapsed(self, collapsed: bool) -> Self; + fn is_collapsed(&self) -> bool; +} + +#[cfg(test)] +mod tests { + use super::FocusableExt; + + struct CustomControl { + focus_ring_enabled: bool, + } + + impl FocusableExt for CustomControl { + fn focus_ring(mut self, enabled: bool) -> Self { + self.focus_ring_enabled = enabled; + self + } + + fn is_focus_ring_enabled(&self) -> bool { + self.focus_ring_enabled + } + } + + #[test] + fn focus_ring_api_carries_state_without_visuals() { + let control = CustomControl { + focus_ring_enabled: true, + } + .focus_ring(false); + + assert!(!control.is_focus_ring_enabled()); + } +} diff --git a/vendor/gpui-base/src/date_picker.rs b/vendor/gpui-base/src/date_picker.rs new file mode 100644 index 00000000..251a4bfe --- /dev/null +++ b/vendor/gpui-base/src/date_picker.rs @@ -0,0 +1,106 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, IntoElement, + ParentElement, RenderOnce, Role, StatefulInteractiveElement, StyleRefinement, Styled, Window, + div, +}; + +use crate::{ + StyledExt as _, + actions::{Cancel, Confirm}, +}; + +type OpenChange = Rc; + +/// Unstyled controlled date-picker root. Applications own its trigger, calendar, +/// positioning, and visual presentation; Base owns focus and open/dismiss keyboard behavior. +#[derive(IntoElement)] +pub struct DatePicker { + base: gpui::Stateful
, + open: bool, + disabled: bool, + focus_handle: FocusHandle, + style: StyleRefinement, + children: Vec, + on_open_change: Option, +} + +impl DatePicker { + pub fn new(id: impl Into, focus_handle: &FocusHandle) -> Self { + Self { + base: div().id(id), + open: false, + disabled: false, + focus_handle: focus_handle.clone(), + style: StyleRefinement::default(), + children: vec![], + on_open_change: None, + } + } + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + pub fn on_open_change( + mut self, + handler: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_change = Some(Rc::new(handler)); + self + } +} +impl Styled for DatePicker { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl ParentElement for DatePicker { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } +} +impl InteractiveElement for DatePicker { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} +impl StatefulInteractiveElement for DatePicker {} +impl RenderOnce for DatePicker { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let open = self.open; + let disabled = self.disabled; + let handler = self.on_open_change; + self.base + .role(Role::ComboBox) + .aria_expanded(open) + .track_focus(&self.focus_handle.tab_stop(!disabled)) + .on_action({ + let handler = handler.clone(); + move |_: &Confirm, window, cx| { + if disabled { + cx.propagate(); + } else if !open { + if let Some(handler) = &handler { + handler(true, window, cx); + } + } + } + }) + .on_action(move |_: &Cancel, window, cx| { + if open { + if let Some(handler) = &handler { + handler(false, window, cx); + } + } else { + cx.propagate(); + } + }) + .refine_style(&self.style) + .children(self.children) + } +} diff --git a/vendor/gpui-base/src/dialog.rs b/vendor/gpui-base/src/dialog.rs new file mode 100644 index 00000000..447fc258 --- /dev/null +++ b/vendor/gpui-base/src/dialog.rs @@ -0,0 +1,619 @@ +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; + +use gpui::{ + AnyElement, App, ClickEvent, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, RenderOnce, Role, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, anchored, deferred, div, point, prelude::FluentBuilder as _, + px, +}; +use smallvec::SmallVec; + +use crate::actions::{Cancel, Confirm}; +use crate::{FocusTrapElement as _, StyledExt as _}; + +const CONTEXT: &str = "Dialog"; +type Decision = Rc bool>; +type Closed = Rc; +type CloseRequest = Rc; +type OpenRequest = Rc; +type OpenChange = Rc; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DialogChangeReason { + TriggerPress, + BackdropPress, + Cancel, + Confirm, + Imperative, +} + +#[derive(Clone)] +pub struct DialogHandle { + open: Rc>, + on_open_change: Rc>>, +} + +impl DialogHandle { + pub fn new(open: bool) -> Self { + Self { + open: Rc::new(Cell::new(open)), + on_open_change: Rc::new(RefCell::new(None)), + } + } + pub fn is_open(&self) -> bool { + self.open.get() + } + pub fn open(&self, window: &mut Window, cx: &mut App) { + self.set_open(true, DialogChangeReason::Imperative, window, cx); + } + pub fn close(&self, window: &mut Window, cx: &mut App) { + self.set_open(false, DialogChangeReason::Imperative, window, cx); + } + pub(crate) fn set_open( + &self, + open: bool, + reason: DialogChangeReason, + window: &mut Window, + cx: &mut App, + ) { + if self.open.replace(open) == open { + return; + } + let callback = self.on_open_change.borrow().clone(); + if let Some(callback) = callback { + callback(open, reason, window, cx); + } + window.refresh(); + } +} + +fn request_open_change( + handle: &Option, + callback: &Option, + open: bool, + reason: DialogChangeReason, + window: &mut Window, + cx: &mut App, +) { + if let Some(handle) = handle { + handle.set_open(open, reason, window, cx); + } else if let Some(callback) = callback { + callback(open, reason, window, cx); + } +} + +pub fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + ]); +} + +impl Dialog { + pub fn on_ok( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_ok = Rc::new(handler); + self + } + + pub fn on_cancel( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_cancel = Rc::new(handler); + self + } + + pub fn on_close( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_close = Rc::new(handler); + self + } +} + +/// Unstyled modal host owning focus, keyboard actions, dismissal, and callback ordering. +#[derive(IntoElement)] +pub struct Dialog { + style: StyleRefinement, + focus: FocusHandle, + role: Role, + layer: usize, + keyboard: bool, + overlay_closable: bool, + topmost: bool, + dismiss_below_y: Pixels, + backdrop: Option, + popup: Option, + children: SmallVec<[AnyElement; 2]>, + on_ok: Decision, + on_cancel: Decision, + on_close: Closed, + request_close: CloseRequest, + handle: Option, + open: bool, + on_open_change: Option, +} + +/// Unstyled trigger that owns pointer activation for opening a dialog. +#[derive(IntoElement)] +pub struct DialogTrigger { + trigger: AnyElement, + open: OpenRequest, + handle: Option, +} + +impl DialogTrigger { + pub fn new(trigger: impl IntoElement) -> Self { + Self { + trigger: trigger.into_any_element(), + open: Rc::new(|_, _| {}), + handle: None, + } + } + pub fn handle(mut self, handle: DialogHandle) -> Self { + self.handle = Some(handle); + self + } + + pub fn on_open(mut self, open: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.open = Rc::new(open); + self + } +} + +impl RenderOnce for DialogTrigger { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + if let Some(handle) = self.handle.as_ref() { + handle.set_open(true, DialogChangeReason::TriggerPress, window, cx); + } + (self.open)(window, cx); + cx.stop_propagation(); + }) + .child(self.trigger) + } +} + +macro_rules! dialog_part { + ($(#[$meta:meta])* $name:ident, $id:literal) => { + $(#[$meta])* + #[derive(IntoElement)] + pub struct $name { + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, + } + + impl $name { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } + } + + impl RenderOnce for $name { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id($id) + .children(self.children) + .refine_style(&self.style) + } + } + }; +} + +dialog_part!( + /// Unstyled backdrop part rendered behind a dialog popup. + DialogBackdrop, + "dialog-backdrop" +); + +dialog_part!( + /// Unstyled popup part containing dialog content. + DialogPopup, + "dialog-popup" +); + +/// Unstyled title slot for a dialog surface. +#[derive(IntoElement)] +pub struct DialogTitle { + base: gpui::Div, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl DialogTitle { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Default for DialogTitle { + fn default() -> Self { + Self::new() + } +} +impl Styled for DialogTitle { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl ParentElement for DialogTitle { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} +impl RenderOnce for DialogTitle { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .id("dialog-title") + .children(self.children) + .refine_style(&self.style) + } +} + +/// Unstyled descriptive-content slot for a dialog surface. +#[derive(IntoElement)] +pub struct DialogDescription { + base: gpui::Div, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl DialogDescription { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Default for DialogDescription { + fn default() -> Self { + Self::new() + } +} +impl Styled for DialogDescription { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl ParentElement for DialogDescription { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} +impl RenderOnce for DialogDescription { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .id("dialog-description") + .children(self.children) + .refine_style(&self.style) + } +} + +/// Wrapper that dispatches the dialog cancel action when activated. +#[derive(IntoElement)] +pub struct DialogClose { + style: StyleRefinement, + children: SmallVec<[AnyElement; 1]>, +} + +impl DialogClose { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} +impl Default for DialogClose { + fn default() -> Self { + Self::new() + } +} +impl ParentElement for DialogClose { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} +impl Styled for DialogClose { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl RenderOnce for DialogClose { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id("dialog-close") + .on_click(|_, window, cx| window.dispatch_action(Box::new(Cancel), cx)) + .children(self.children) + .refine_style(&self.style) + } +} + +impl Dialog { + pub fn new(cx: &mut App) -> Self { + Self { + style: StyleRefinement::default(), + focus: cx.focus_handle(), + role: Role::Dialog, + layer: 0, + keyboard: true, + overlay_closable: true, + topmost: true, + dismiss_below_y: px(0.), + backdrop: None, + popup: None, + children: SmallVec::new(), + on_ok: Rc::new(|_, _, _| true), + on_cancel: Rc::new(|_, _, _| true), + on_close: Rc::new(|_, _, _| {}), + request_close: Rc::new(|_, _, _| {}), + handle: None, + open: true, + on_open_change: None, + } + } + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + pub fn handle(mut self, handle: DialogHandle) -> Self { + if let Some(callback) = self.on_open_change.as_ref() { + *handle.on_open_change.borrow_mut() = Some(callback.clone()); + } + self.handle = Some(handle); + self + } + pub fn on_open_change( + mut self, + handler: impl Fn(bool, DialogChangeReason, &mut Window, &mut App) + 'static, + ) -> Self { + let handler: OpenChange = Rc::new(handler); + if let Some(handle) = self.handle.as_ref() { + *handle.on_open_change.borrow_mut() = Some(handler.clone()); + } + self.on_open_change = Some(handler); + self + } + + pub fn backdrop(mut self, element: impl IntoElement) -> Self { + self.backdrop = Some(element.into_any_element()); + self + } + pub fn popup(mut self, element: impl IntoElement) -> Self { + self.popup = Some(element.into_any_element()); + self + } + pub fn close_on_escape(mut self, value: bool) -> Self { + self.keyboard = value; + self + } + pub fn close_on_backdrop_press(mut self, value: bool) -> Self { + self.overlay_closable = value; + self + } + pub fn dismiss_below_y(mut self, value: Pixels) -> Self { + self.dismiss_below_y = value; + self + } + pub(crate) fn role(mut self, role: Role) -> Self { + self.role = role; + self + } + #[doc(hidden)] + pub fn layer(mut self, index: usize, topmost: bool) -> Self { + self.layer = index; + self.topmost = topmost; + self + } + #[doc(hidden)] + pub fn focus_handle(mut self, value: FocusHandle) -> Self { + self.focus = value; + self + } + #[doc(hidden)] + pub fn request_close( + mut self, + handler: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.request_close = Rc::new(handler); + self + } +} + +impl Styled for Dialog { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl ParentElement for Dialog { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Dialog { + fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { + let open = self + .handle + .as_ref() + .map_or(self.open, DialogHandle::is_open); + if !open { + return div().into_any_element(); + } + let request_close = self.request_close; + let cancel = self.on_cancel.clone(); + let confirm = self.on_ok.clone(); + let closed = self.on_close.clone(); + let overlay_closable = self.overlay_closable && self.topmost; + let dismiss_below_y = self.dismiss_below_y; + let escape_handle = self.handle.clone(); + let confirm_handle = self.handle.clone(); + let backdrop_handle = self.handle.clone(); + let escape_change = self.on_open_change.clone(); + let confirm_change = self.on_open_change.clone(); + let backdrop_change = self.on_open_change.clone(); + let viewport = window.viewport_size(); + + deferred( + anchored().position(point(px(0.), px(0.))).child( + div() + .id(("dialog-host", self.layer)) + .absolute() + .top_0() + .left_0() + .w(viewport.width) + .h(viewport.height) + .role(self.role) + .track_focus(&self.focus) + .focus_trap(format!("dialog-{}", self.layer), &self.focus) + .when(self.keyboard, |this| this.key_context(CONTEXT)) + .map(|this| { + let request_cancel = request_close.clone(); + let request_confirm = request_close.clone(); + let closed_cancel = closed.clone(); + this.on_action(move |_: &Cancel, window, cx| { + let event = ClickEvent::default(); + if cancel(&event, window, cx) { + request_open_change( + &escape_handle, + &escape_change, + false, + DialogChangeReason::Cancel, + window, + cx, + ); + request_cancel(false, window, cx); + closed_cancel(&event, window, cx); + } + }) + .on_action(move |_: &Confirm, window, cx| { + let event = ClickEvent::default(); + if confirm(&event, window, cx) { + request_open_change( + &confirm_handle, + &confirm_change, + false, + DialogChangeReason::Confirm, + window, + cx, + ); + request_confirm(true, window, cx); + closed(&event, window, cx); + } + }) + }) + .when_some(self.backdrop, |this, backdrop| { + let cancel = self.on_cancel.clone(); + let closed = self.on_close.clone(); + let request_close = request_close.clone(); + this.child( + div() + .on_any_mouse_down(move |event, window, cx| { + if event.position.y < dismiss_below_y { + return; + } + let button = event.button; + cx.stop_propagation(); + let event = ClickEvent::default(); + if button == MouseButton::Left + && overlay_closable + && cancel(&event, window, cx) + { + request_open_change( + &backdrop_handle, + &backdrop_change, + false, + DialogChangeReason::BackdropPress, + window, + cx, + ); + request_close(false, window, cx); + closed(&event, window, cx); + } + }) + .child(backdrop), + ) + }) + .children(self.popup) + .children(self.children) + .refine_style(&self.style), + ), + ) + .with_priority(10 + self.layer) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, point}; + use std::{cell::RefCell, rc::Rc}; + + struct TriggerHarness { + handle: DialogHandle, + } + impl Render for TriggerHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + DialogTrigger::new(div().size(px(100.))).handle(self.handle.clone()) + } + } + + #[gpui::test] + fn trigger_opens_shared_handle_and_reports_reason(cx: &mut gpui::TestAppContext) { + let handle = DialogHandle::new(false); + let changes = Rc::new(RefCell::new(Vec::new())); + *handle.on_open_change.borrow_mut() = Some({ + let changes = changes.clone(); + Rc::new(move |open, reason, _, _| changes.borrow_mut().push((open, reason))) + }); + let (_, cx) = cx.add_window_view({ + let handle = handle.clone(); + move |_, _| TriggerHarness { handle } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(20.), px(20.)), Default::default()); + + assert!(handle.is_open()); + assert_eq!( + &*changes.borrow(), + &[(true, DialogChangeReason::TriggerPress)] + ); + } +} diff --git a/vendor/gpui-base/src/dock/active.rs b/vendor/gpui-base/src/dock/active.rs new file mode 100644 index 00000000..c0d040cc --- /dev/null +++ b/vendor/gpui-base/src/dock/active.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; + +use super::layout::PanelId; + +/// Tracks what each panel was last told about its active state, so a group +/// emits exactly one notification per real edge. +/// +/// This is the enforcement point for the `Panel::set_active` contract: called +/// with the frame-end net state when a panel becomes, or stops being, the +/// displayed tab of its group — exactly one notification per edge, delivered +/// on the next tick after the change, never same-value repeats nor +/// false-then-true flips within one frame. A panel removed from its group is +/// NOT told `false`; `Panel::on_removed` is the deactivation signal instead. A +/// hidden panel occupying the active slot still receives `true`, even though +/// rendering falls back to the first visible panel. +/// +/// `TabGroup` owns one of these, and is the only thing that does: the skin in +/// `crates/ui` draws tab bars and keeps no active-state map of its own. +#[derive(Default)] +pub(crate) struct ActiveTracker { + notified: HashMap, + sync_scheduled: bool, +} + +impl ActiveTracker { + /// Compare the group's current membership and displayed panel against + /// what each panel was last told, and return only the real edges, + /// deactivations before the activation. + /// + /// `displayed` names at most one panel, so at most one panel can newly + /// become active per call. Collecting deactivations separately from the + /// single possible activation (rather than sorting the combined list) + /// keeps "deactivations are delivered first" a structural property of + /// this function instead of an incidental property of a sort key, and is + /// what guarantees no panel ever observes two panels active in its group + /// at the same time. + /// + /// `displayed`, when `Some`, is expected to name a member of `panels`. + /// This is not a hard invariant enforced here — a `displayed` id absent + /// from `panels` is simply never matched by the `should_be_active` check + /// below, so it degrades gracefully to "no panel in this group is + /// displayed" rather than panicking. Callers should not rely on that + /// degradation as a feature; it exists so a caller computing `displayed` + /// from slightly stale state doesn't crash the tracker. + pub(crate) fn reconcile( + &mut self, + panels: &[PanelId], + displayed: Option, + ) -> Vec<(PanelId, bool)> { + // A panel that left the group is forgotten, not deactivated. + self.notified.retain(|panel, _| panels.contains(panel)); + + let mut deactivations = Vec::new(); + let mut activation = None; + + for panel in panels { + let should_be_active = displayed == Some(*panel); + let was_active = self.notified.get(panel).copied(); + if was_active == Some(should_be_active) { + continue; + } + if !should_be_active && was_active.is_none() { + // Never announce `false` to a panel that was never told `true`. + self.notified.insert(*panel, false); + continue; + } + + self.notified.insert(*panel, should_be_active); + if should_be_active { + debug_assert!( + activation.is_none(), + "`displayed` names at most one panel, so at most one activation per reconcile" + ); + activation = Some((*panel, true)); + } else { + deactivations.push((*panel, false)); + } + } + + deactivations.extend(activation); + deactivations + } + + /// Record what a panel was last told, without emitting anything. Used + /// when a panel moves between groups while displayed, so the move does + /// not look like an edge to the destination group. + pub(crate) fn seed(&mut self, panel: PanelId, active: bool) { + self.notified.insert(panel, active); + } + + /// What this tracker last told `panel`, if anything. A pure read: it does + /// not remove or otherwise change the entry. This is how a caller reads a + /// departing panel's state *before* pruning it (e.g. before removing the + /// panel from the group that owns this tracker), so the value can be + /// handed to [`Self::seed`] on the destination tracker when the panel + /// moves to another group — the source tracker's own next `reconcile` + /// prunes the entry on its own, so this type does not also need a + /// remove-and-return accessor. + pub(crate) fn last_notified(&self, panel: PanelId) -> Option { + self.notified.get(&panel).copied() + } + + #[cfg(test)] + pub(crate) fn tracks(&self, panel: PanelId) -> bool { + self.notified.contains_key(&panel) + } + + /// Returns whether a sync needs scheduling; the caller defers the actual + /// `reconcile` to frame end so within-frame churn nets out to one + /// delivery per edge. + pub(crate) fn schedule_sync(&mut self) -> bool { + let needed = !self.sync_scheduled; + self.sync_scheduled = true; + needed + } + + /// Marks the deferred sync as delivered, so a future change schedules a + /// new one. Callers should invoke this alongside `reconcile` (typically + /// just before it) when the scheduled sync runs. + pub(crate) fn sync_finished(&mut self) { + self.sync_scheduled = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn panel(n: u64) -> PanelId { + PanelId::from_u64(n) + } + + #[test] + fn the_first_reconcile_announces_the_displayed_panel_only() { + let mut tracker = ActiveTracker::default(); + let changes = tracker.reconcile(&[panel(1), panel(2)], Some(panel(1))); + + assert_eq!(changes, vec![(panel(1), true)]); + } + + #[test] + fn switching_reports_the_old_panel_false_then_the_new_panel_true() { + let mut tracker = ActiveTracker::default(); + tracker.reconcile(&[panel(1), panel(2)], Some(panel(1))); + + let changes = tracker.reconcile(&[panel(1), panel(2)], Some(panel(2))); + + assert_eq!(changes, vec![(panel(1), false), (panel(2), true)]); + } + + #[test] + fn reselecting_the_displayed_panel_is_silent() { + let mut tracker = ActiveTracker::default(); + tracker.reconcile(&[panel(1)], Some(panel(1))); + + assert!(tracker.reconcile(&[panel(1)], Some(panel(1))).is_empty()); + } + + #[test] + fn a_removed_panel_is_forgotten_rather_than_told_false() { + let mut tracker = ActiveTracker::default(); + tracker.reconcile(&[panel(1), panel(2)], Some(panel(1))); + + let changes = tracker.reconcile(&[panel(2)], Some(panel(2))); + + assert_eq!( + changes, + vec![(panel(2), true)], + "panel 1 left the group; on_removed is its deactivation signal" + ); + assert!(!tracker.tracks(panel(1))); + } + + #[test] + fn seeding_a_moved_panel_prevents_a_spurious_reactivation() { + let mut tracker = ActiveTracker::default(); + tracker.seed(panel(1), true); + + assert!( + tracker.reconcile(&[panel(1)], Some(panel(1))).is_empty(), + "a panel dragged in while displayed must not be told true twice" + ); + } + + #[test] + fn no_panel_displayed_deactivates_the_previous_one() { + let mut tracker = ActiveTracker::default(); + tracker.reconcile(&[panel(1)], Some(panel(1))); + + let changes = tracker.reconcile(&[panel(1)], None); + + assert_eq!(changes, vec![(panel(1), false)]); + } + + #[test] + fn last_notified_reads_a_panels_state_for_handoff_to_another_tracker() { + // Mirrors `TabPanel::detach_panel` reading the outgoing panel's + // notified state, and `on_drop` handing it to the destination + // group's tracker via `seed`, when a panel is dragged between tab + // groups while displayed. + let mut source = ActiveTracker::default(); + source.reconcile(&[panel(1), panel(2)], Some(panel(1))); + + assert_eq!(source.last_notified(panel(1)), Some(true)); + assert_eq!(source.last_notified(panel(2)), Some(false)); + assert_eq!( + source.last_notified(panel(3)), + None, + "a panel never reconciled has no recorded state" + ); + + let mut destination = ActiveTracker::default(); + destination.seed(panel(1), source.last_notified(panel(1)).unwrap()); + + assert!( + destination + .reconcile(&[panel(1)], Some(panel(1))) + .is_empty(), + "seeded from the source's last_notified value, the move must not look like a fresh edge" + ); + } +} diff --git a/vendor/gpui-base/src/dock/dock_area.rs b/vendor/gpui-base/src/dock/dock_area.rs new file mode 100644 index 00000000..9201f8f5 --- /dev/null +++ b/vendor/gpui-base/src/dock/dock_area.rs @@ -0,0 +1,4324 @@ +//! The dock area: the trees, the entity cache that mirrors them, and the +//! reconciliation that keeps the two in step. + +use std::{ + collections::{HashMap, HashSet}, + rc::Rc, + sync::Arc, +}; + +use anyhow::Result; +use gpui::{ + AnyElement, AnyView, App, AppContext as _, Axis, Bounds, Context, Div, Empty, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement, + Pixels, Point, Render, SharedString, Stateful, Styled as _, Subscription, WeakEntity, Window, + div, prelude::FluentBuilder as _, px, +}; + +use crate::{ + ElementExt as _, Placement, ResizablePanelEvent, ResizableState, ResizeHandleContext, + h_resizable, resizable::PANEL_MIN_SIZE, resizable_panel, v_resizable, +}; + +use super::{ + dock_placement::{Dock, DockSizing}, + drag::{AnyDrag, DropTarget}, + layout::{ + DockLayout, EditResult, InsertTarget, NodeId, NodeKind, PaneNode, PaneRef, PaneTree, + PanelId, RootKind, + }, + panel::{LivePanels, Panel, PanelEvent, PanelView}, + registry::{PanelBuildContext, PanelRegistry}, + state::{DockAreaState, DockPlacement, DockState, PanelInfo, PanelState, TileMeta}, + state_convert::{PanelBuilder, PanelSource as _}, + tab_group::{BareTabGroup, TabGroup, TabGroupConstraints, TabGroupEvent, TabGroupRenderer}, + tiles_state::{BareTiles, TilesEvent, TilesRenderer, TilesState}, +}; + +/// What the dock area reports outward. +pub enum DockEvent { + /// The layout changed. Subscribe to persist it; this fires on every edit, + /// including each step of a tile drag, so a subscriber that writes to disk + /// should debounce. + LayoutChanged, + /// A host-owned drag item was dropped inside the dock. + DragDrop { item: AnyDrag, target: DropTarget }, +} + +/// What fills the whole area, when something does. +/// +/// A zoom names a *container*, never the panel inside it. The old dock zoomed +/// the `TabPanel`: `TabPanel` is the only thing that ever emitted +/// `PanelEvent::ZoomIn` — `subscribe_panel` was handed `StackPanel`s too, but +/// those never zoom — so `set_zoomed_in` only ever received a whole tab panel, +/// and a zoomed panel kept its tab bar, its toolbar and its menu. That is +/// where the control that zooms back out lives. Naming the panel instead would +/// strip all of it and leave the user with no way back. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Zoomed { + /// A tab group, rendered whole through its own [`TabGroupRenderer`]. + Group(NodeId), + /// One tile of a canvas, rendered by that canvas through its own + /// [`TilesRenderer`]. The canvas is what draws a tile's chrome, so the + /// canvas is what is rendered. + Tile { node: NodeId, panel: PanelId }, +} + +/// What a caller asked for when adding a panel, which is also which entry +/// point it came through. +#[derive(Clone, Copy)] +enum Added { + /// Wherever the region's own shape puts it. The size seeds a dock that + /// does not exist yet, and the slot of a group that has to be made. + Anywhere(Option), + /// A tile at these bounds, or nowhere: the bounds name a place only a + /// canvas has, so a region without one is left alone rather than growing a + /// tab group the caller never asked for. + AsTile(Bounds), +} + +impl Added { + fn dock_size(self) -> Option { + match self { + Self::Anywhere(size) => size, + Self::AsTile(_) => None, + } + } +} + +/// One dock: its own layout tree plus the open/size/collapsible state. +struct DockRegion { + tree: PaneTree, + dock: Dock, +} + +/// A cached container entity together with the subscription that carries its +/// intents back here. Kept as one value so dropping the cache entry drops the +/// subscription with it. +struct Cached { + entity: Entity, + _subscription: Subscription, +} + +/// A split's cached `ResizableState`, plus the child order that state's panel +/// list currently mirrors. +/// +/// The order is what makes a reconcile index-precise. `ResizableState` keeps +/// the authoritative size on `panels[ix]` and only ever consults the tree's +/// size as an initial value, so appending and truncating at the tail — +/// which is all `sync_panels_count` can do — leaves the survivors of a +/// non-tail removal wearing their predecessors' widths. +struct CachedSplit { + entity: Entity, + children: Vec, + _subscription: Subscription, +} + +/// The main area of the dock. +/// +/// It owns one [`PaneTree`] per region and the entity cache that mirrors +/// them. Nothing else turns a tree edit into live entities. +pub struct DockArea { + id: SharedString, + version: Option, + bounds: Bounds, + this: WeakEntity, + + center: PaneTree, + docks: HashMap, + + groups: HashMap>, + splits: HashMap, + tiles: HashMap>, + panels: HashMap>, + + locked: bool, + zoomed: Option, + focus_handle: FocusHandle, + renderer: Rc, +} + +impl DockArea { + /// An empty area that draws nothing but its panels. + /// + /// `id` names the area for the host's own persistence; `version` is + /// written into [`Self::dump`] and read back by [`Self::load`], for a host + /// that wants to reject or migrate a layout an older build wrote. + /// + /// Install appearance with [`Self::with_renderer`]. + pub fn new( + id: impl Into, + version: Option, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + PanelRegistry::init(cx); + + Self { + id: id.into(), + version, + bounds: Bounds::default(), + this: cx.weak_entity(), + center: PaneTree::new(RootKind::Split), + docks: HashMap::new(), + groups: HashMap::new(), + splits: HashMap::new(), + tiles: HashMap::new(), + panels: HashMap::new(), + locked: false, + zoomed: None, + focus_handle: cx.focus_handle(), + renderer: Rc::new(BareDockArea), + } + } + + /// Install the appearance for this area and everything under it: the + /// renderer also supplies the [`TabGroupRenderer`] and [`TilesRenderer`] + /// every container it builds will use. + pub fn with_renderer(mut self, renderer: Rc) -> Self { + self.renderer = renderer; + self + } + + pub fn id(&self) -> SharedString { + self.id.clone() + } + + pub fn version(&self) -> Option { + self.version + } + + /// Change the schema version a later [`dump`](Self::dump) writes. + /// + /// Set at construction for an area whose layout never changes shape. A + /// host that installs one of several preset layouts into the same area + /// picks the version with the preset, which is after the area exists. + pub fn set_version(&mut self, version: Option, cx: &mut Context) { + self.version = version; + cx.notify(); + } + + /// The area's own bounds, recorded each frame. Dock resizing measures + /// against it. + pub fn bounds(&self) -> Bounds { + self.bounds + } + + /// The tree for one region, or `None` for a dock that does not exist. + /// + /// The `Option` is in the signature rather than hidden behind a panic + /// because a dock is genuinely optional — it is `Option` in the + /// persisted schema — and there is no borrowable empty tree to hand back + /// for one that is absent. + pub fn layout(&self, placement: DockPlacement) -> Option<&PaneTree> { + match placement { + DockPlacement::Center => Some(&self.center), + _ => self.docks.get(&placement).map(|pane| &pane.tree), + } + } + + /// The live view for a panel, if the dock still holds it. + pub fn panel(&self, panel: PanelId) -> Option<&Arc> { + self.panels.get(&panel) + } + + pub fn is_locked(&self) -> bool { + self.locked + } + + /// Whether a region currently holds no visible panel. + /// + /// This is the question the old `DockItem::is_empty` answered, and it is + /// the same one [`Self::is_node_visible`] answers per container: a region + /// is empty when nothing in it would be drawn. A region that does not + /// exist — a dock that was never installed — is empty too. + pub fn is_empty(&self, placement: DockPlacement, cx: &App) -> bool { + self.layout(placement) + .is_none_or(|tree| !self.is_node_visible(tree.root(), cx)) + } + + /// Lock the layout against rearranging. Resizing stays available. + pub fn set_locked(&mut self, locked: bool, window: &mut Window, cx: &mut Context) { + if self.locked == locked { + return; + } + self.locked = locked; + // The lock is one of the facts every group is told, so it only takes + // effect once the groups have been re-told. + self.reconcile(window, cx); + } +} + +/// Installing layouts: the center region and the three docks. +impl DockArea { + /// Replace the center region with a described layout. Whatever was there + /// leaves the dock, so its panels are told [`Panel::on_removed`]. + pub fn set_center(&mut self, layout: DockLayout, window: &mut Window, cx: &mut Context) { + let (tree, panels) = PaneTree::from_layout(layout, RootKind::Split); + self.center = tree; + self.panels.extend(panels); + self.reconcile(window, cx); + cx.emit(DockEvent::LayoutChanged); + } + + /// Replace one dock with a described layout, creating the dock if the area + /// does not have one there yet. A new dock keeps the size and open state of + /// the one it replaces, so re-filling a dock does not resize it. + /// + /// [`DockPlacement::Center`] defers to [`Self::set_center`]: the center is + /// not a dock and has no size or open state of its own. + pub fn set_dock( + &mut self, + placement: DockPlacement, + layout: DockLayout, + window: &mut Window, + cx: &mut Context, + ) { + if placement == DockPlacement::Center { + return self.set_center(layout, window, cx); + } + + let (tree, panels) = PaneTree::from_layout(layout, RootKind::Any); + let dock = self + .docks + .get(&placement) + .map(|pane| pane.dock) + .unwrap_or_else(|| Dock::new(PANEL_MIN_SIZE * 2.)); + self.docks.insert(placement, DockRegion { tree, dock }); + self.panels.extend(panels); + self.reconcile(window, cx); + cx.emit(DockEvent::LayoutChanged); + } + + /// Take a dock away entirely, panels and all. Distinct from + /// [`Self::toggle_dock`], which only takes it off screen. + pub fn remove_dock( + &mut self, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) { + if self.docks.remove(&placement).is_none() { + return; + } + // The dock's panels leave the dock entirely, so they are told, unlike + // the panels of a dock that merely closed. + self.reconcile(window, cx); + cx.emit(DockEvent::LayoutChanged); + } + + pub fn has_dock(&self, placement: DockPlacement) -> bool { + self.docks.contains_key(&placement) + } + + /// Whether a dock is on screen. A dock the area does not have is never + /// open, so this answers the question a caller usually means without a + /// preceding [`Self::has_dock`]. + pub fn is_dock_open(&self, placement: DockPlacement) -> bool { + self.docks + .get(&placement) + .is_some_and(|pane| pane.dock.is_open()) + } + + /// Open a closed dock or close an open one. A dock that is not + /// collapsible refuses to close; there is nothing to refuse when opening. + pub fn toggle_dock( + &mut self, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) { + let Some(pane) = self.docks.get_mut(&placement) else { + return; + }; + if !pane.dock.is_collapsible() && pane.dock.is_open() { + return; + } + let open = pane.dock.is_open(); + pane.dock.set_open(!open); + // A closed dock takes its displayed panel off screen, which the + // active-state contract counts as no panel being displayed — that is + // what `TabGroupConstraints::collapsed` carries. + self.reconcile(window, cx); + cx.emit(DockEvent::LayoutChanged); + } + + /// Whether a dock may be collapsed at all. A skin drawing a collapse + /// affordance in a tab bar reads this to decide whether to offer one. + pub fn is_dock_collapsible(&self, placement: DockPlacement) -> bool { + self.docks + .get(&placement) + .is_some_and(|pane| pane.dock.is_collapsible()) + } + + pub fn set_dock_collapsible( + &mut self, + placement: DockPlacement, + collapsible: bool, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(pane) = self.docks.get_mut(&placement) { + pane.dock.set_collapsible(collapsible); + cx.notify(); + } + } + + /// The dock's size along its axis. + pub fn dock_size(&self, placement: DockPlacement) -> Option { + self.docks.get(&placement).map(|pane| pane.dock.size()) + } + + pub fn set_dock_size( + &mut self, + placement: DockPlacement, + size: Pixels, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(pane) = self.docks.get_mut(&placement) { + pane.dock.set_size(size); + cx.notify(); + } + } +} + +/// Editing the layout. +impl DockArea { + /// Add a panel to a region, merging it into the first tab group there, + /// placing it on the region's tiles canvas if that is what the region is, + /// or starting a group when the region is empty. + pub fn add_panel( + &mut self, + panel: Entity

, + placement: DockPlacement, + size: Option, + window: &mut Window, + cx: &mut Context, + ) { + let id = PanelId::from(panel.entity_id()); + self.add_panel_inner( + id, + Arc::new(panel), + placement, + Added::Anywhere(size), + window, + cx, + ); + } + + /// Add an already-wrapped panel handle to a region. + /// + /// The companion to [`Self::add_panel`], for a layer that hands base its + /// own concrete handle — see [`PanelView::as_any`] — rather than a bare + /// entity. The id comes from [`PanelView::panel_id`], which is the only + /// place it can come from once the entity is behind the handle. + pub fn add_panel_view( + &mut self, + panel: Arc, + placement: DockPlacement, + size: Option, + window: &mut Window, + cx: &mut Context, + ) { + let id = panel.panel_id(cx); + self.add_panel_inner(id, panel, placement, Added::Anywhere(size), window, cx); + } + + /// Add a panel to a region's tiles canvas at `bounds`. + /// + /// [`Self::add_panel`] places a tile too, but only where the canvas + /// itself chooses; this is for a host that knows where the tile belongs — + /// most of all one acting on [`DockEvent::DragDrop`] with a + /// [`DropTarget::Canvas`], which reports *that* something was dropped on + /// a canvas and leaves placing it to the host. + /// + /// A region with no tiles canvas has nowhere to put a tile, so nothing + /// happens and the panel is not registered. + pub fn add_tile( + &mut self, + panel: Entity

, + placement: DockPlacement, + bounds: Bounds, + window: &mut Window, + cx: &mut Context, + ) { + let id = PanelId::from(panel.entity_id()); + self.add_panel_inner( + id, + Arc::new(panel), + placement, + Added::AsTile(bounds), + window, + cx, + ); + } + + /// [`Self::add_tile`] for an already-wrapped handle, for the same reason + /// [`Self::add_panel_view`] is the companion to [`Self::add_panel`]. + pub fn add_tile_view( + &mut self, + panel: Arc, + placement: DockPlacement, + bounds: Bounds, + window: &mut Window, + cx: &mut Context, + ) { + let id = panel.panel_id(cx); + self.add_panel_inner(id, panel, placement, Added::AsTile(bounds), window, cx); + } + + fn add_panel_inner( + &mut self, + id: PanelId, + panel: Arc, + placement: DockPlacement, + added: Added, + window: &mut Window, + cx: &mut Context, + ) { + // The registration is written before the target is resolved, because + // both want `&mut self`, so an add that finds nowhere to put the panel + // has to undo it. *Undo*, not remove: adding a panel the dock already + // holds is a legitimate call — a host re-placing one it owns — and + // dropping its view would strand it in a tree with no entity, which is + // what `reconcile`'s `views_of` asserts against. Restoring the previous + // handle rather than keeping this one matters too: the two differ when + // a panel registered through `add_panel_view` is then named by + // `add_tile`, and keeping the bare entity would cost the panel its + // title for a call that otherwise did nothing. + let previous = self.panels.insert(id, panel); + + // A dock is created to hold the panel, but only for a caller that will + // take whatever shape the region offers. A tile has to land on a + // canvas, and a freshly made dock has none, so making one here would + // leave an empty dock behind after the insert below declines. + if matches!(added, Added::Anywhere(_)) + && placement != DockPlacement::Center + && !self.docks.contains_key(&placement) + { + self.docks.insert( + placement, + DockRegion { + tree: PaneTree::new(RootKind::Any), + dock: Dock::new(added.dock_size().unwrap_or(PANEL_MIN_SIZE * 2.)), + }, + ); + } + + let Some(tree) = self.tree_mut(placement) else { + self.restore_registration(id, previous); + return; + }; + let target = match added { + // An explicit tile: only a canvas can hold it. Falling back to a + // tab group would put the panel somewhere the caller did not ask + // for and silently discard the bounds. + Added::AsTile(bounds) => match first_tiles_canvas(tree.root()) { + Some(node) => InsertTarget::Tile { node, bounds }, + None => { + self.restore_registration(id, previous); + return; + } + }, + Added::Anywhere(size) => match first_tab_group(tree.root()) { + Some(node) => InsertTarget::Tabs { + node, + ix: None, + activate: true, + }, + // A region that is a tiles canvas takes a tile, at the + // placement `TileMeta` defaults to — which is what the old + // `DockItem::add_panel`'s `Tiles` arm did with no bounds in + // hand. Splitting a canvas in two instead would wrap the whole + // thing in a stack the user never asked for. + None => match first_tiles_canvas(tree.root()) { + Some(node) => InsertTarget::Tile { + node, + bounds: TileMeta::default().bounds, + }, + // An empty region has no container to merge into, so the + // panel makes one beside the root. `normalize` then removes + // the emptied root and, for a dock, collapses the wrapper + // away again. + None => InsertTarget::Split { + node: tree.root().id(), + placement: Placement::Right, + size, + }, + }, + }, + }; + let result = tree.insert_panel(id, target); + if !result.changed() { + // Nothing took the panel, so a newly registered one must not + // linger in the view map and be told `on_removed` by the next + // reconcile. + self.restore_registration(id, previous); + return; + } + self.commit(result, window, cx); + } + + /// Put the view map back the way an add found it, for one that placed + /// nothing. `previous` is what [`HashMap::insert`] handed back. + fn restore_registration(&mut self, id: PanelId, previous: Option>) { + match previous { + Some(view) => self.panels.insert(id, view), + None => self.panels.remove(&id), + }; + } + + /// Remove a panel from wherever it lives, telling it that it was removed. + pub fn remove_panel( + &mut self, + panel: Entity

, + window: &mut Window, + cx: &mut Context, + ) { + self.remove_panel_id(PanelId::from(panel.entity_id()), window, cx); + } + + /// Move a panel to a new home. The panel never leaves the dock, so it is + /// never told it was removed. + pub fn move_panel( + &mut self, + panel: PanelId, + target: InsertTarget, + window: &mut Window, + cx: &mut Context, + ) { + let Some(destination) = self.placement_of_node(target_node(&target)) else { + return; + }; + let source = self.placement_of_panel(panel); + + // A split target divides an existing slot, so the tree needs real + // pixels to divide. + if matches!(target, InsertTarget::Split { .. }) { + self.adopt_measured_sizes(destination, cx); + } + + // Read what the source group last told the panel *before* the edit, + // so the destination can be seeded with it. Without this a panel + // dragged between groups while displayed is told `true` twice. + let was_active = self + .layout(source.unwrap_or(destination)) + .and_then(|tree| tree.find_panel_node(panel)) + .and_then(|node| self.groups.get(&node)) + .and_then(|cached| cached.entity.read(cx).last_notified_active(panel)); + + let changed = match source { + Some(source) if source == destination => { + let Some(tree) = self.tree_mut(destination) else { + return; + }; + tree.move_panel(panel, target).changed() + } + source => { + // Across trees a move is a detach plus an insert. The detach's + // `removed_panels` is deliberately dropped on the floor: the + // panel is still in the dock, so it must not hear `on_removed`. + // + // Both halves are committed on, not just the insert. A target + // whose node kind does not match the insert is a silent no-op + // in `apply_insert`, and committing on the insert alone would + // early-return with the panel already gone from the source — + // stranded in `self.panels`, belonging to no tree, for the + // next reconcile to prune and destroy. + let detached = source + .and_then(|source| self.tree_mut(source)) + .is_some_and(|tree| tree.remove_panel(panel).changed()); + let Some(tree) = self.tree_mut(destination) else { + return; + }; + let inserted = tree.insert_panel(panel, target).changed(); + detached || inserted + } + }; + + self.commit_changed(changed, window, cx); + + if let Some(active) = was_active { + if let Some(cached) = self + .layout(destination) + .and_then(|tree| tree.find_panel_node(panel)) + .and_then(|node| self.groups.get(&node)) + { + let group = cached.entity.clone(); + group.update(cx, |group, _| group.seed_active(panel, active)); + } + } + } + + /// Put `panel` in a new tab group beside `node`. + pub fn split_at( + &mut self, + node: NodeId, + panel: PanelId, + placement: Placement, + window: &mut Window, + cx: &mut Context, + ) { + let Some(region) = self.placement_of_node(node) else { + return; + }; + self.adopt_measured_sizes(region, cx); + let Some(tree) = self.tree_mut(region) else { + return; + }; + let result = tree.split(node, panel, placement, None); + self.commit(result, window, cx); + } + + fn remove_panel_id(&mut self, panel: PanelId, window: &mut Window, cx: &mut Context) { + let Some(region) = self.placement_of_panel(panel) else { + return; + }; + let Some(tree) = self.tree_mut(region) else { + return; + }; + let result = tree.remove_panel(panel); + self.commit(result, window, cx); + } +} + +/// Zooming. +/// +/// There is no `set_zoomed_in(panel)` here. A zoom is a container's own act: +/// only the container knows whether its displayed panel is zoomable, and only +/// the container can tell that panel it was zoomed. So the way in is +/// [`TabGroupContext::toggle_zoom`](super::TabGroupContext::toggle_zoom) or +/// [`TileContext::toggle_zoom`](super::TileContext::toggle_zoom) — a skin has +/// one of those wherever it draws a zoom control — or +/// [`Self::set_zoomed_in`] by node, which delegates to the same place. The +/// area then installs the container that reported it. +impl DockArea { + /// Zoom the tab group at `node` in, as if its own zoom control had been + /// used. + /// + /// Nothing happens for a node that is not a live tab group, or when the + /// group refuses — the group is the one that knows. + pub fn set_zoomed_in(&mut self, node: NodeId, window: &mut Window, cx: &mut Context) { + self.set_zoom(Some(Zoomed::Group(node)), window, cx); + } + + /// Clear the zoom, putting the zoomed container's own flag back with it. + /// + /// A container toggles its zoom itself and only reports it, so an area + /// that dropped the view without telling the container would leave it + /// believing it still fills the dock — and a zoomed group refuses drops. + pub fn set_zoomed_out(&mut self, window: &mut Window, cx: &mut Context) { + self.set_zoom(None, window, cx); + } + + pub fn is_zoomed(&self) -> bool { + self.zoomed.is_some() + } + + /// The tab group filling the area, if a group is what is zoomed. + pub fn zoomed_group(&self) -> Option { + match self.zoomed { + Some(Zoomed::Group(node)) => Some(node), + _ => None, + } + } + + /// The tile filling the area, if a tile is what is zoomed. + pub fn zoomed_tile(&self) -> Option { + match self.zoomed { + Some(Zoomed::Tile { panel, .. }) => Some(panel), + _ => None, + } + } + + /// The one place `self.zoomed` is written. + /// + /// Every write drives the container's own flag through the same call, and + /// the new target is recorded only if that container accepted it. So the + /// area cannot show a container the container does not think is zoomed, + /// and cannot leave a container flagged zoomed while showing something + /// else — the split state a group would carry as a permanent lock. + fn set_zoom(&mut self, zoomed: Option, window: &mut Window, cx: &mut Context) { + if self.zoomed == zoomed { + return; + } + + if let Some(previous) = self.zoomed { + self.drive_zoom(previous, false, window, cx); + } + let accepted = match zoomed { + Some(next) => self.drive_zoom(next, true, window, cx).then_some(next), + None => None, + }; + self.zoomed = accepted; + cx.notify(); + } + + /// Ask one container to zoom in or out, and report whether it now agrees. + /// + /// A container that has already been left out of the cache — its node is + /// gone from the tree — has nothing to say and nothing to put back, so it + /// answers `false` and a zoom naming it is never installed. + fn drive_zoom( + &mut self, + zoomed: Zoomed, + zoom_in: bool, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match zoomed { + Zoomed::Group(node) => { + let Some(group) = self.groups.get(&node).map(|cached| cached.entity.clone()) else { + return false; + }; + group.update(cx, |group, cx| { + group.set_zoomed(zoom_in, window, cx); + group.is_zoomed() == zoom_in + }) + } + Zoomed::Tile { node, panel } => { + let Some(canvas) = self.tiles.get(&node).map(|cached| cached.entity.clone()) else { + return false; + }; + canvas.update(cx, |canvas, cx| { + canvas.set_zoomed(zoom_in.then_some(panel), window, cx); + canvas.zoomed_tile() == zoom_in.then_some(panel) + }) + } + } + } + + /// The container that fills the area when something is zoomed. + /// + /// The container, not the panel inside it: this is what keeps a zoomed + /// group's tab bar and a zoomed tile's chrome on screen. + fn zoomed_view(&self) -> Option { + match self.zoomed? { + Zoomed::Group(node) => Some(self.groups.get(&node)?.entity.clone().into()), + Zoomed::Tile { node, .. } => Some(self.tiles.get(&node)?.entity.clone().into()), + } + } +} + +/// Persistence. +impl DockArea { + /// Read a persisted layout, rebuilding every panel through + /// [`PanelRegistry`]. A panel this build does not know about becomes a + /// placeholder that carries the original [`PanelState`] forward, so the + /// next save does not erase it. + pub fn load( + &mut self, + state: DockAreaState, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + self.version = state.version; + self.zoomed = None; + // Nothing in the old layout survives a load, so the caches are + // emptied rather than reconciled: every node id in the new trees is + // freshly minted and would miss the old cache anyway. + self.groups.clear(); + self.splits.clear(); + self.tiles.clear(); + self.docks.clear(); + // `self.panels` is deliberately *not* cleared: leaving the outgoing + // panels in it lets `reconcile` prune them, which is what tells them + // they were removed. + + let dock_area = self.this.clone(); + let renderer = self.renderer.clone(); + let mut built = Vec::new(); + self.center = { + let mut builder = RegistryPanelBuilder { + dock_area: dock_area.clone(), + renderer: renderer.clone(), + built: &mut built, + window, + cx, + }; + PaneTree::from_state(&state.center, RootKind::Split, &mut builder) + }; + + for dock_state in [state.left_dock, state.right_dock, state.bottom_dock] + .into_iter() + .flatten() + { + let tree = { + let mut builder = RegistryPanelBuilder { + dock_area: dock_area.clone(), + renderer: renderer.clone(), + built: &mut built, + window, + cx, + }; + PaneTree::from_state(dock_state.panel(), RootKind::Any, &mut builder) + }; + let mut dock = Dock::new(dock_state.size()); + dock.set_open(dock_state.open()); + self.docks + .insert(dock_state.placement(), DockRegion { tree, dock }); + } + + self.panels.extend(built); + self.reconcile(window, cx); + cx.emit(DockEvent::LayoutChanged); + Ok(()) + } + + /// Write the layout out. + /// + /// Slot sizes are resolved to concrete pixels first. The tree represents + /// an unconstrained slot as `None` and the writer emits `0.0` for it, + /// which *this* reader maps back to `None` — but an older build has no + /// notion of the sentinel and would construct a real zero-pixel panel from + /// it. Preference order is the split's measured size, then the tree's own + /// size, then [`PANEL_MIN_SIZE`] for a slot nothing has ever measured. + /// + /// The measurement wins because the tree does not track every change to + /// it. `ResizableState` only emits `Resized` from a finished drag, so that + /// is all the subscription in [`Self::split_entity`] writes back; + /// `adjust_to_container_size` rescales every slot silently on each window + /// resize, insert and remove. Preferring the tree would persist load-time + /// or last-drag pixels after a window resize, and worse, mix them: a slot + /// left `None` by a later insert would be filled from the current + /// measurement while its untouched siblings kept file-era numbers, so the + /// written ratio would match neither the file nor the screen. Reading the + /// measurement for every slot of a split writes one internally consistent + /// set, which is what the old `StackPanel::dump` did. + pub fn dump(&self, cx: &App) -> DockAreaState { + let source = LivePanels::new(&self.panels, cx); + + DockAreaState { + version: self.version, + center: self.resolved_tree(&self.center, cx).to_state(&source), + left_dock: self.dump_dock(DockPlacement::Left, &source, cx), + right_dock: self.dump_dock(DockPlacement::Right, &source, cx), + bottom_dock: self.dump_dock(DockPlacement::Bottom, &source, cx), + } + } + + fn dump_dock( + &self, + placement: DockPlacement, + source: &LivePanels<'_>, + cx: &App, + ) -> Option { + let pane = self.docks.get(&placement)?; + Some(DockState::new( + self.resolved_tree(&pane.tree, cx).to_state(source), + placement, + pane.dock.size(), + pane.dock.is_open(), + )) + } + + fn resolved_tree(&self, tree: &PaneTree, cx: &App) -> PaneTree { + let mut tree = tree.clone(); + self.resolve_sizes(tree.root_mut(), cx); + tree + } + + fn resolve_sizes(&self, node: &mut PaneNode, cx: &App) { + let measured = self + .splits + .get(&node.id()) + .map(|cached| cached.entity.read(cx).sizes().clone()) + .unwrap_or_default(); + + let NodeKind::Split { + children, sizes, .. + } = node.kind_mut() + else { + return; + }; + + for (ix, size) in sizes.iter_mut().enumerate() { + // A slot already holding zero is exactly as unsafe as an absent + // one: it is the same byte in the file and the same zero-pixel + // panel in an older build. So both the measurement and the stored + // value have to clear zero before they can be written. + let on_screen = measured.get(ix).copied().filter(|size| *size > px(0.)); + let stored = (*size).filter(|size| *size > px(0.)); + *size = Some(on_screen.or(stored).unwrap_or(PANEL_MIN_SIZE)); + } + + for child in children.iter_mut() { + self.resolve_sizes(child, cx); + } + } +} + +/// Reconciliation. +impl DockArea { + /// Apply one edit: bring the entity cache back in line and say so. + /// + /// `on_removed` is not fired from `EditResult::removed_panels` here. + /// [`Self::reconcile`] fires it instead, from the panels it prunes: that + /// is the same set for a plain removal, and it also covers the panels a + /// wholesale `set_center`, `set_dock`, `remove_dock` or `load` displaces, + /// which no `EditResult` describes at all. It is also the safer default — + /// a caller cannot forget a list it does not pass. + fn commit(&mut self, result: EditResult, window: &mut Window, cx: &mut Context) { + self.commit_changed(result.changed(), window, cx); + } + + /// [`Self::commit`] for an edit that took more than one `EditResult`. + fn commit_changed(&mut self, changed: bool, window: &mut Window, cx: &mut Context) { + if !changed { + return; + } + + self.reconcile(window, cx); + cx.emit(DockEvent::LayoutChanged); + } + + /// Bring the entity cache in line with the trees. + /// + /// Because `NodeId` survives every edit and every normalization rule, a + /// steady-state pass creates and drops nothing; only genuinely new or dead + /// containers churn. That is what keeps a drag from resetting the state of + /// panels it did not touch. + fn reconcile(&mut self, window: &mut Window, cx: &mut Context) { + // Planned first, applied second: the plan borrows the trees, and + // applying it needs `&mut self` to fill the caches. + let mut plans = Vec::new(); + plan_tree(&self.center, false, self.locked, &mut plans); + for pane in self.docks.values() { + plan_tree(&pane.tree, !pane.dock.is_open(), self.locked, &mut plans); + } + + // Sets rather than vectors: these are membership tests, run once per + // cached entity and once per live panel, and a drag reaches this path + // on every mouse move. + let mut live_nodes = HashSet::with_capacity(plans.len()); + let mut live_panels: HashSet = HashSet::new(); + + for plan in plans { + live_nodes.insert(plan.node()); + match plan { + ContainerPlan::Split { + node, + axis, + children, + sizes, + } => { + let state = self.split_entity(node, cx); + let previous = self + .splits + .get(&node) + .map(|cached| cached.children.clone()) + .unwrap_or_default(); + state.update(cx, |state, cx| { + sync_split_panels(state, &previous, &children, &sizes, cx); + state.sync_panels_count(axis, children.len(), cx); + // The tree is authoritative on how space divides, so + // its sizes land last — `insert_panel` renormalizes + // everything it touches, which would otherwise undo + // the share an edit just decided. + state.adopt_sizes(&sizes, cx); + }); + if let Some(cached) = self.splits.get_mut(&node) { + cached.children = children; + } + } + ContainerPlan::Group { + node, + panels, + active_ix, + constraints, + } => { + live_panels.extend(panels.iter().copied()); + let views = self.views_of(&panels); + let group = self.group_entity(node, window, cx); + group.update(cx, |group, cx| { + // Every group must be told this. A group nobody has + // constrained stays `sealed()` and silently declines + // drags, drops and closes. + group.set_constraints(constraints, window, cx); + group.sync_from_tree(views, active_ix, window, cx); + }); + } + ContainerPlan::Tiles { node, tiles } => { + live_panels.extend(tiles.iter().map(|(panel, _, _)| *panel)); + let mirrored = tiles + .iter() + .filter_map(|(panel, bounds, z_index)| { + self.panels + .get(panel) + .map(|view| (view.clone(), *bounds, *z_index)) + }) + .collect(); + let canvas = self.tiles_entity(node, window, cx); + canvas.update(cx, |canvas, cx| canvas.sync_from_tree(mirrored, cx)); + } + } + } + + self.groups.retain(|node, _| live_nodes.contains(node)); + self.splits.retain(|node, _| live_nodes.contains(node)); + self.tiles.retain(|node, _| live_nodes.contains(node)); + + let departed: Vec> = self + .panels + .iter() + .filter(|(panel, _)| !live_panels.contains(panel)) + .map(|(_, view)| view.clone()) + .collect(); + self.panels.retain(|panel, _| live_panels.contains(panel)); + + // A zoomed container fills the whole area, so one that has just left + // the dock would otherwise keep filling it with nothing behind it. + // + // It is the container going away that ends the zoom, not a panel: + // a group survives its displayed panel closing, and the next tab + // takes over, still zoomed. The old `TabPanel::remove_panel` instead + // emitted `ZoomOut` on every removal, which cleared the dock's zoom + // even for a panel in some other tab panel entirely — and left the + // zoomed `TabPanel` still flagged zoomed while the dock was not. + let zoom_survives = match self.zoomed { + Some(Zoomed::Group(node)) => self.groups.contains_key(&node), + Some(Zoomed::Tile { node, panel }) => { + self.tiles.contains_key(&node) && self.panels.contains_key(&panel) + } + None => true, + }; + if !zoom_survives { + self.set_zoom(None, window, cx); + } + + cx.notify(); + // Last, so a panel reacting to this sees a dock that already agrees + // with its trees. + for view in departed { + view.on_removed(window, cx); + } + } + + /// The views for a group's panel ids, in tab order. + fn views_of(&self, panels: &[PanelId]) -> Vec> { + debug_assert!( + panels.iter().all(|panel| self.panels.contains_key(panel)), + "every panel in a tree must have a live view; a missing one would \ + silently shift the group's active index" + ); + panels + .iter() + .filter_map(|panel| self.panels.get(panel).cloned()) + .collect() + } + + fn group_entity( + &mut self, + node: NodeId, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(cached) = self.groups.get(&node) { + return cached.entity.clone(); + } + + let renderer = self.renderer.tab_group_renderer(); + let entity = cx.new(|cx| TabGroup::new(node, window, cx).with_renderer(renderer)); + let subscription = cx.subscribe_in(&entity, window, Self::on_tab_group_event); + self.groups.insert( + node, + Cached { + entity: entity.clone(), + _subscription: subscription, + }, + ); + entity + } + + fn tiles_entity( + &mut self, + node: NodeId, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(cached) = self.tiles.get(&node) { + return cached.entity.clone(); + } + + let renderer = self.renderer.tiles_renderer(); + let entity = cx.new(|cx| TilesState::new(node, window, cx).with_renderer(renderer)); + let subscription = cx.subscribe_in(&entity, window, Self::on_tiles_event); + self.tiles.insert( + node, + Cached { + entity: entity.clone(), + _subscription: subscription, + }, + ); + entity + } + + fn split_entity(&mut self, node: NodeId, cx: &mut Context) -> Entity { + if let Some(cached) = self.splits.get(&node) { + return cached.entity.clone(); + } + + let entity = cx.new(|_| ResizableState::default()); + // A drag on a resize handle changes only the measured sizes. Writing + // them straight back keeps the tree describing the layout the user + // arranged, which is what a later insert or removal scales from and + // what a region with no live split entity is dumped from. + // + // It does not make the tree authoritative on slot sizes generally, and + // `dump` does not treat it as such: only a finished drag arrives here, + // while `adjust_to_container_size` rewrites the measurements silently + // on every window resize. See [`Self::dump`]. + let subscription = + cx.subscribe(&entity, move |this, state, _: &ResizablePanelEvent, cx| { + let sizes: Vec> = state + .read(cx) + .sizes() + .iter() + .map(|size| Some(*size)) + .collect(); + let Some(region) = this.placement_of_node(node) else { + return; + }; + let Some(tree) = this.tree_mut(region) else { + return; + }; + if tree.set_sizes(node, sizes).changed() { + cx.emit(DockEvent::LayoutChanged); + } + }); + self.splits.insert( + node, + CachedSplit { + entity: entity.clone(), + children: Vec::new(), + _subscription: subscription, + }, + ); + entity + } +} + +/// Intents arriving from the containers. +impl DockArea { + fn on_tab_group_event( + &mut self, + group: &Entity, + event: &TabGroupEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + TabGroupEvent::Drop { panel, target, .. } => { + self.move_panel(*panel, *target, window, cx) + } + TabGroupEvent::DragDrop { item, target } => cx.emit(DockEvent::DragDrop { + item: item.clone(), + target: target.clone(), + }), + TabGroupEvent::ClosePanel { panel } => self.remove_panel_id(*panel, window, cx), + TabGroupEvent::ActiveChanged { ix } => { + let node = group.read(cx).node(); + let Some(region) = self.placement_of_node(node) else { + return; + }; + let Some(tree) = self.tree_mut(region) else { + return; + }; + let result = tree.set_active(node, *ix); + self.commit(result, window, cx); + } + TabGroupEvent::ZoomIn => { + let node = group.read(cx).node(); + self.set_zoom(Some(Zoomed::Group(node)), window, cx); + } + // Only the group that is actually on screen can give the dock + // back. A group told to zoom out to make room for another one + // reports it too, and that report must not undo the zoom that + // replaced it. + TabGroupEvent::ZoomOut => { + let node = group.read(cx).node(); + if self.zoomed == Some(Zoomed::Group(node)) { + self.set_zoom(None, window, cx); + } + } + } + } + + fn on_tiles_event( + &mut self, + canvas: &Entity, + event: &TilesEvent, + window: &mut Window, + cx: &mut Context, + ) { + let node = canvas.read(cx).node(); + let Some(region) = self.placement_of_node(node) else { + return; + }; + + match event { + TilesEvent::BoundsChanged { panel, bounds } => { + let Some(tree) = self.tree_mut(region) else { + return; + }; + let result = tree.set_tile_bounds(*panel, *bounds); + self.commit(result, window, cx); + } + TilesEvent::BringToFront { panel } => { + let Some(tree) = self.tree_mut(region) else { + return; + }; + let result = tree.bring_to_front(*panel); + self.commit(result, window, cx); + } + TilesEvent::ClosePanel { panel } => self.remove_panel_id(*panel, window, cx), + TilesEvent::DragDrop { item } => cx.emit(DockEvent::DragDrop { + item: item.clone(), + target: DropTarget::Canvas, + }), + TilesEvent::ZoomIn { panel } => { + self.set_zoom( + Some(Zoomed::Tile { + node, + panel: *panel, + }), + window, + cx, + ); + } + // As with a tab group: only the canvas actually on screen can + // give the dock back. + TilesEvent::ZoomOut => { + if matches!(self.zoomed, Some(Zoomed::Tile { node: zoomed, .. }) if zoomed == node) + { + self.set_zoom(None, window, cx); + } + } + } + } +} + +/// Region lookup. +impl DockArea { + /// Feed every split's measured size back into the tree before an edit + /// that has to divide space. + /// + /// Done here rather than continuously: the tree is the record of what the + /// user arranged, and rewriting it on every layout pass would let a + /// transient window size become the stored layout. + fn adopt_measured_sizes(&mut self, placement: DockPlacement, cx: &App) { + let measured: HashMap> = self + .splits + .iter() + // Only splits that have actually been laid out. A split created + // earlier in this same edit has a zero container and its `sizes` + // are whatever `insert_panel` left behind — adopting those would + // freeze a placeholder ratio into the tree, and every later edit + // would divide space according to it. + .filter(|(_, cached)| cached.entity.read(cx).container_size() > Pixels::ZERO) + .map(|(node, cached)| (*node, cached.entity.read(cx).sizes().clone())) + .collect(); + + if let Some(tree) = self.tree_mut(placement) { + tree.adopt_measured_sizes(&measured); + } + } + + fn tree_mut(&mut self, placement: DockPlacement) -> Option<&mut PaneTree> { + match placement { + DockPlacement::Center => Some(&mut self.center), + _ => self.docks.get_mut(&placement).map(|pane| &mut pane.tree), + } + } + + /// Which region a container belongs to. Unambiguous because `NodeId`s are + /// allocated globally rather than per tree. + fn placement_of_node(&self, node: NodeId) -> Option { + if self.center.find_node(node).is_some() { + return Some(DockPlacement::Center); + } + self.docks + .iter() + .find(|(_, pane)| pane.tree.find_node(node).is_some()) + .map(|(placement, _)| *placement) + } + + fn placement_of_panel(&self, panel: PanelId) -> Option { + if self.center.find_panel_node(panel).is_some() { + return Some(DockPlacement::Center); + } + self.docks + .iter() + .find(|(_, pane)| pane.tree.find_panel_node(panel).is_some()) + .map(|(placement, _)| *placement) + } + + /// Resize one dock from a pointer position, clamped so neither this dock + /// nor the one opposite is squeezed below its minimum. + fn resize_dock( + &mut self, + placement: DockPlacement, + pointer: Point, + cx: &mut Context, + ) { + let opposite = match placement { + DockPlacement::Left => self.dock_size(DockPlacement::Right), + DockPlacement::Right => self.dock_size(DockPlacement::Left), + _ => None, + }; + let sizing = DockSizing::new(placement) + .with_area_bounds(self.bounds) + .with_opposite_dock_size(opposite.unwrap_or(px(0.))); + let size = sizing.clamp(sizing.size_from_pointer(pointer)); + + if let Some(pane) = self.docks.get_mut(&placement) { + pane.dock.set_size(size); + cx.notify(); + } + } +} + +/// Rendering. +impl DockArea { + /// Lower one container to an element. + fn render_node(&self, node: &PaneNode, window: &mut Window, cx: &mut App) -> AnyElement { + match node.kind() { + PaneRef::Split { + axis, + children, + sizes, + } => { + let group = match axis { + Axis::Horizontal => h_resizable(("dock-split", node.id().as_u64())), + Axis::Vertical => v_resizable(("dock-split", node.id().as_u64())), + }; + // A container whose every panel is hidden must not keep + // occupying its slot. No renderer hook could supply this: + // only the area can see the panels behind a node. + let shown: Vec = children + .iter() + .map(|child| self.is_node_visible(child, cx)) + .collect(); + // The slot that absorbs the leftover has to be one that is + // actually drawn. A hidden slot renders nothing and grows + // nothing, so making it the flexible one leaves every drawn + // slot rigid and the split ends short of its container — the + // empty strip this picks the *last shown* slot to avoid. + let grows = shown.iter().rposition(|shown| *shown); + let panels: Vec<_> = children + .iter() + .zip(sizes.iter()) + .enumerate() + .map(|(ix, (child, size))| { + resizable_panel() + .visible(shown[ix]) + .child(self.render_node(child, window, cx)) + // `flex_none` is what makes the size stick. + // `ResizablePanel` sets `flex_grow: 1` on itself, + // so a slot given a size would otherwise treat it + // as a flex-basis and still absorb an equal share + // of the leftover — a 200px sidebar rendering + // 1075px wide in a 1950px split. + // The growth slot absorbs container growth. A + // drag records every measured size as pixels; if + // all of them became `flex_none`, a later viewport + // resize would leave an empty strip after the + // split instead of keeping the Dock filled. + .when_some(*size, |panel, size| { + panel + .size(size) + .when(Some(ix) != grows, |panel| panel.flex_none()) + }) + }) + .collect(); + + let group = group + .when_some(self.splits.get(&node.id()), |group, cached| { + group.with_state(&cached.entity) + }) + .with_handle_appearance({ + let renderer = self.renderer.clone(); + Rc::new(move |handle, window, cx| { + renderer.render_split_handle(handle, window, cx) + }) + }) + .children(panels); + + self.renderer + .split_frame(node.id(), axis, window, cx) + .child(group) + .into_any_element() + } + PaneRef::Tabs { .. } => match self.groups.get(&node.id()) { + Some(cached) => cached.entity.clone().into_any_element(), + None => Empty.into_any_element(), + }, + PaneRef::Tiles { .. } => match self.tiles.get(&node.id()) { + Some(cached) => cached.entity.clone().into_any_element(), + None => Empty.into_any_element(), + }, + } + } + + /// Whether anything in this container is on screen. + /// + /// Mirrors the old `StackPanel::render`, which asked each slot's + /// `TabPanel::visible` — "does this group hold any visible panel?" — and + /// hid the slot when it did not. + fn is_node_visible(&self, node: &PaneNode, cx: &App) -> bool { + let panels = LivePanels::new(&self.panels, cx); + match node.kind() { + PaneRef::Split { children, .. } => { + children.iter().any(|child| self.is_node_visible(child, cx)) + } + PaneRef::Tabs { panels: ids, .. } => ids.iter().any(|panel| panels.is_visible(*panel)), + PaneRef::Tiles { panels: tiles } => { + tiles.iter().any(|tile| panels.is_visible(tile.panel())) + } + } + } + + fn render_dock( + &self, + placement: DockPlacement, + window: &mut Window, + cx: &mut App, + ) -> Option { + let pane = self.docks.get(&placement)?; + let content = self.render_node(pane.tree.root(), window, cx); + let dock = self.dock_context(placement, &pane.dock); + Some(self.renderer.render_dock(&dock, content, window, cx)) + } + + fn dock_context(&self, placement: DockPlacement, dock: &Dock) -> DockContext { + let area = self.this.clone(); + + DockContext { + placement, + size: dock.size(), + open: dock.is_open(), + collapsible: dock.is_collapsible(), + on_toggle: { + let area = area.clone(); + Rc::new(move |window, cx| { + _ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx)); + }) + }, + on_resize: Rc::new(move |pointer, _, cx| { + _ = area.update(cx, |area, cx| area.resize_dock(placement, pointer, cx)); + }), + } + } +} + +impl EventEmitter for DockArea {} + +impl Focusable for DockArea { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for DockArea { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let area = cx.entity(); + let renderer = self.renderer.clone(); + + renderer + .frame(window, cx) + .on_prepaint(move |bounds, _, cx| { + area.update(cx, |area, _| area.bounds = bounds); + }) + .track_focus(&self.focus_handle) + .map(|frame| match self.zoomed_view() { + Some(view) => frame.child(view), + None => frame + .when_some( + self.render_dock(DockPlacement::Left, window, cx), + ParentElement::child, + ) + .child( + renderer + .center_frame(window, cx) + .child(self.render_node(self.center.root(), window, cx)) + .when_some( + self.render_dock(DockPlacement::Bottom, window, cx), + ParentElement::child, + ), + ) + .when_some( + self.render_dock(DockPlacement::Right, window, cx), + ParentElement::child, + ), + }) + } +} + +/// One container's worth of reconciliation work, snapshotted out of the tree. +enum ContainerPlan { + Split { + node: NodeId, + axis: Axis, + children: Vec, + sizes: Vec>, + }, + Group { + node: NodeId, + panels: Vec, + active_ix: usize, + constraints: TabGroupConstraints, + }, + Tiles { + node: NodeId, + tiles: Vec<(PanelId, Bounds, usize)>, + }, +} + +impl ContainerPlan { + fn node(&self) -> NodeId { + match self { + Self::Split { node, .. } | Self::Group { node, .. } | Self::Tiles { node, .. } => *node, + } + } +} + +/// Bring one split's `ResizableState` panel list from `previous` to `next`, +/// inserting and removing at the exact index rather than at the tail. +/// +/// `ResizableState` treats the size handed to `insert_panel` as an initial +/// value only; from then on `panels[ix].size` is authoritative. So an +/// append-and-truncate sync leaves every survivor of a non-tail removal +/// wearing the width of whoever used to sit at its index — which is the most +/// ordinary edit in the dock, a drag that empties a group, and it is carried +/// into the save file by `resolve_sizes`. +fn sync_split_panels( + state: &mut ResizableState, + previous: &[NodeId], + next: &[NodeId], + sizes: &[Option], + cx: &mut Context, +) { + let mut current = previous.to_vec(); + + // Removals from the tail, so the indices ahead of each removal stay valid. + for ix in (0..current.len()).rev() { + if !next.contains(¤t[ix]) { + if ix < state.sizes().len() { + state.remove_panel(ix, cx); + } + current.remove(ix); + } + } + + for (ix, node) in next.iter().enumerate() { + if current.get(ix) == Some(node) { + continue; + } + let at = ix.min(state.sizes().len()); + // Passed through as-is. The tree already decided this slot's share + // when the panel was inserted — a drop halves the neighbour it landed + // beside — so there is nothing to compute here, and nothing that + // depends on a container this state may not have measured yet. + state.insert_panel(sizes.get(ix).copied().flatten(), Some(at), cx); + current.insert(at.min(current.len()), *node); + } + + debug_assert_eq!( + current, next, + "the split's panel list must end up mirroring its children exactly; \ + a reordering edit would need its own case here" + ); +} + +fn plan_tree(tree: &PaneTree, collapsed: bool, locked: bool, out: &mut Vec) { + // The root has nothing beside it by definition. + plan_node(tree.root(), true, collapsed, locked, out); +} + +fn plan_node( + node: &PaneNode, + alone: bool, + collapsed: bool, + locked: bool, + out: &mut Vec, +) { + match node.kind() { + PaneRef::Split { + axis, + children, + sizes, + } => { + out.push(ContainerPlan::Split { + node: node.id(), + axis, + children: children.iter().map(PaneNode::id).collect(), + sizes: sizes.to_vec(), + }); + let children_alone = children.len() <= 1; + for child in children { + plan_node(child, children_alone, collapsed, locked, out); + } + } + PaneRef::Tabs { panels, active_ix } => out.push(ContainerPlan::Group { + node: node.id(), + panels: panels.to_vec(), + active_ix, + constraints: TabGroupConstraints::in_split(alone) + .dock_locked(locked) + .collapsed(collapsed), + }), + PaneRef::Tiles { panels } => out.push(ContainerPlan::Tiles { + node: node.id(), + tiles: panels + .iter() + .map(|tile| (tile.panel(), tile.bounds(), tile.z_index())) + .collect(), + }), + } +} + +fn first_tab_group(node: &PaneNode) -> Option { + match node.kind() { + PaneRef::Tabs { .. } => Some(node.id()), + PaneRef::Split { children, .. } => children.iter().find_map(first_tab_group), + PaneRef::Tiles { .. } => None, + } +} + +fn first_tiles_canvas(node: &PaneNode) -> Option { + match node.kind() { + PaneRef::Tiles { .. } => Some(node.id()), + PaneRef::Split { children, .. } => children.iter().find_map(first_tiles_canvas), + PaneRef::Tabs { .. } => None, + } +} + +fn target_node(target: &InsertTarget) -> NodeId { + match target { + InsertTarget::Tabs { node, .. } + | InsertTarget::Split { node, .. } + | InsertTarget::Tile { node, .. } => *node, + } +} + +/// Rebuilds panels out of persisted state through [`PanelRegistry`]. +struct RegistryPanelBuilder<'a, 'w, 'c> { + dock_area: WeakEntity, + renderer: Rc, + built: &'a mut Vec<(PanelId, Arc)>, + window: &'w mut Window, + cx: &'c mut App, +} + +impl PanelBuilder for RegistryPanelBuilder<'_, '_, '_> { + fn build(&mut self, state: &PanelState, info: &PanelInfo) -> PanelId { + let context = PanelBuildContext::new(self.dock_area.clone(), state, info); + let view = + match PanelRegistry::build_panel(&state.panel_name, context, self.window, self.cx) { + Some(view) => view, + None => self + .renderer + .build_placeholder(state, self.window, self.cx) + .unwrap_or_else(|| { + Arc::new(self.cx.new(|cx| PlaceholderPanel::new(state.clone(), cx))) + as Arc + }), + }; + + let id = view.panel_id(self.cx); + self.built.push((id, view)); + id + } +} + +/// Stands in for a panel this build cannot construct. +/// +/// It draws nothing — an "unknown panel" message is presentation and belongs +/// above this seam — but it keeps the original [`PanelState`] and hands it +/// back from [`Panel::dump`], so a layout written by a newer build survives a +/// load and save here instead of losing the panel. +struct PlaceholderPanel { + state: PanelState, + focus_handle: FocusHandle, +} + +impl PlaceholderPanel { + fn new(state: PanelState, cx: &mut Context) -> Self { + Self { + state, + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for PlaceholderPanel { + fn panel_name(&self) -> &'static str { + "InvalidPanel" + } + + fn dump(&self, _: &App) -> PanelState { + self.state.clone() + } +} + +impl EventEmitter for PlaceholderPanel {} + +impl Focusable for PlaceholderPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PlaceholderPanel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +type DockToggleHandler = Rc; +type DockResizeHandler = Rc, &mut Window, &mut App)>; + +/// What a skin needs to draw one dock, and the callbacks it invokes rather +/// than reimplementing the open/close and clamping behavior. +#[derive(Clone)] +pub struct DockContext { + placement: DockPlacement, + size: Pixels, + open: bool, + collapsible: bool, + on_toggle: DockToggleHandler, + on_resize: DockResizeHandler, +} + +impl DockContext { + pub fn placement(&self) -> DockPlacement { + self.placement + } + + /// The dock's extent along its own axis: width for left/right, height for + /// bottom. + pub fn size(&self) -> Pixels { + self.size + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn is_collapsible(&self) -> bool { + self.collapsible + } + + pub fn toggle(&self, window: &mut Window, cx: &mut App) { + (self.on_toggle)(window, cx); + } + + /// Resize from a pointer position in window coordinates. Base clamps it + /// against the area bounds and the opposite dock. + pub fn resize_to(&self, pointer: Point, window: &mut Window, cx: &mut App) { + (self.on_resize)(pointer, window, cx); + } +} + +/// Appearance for the dock area. Base draws none of it. +/// +/// The frame hooks return the element itself rather than wrapping one, for the +/// same reason [`TabGroupRenderer`]'s do: base tracks focus and records the +/// area bounds on the very element the skin styles. +/// +/// There is no separate `render_resize_handle` hook. A handle needs to be +/// positioned against the dock it resizes, and positioning is the skin's; the +/// skin draws it inside [`Self::render_dock`] and drives it through +/// [`DockContext::resize_to`]. +#[allow(unused_variables)] +pub trait DockAreaRenderer: 'static { + /// The area's outer frame, which base records its bounds on. + fn frame(&self, window: &mut Window, cx: &mut App) -> Stateful

{ + div().id("dock-area") + } + + /// One split container's frame, around base's resizable group. + /// + /// This one really is a wrapper, unlike the other frame hooks, and + /// deliberately: base attaches nothing to it, so there is no hit area to + /// separate from the painted area. It exists because the old `StackPanel` + /// carried real appearance here — a background and an overflow clip — and + /// without it a skin could style a dock and a tab group but nothing in + /// between. `Stateful
` rather than a plain one so the skin keeps a + /// role, a tooltip, and scroll tracking. + fn split_frame( + &self, + node: NodeId, + axis: Axis, + window: &mut Window, + cx: &mut App, + ) -> Stateful
{ + div().id(("dock-split-frame", node.as_u64())) + } + + /// The column holding the center region and the bottom dock. + fn center_frame(&self, window: &mut Window, cx: &mut App) -> Stateful
{ + div().id("dock-area-center") + } + + /// The painted part of the divider between two slots of a split. + /// + /// `None` keeps base's own one-pixel line, so a skin that has no opinion + /// about dividers implements nothing. The hit area, the cursor and the + /// drag itself stay with base either way — this hook supplies appearance + /// only, and is told the axis and whether the divider is being dragged. + fn render_split_handle( + &self, + handle: &ResizeHandleContext, + window: &mut Window, + cx: &mut App, + ) -> Option { + None + } + + /// One dock's chrome around its content: the title strip, the collapse + /// affordance, and the resize handle. + fn render_dock( + &self, + dock: &DockContext, + content: AnyElement, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + content + } + + /// The stand-in for a panel this build cannot construct — one whose + /// `panel_name` no [`PanelRegistry`] builder answers to. `None` takes + /// base's own placeholder, which draws nothing. + /// + /// The hook exists because a placeholder cannot be wrapped after the + /// fact: presentation reaches base only through the handle a panel is + /// registered behind, so whoever creates the panel decides what it can + /// draw. An "unknown panel" message is presentation, so the skin creates + /// that panel or does without one. + /// + /// A placeholder is what gets written back out on the next save, so one + /// supplied here should answer [`Panel::dump`] with `state` unchanged — + /// otherwise saving after a load erases the panel it stood in for. Base's + /// own placeholder does; nothing here can enforce it of a skin's. + fn build_placeholder( + &self, + state: &PanelState, + window: &mut Window, + cx: &mut App, + ) -> Option> { + None + } + + fn tab_group_renderer(&self) -> Rc; + + fn tiles_renderer(&self) -> Rc; +} + +/// The renderer an area starts with: the layout and nothing else. +struct BareDockArea; + +impl DockAreaRenderer for BareDockArea { + fn tab_group_renderer(&self) -> Rc { + Rc::new(BareTabGroup) + } + + fn tiles_renderer(&self) -> Rc { + Rc::new(BareTiles) + } +} + +#[cfg(test)] +impl DockArea { + /// Every cached container, as `(node, entity)`. + /// + /// Entity ids, not just node ids: node ids alone would compare equal even + /// if every container entity had been torn down and rebuilt under the same + /// key, which is exactly the failure the reconciliation contract exists to + /// prevent. + pub(crate) fn container_entity_ids(&self) -> Vec<(NodeId, gpui::EntityId)> { + let mut ids: Vec<(NodeId, gpui::EntityId)> = self + .groups + .iter() + .map(|(node, cached)| (*node, cached.entity.entity_id())) + .chain( + self.splits + .iter() + .map(|(node, cached)| (*node, cached.entity.entity_id())), + ) + .chain( + self.tiles + .iter() + .map(|(node, cached)| (*node, cached.entity.entity_id())), + ) + .collect(); + ids.sort(); + ids + } +} + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, VisualTestContext}; + + use std::cell::RefCell; + + use super::*; + use crate::dock::test_support::{Log, PanelSignal, TestPanel, drain, drain_active, log_of}; + use crate::dock::{TabGroupContext, TileContext}; + + fn setup(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + let _ = crate::Theme::global_mut(cx); + }); + cx.add_window_view(|window, cx| DockArea::new("test-dock", None, window, cx)) + } + + /// Two tab groups side by side, holding one logging panel each. + fn two_groups<'a>( + log: &Log, + cx: &'a mut TestAppContext, + ) -> ( + Entity, + Entity, + &'a mut VisualTestContext, + ) { + let (area, cx) = setup(cx); + let log = log.clone(); + let alpha = cx.update(|window, cx| { + let alpha = TestPanel::logging("Alpha", &log, cx); + let beta = TestPanel::logging("Beta", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha.clone()), None) + .child(DockLayout::tabs().panel(beta), None), + window, + cx, + ); + }); + alpha + }); + (area, alpha, cx) + } + + /// The id of the center split's `ix`-th child container. + fn child_node(area: &Entity, ix: usize, cx: &mut VisualTestContext) -> NodeId { + cx.read(|cx| { + let PaneRef::Split { children, .. } = area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .kind() + else { + panic!("the center root is a split"); + }; + children[ix].id() + }) + } + + fn panel_id_of(panel: &Entity) -> PanelId { + PanelId::from(panel.entity_id()) + } + + fn move_alpha_into_the_other_group( + area: &Entity, + alpha: &Entity, + cx: &mut VisualTestContext, + ) { + let target = child_node(area, 1, cx); + let alpha_id = panel_id_of(alpha); + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + alpha_id, + InsertTarget::Tabs { + node: target, + ix: None, + activate: true, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + } + + fn collect_sizes(state: &PanelState, out: &mut Vec) { + if let PanelInfo::Stack { sizes, .. } = &state.info { + out.extend(sizes.iter().copied()); + } + for child in &state.children { + collect_sizes(child, out); + } + } + + fn register_test_panels(cx: &mut App) { + for name in ["Alpha", "Beta", "Gamma"] { + crate::dock::registry::register_panel(cx, name, move |_, _, cx| { + Arc::new(TestPanel::new(name, cx)) as Arc + }); + } + } + + /// One tab group holding `names`, installed as the whole center. + /// + /// The `DockItem::tabs` the old `TabPanel` tests built is now a described + /// layout the area reconciles, so the group entity is reached through the + /// tree rather than handed back by the constructor. + fn one_group<'a>( + log: &Log, + names: &[&'static str], + active_ix: Option, + cx: &'a mut TestAppContext, + ) -> ( + Entity, + Vec>, + &'a mut VisualTestContext, + ) { + let (area, cx) = setup(cx); + let log = log.clone(); + let names = names.to_vec(); + let panels = cx.update(|window, cx| { + let panels: Vec<_> = names + .iter() + .map(|name| TestPanel::logging(name, &log, cx)) + .collect(); + let layout = panels + .iter() + .fold(DockLayout::tabs(), |layout, panel| { + layout.panel(panel.clone()) + }) + .active_index(active_ix.unwrap_or(0)); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + panels + }); + (area, panels, cx) + } + + /// The live group behind the center split's `ix`-th child. + fn group_of( + area: &Entity, + ix: usize, + cx: &mut VisualTestContext, + ) -> Entity { + let node = child_node(area, ix, cx); + cx.read(|cx| area.read(cx).groups.get(&node).unwrap().entity.clone()) + } + + fn move_panel_into( + area: &Entity, + panel: PanelId, + node: NodeId, + ix: Option, + activate: bool, + cx: &mut VisualTestContext, + ) { + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel(panel, InsertTarget::Tabs { node, ix, activate }, window, cx); + }); + }); + cx.run_until_parked(); + } + + fn is_center_empty(area: &Entity, cx: &mut VisualTestContext) -> bool { + cx.read(|cx| area.read(cx).is_empty(DockPlacement::Center, cx)) + } + + #[gpui::test] + fn a_layout_installs_and_dumps_back_to_the_same_state(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha), Some(px(300.))) + .child(DockLayout::tabs().panel(beta), None), + window, + cx, + ); + }); + }); + + let state = cx.read(|cx| area.read(cx).dump(cx)); + assert_eq!(state.center.panel_name, "StackPanel"); + assert_eq!(state.center.children.len(), 2); + assert_eq!(state.center.children[0].children[0].panel_name, "Alpha"); + assert_eq!(state.center.children[1].children[0].panel_name, "Beta"); + } + + #[gpui::test] + fn moving_a_panel_reuses_its_entity(cx: &mut TestAppContext) { + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + let destination = child_node(&area, 1, cx); + let destination_entity = cx.read(|cx| { + area.read(cx) + .groups + .get(&destination) + .unwrap() + .entity + .entity_id() + }); + + move_alpha_into_the_other_group(&area, &alpha, cx); + + assert_eq!( + cx.read(|cx| area + .read(cx) + .groups + .get(&destination) + .unwrap() + .entity + .entity_id()), + destination_entity, + "the group the panel arrived in was reused, not rebuilt" + ); + + // A liveness flag on the panel would not say this. The invariant is + // that the panel is still in the tree and was never told it was + // removed — which is exactly what `EditResult::removed_panels` encodes + // by excluding moves. + let alpha_id = panel_id_of(&alpha); + assert!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_panel_node(alpha_id)) + .is_some(), + "the moved panel is still in the tree" + ); + + let state = cx.read(|cx| area.read(cx).dump(cx)); + // One child, not two: emptying the first group removes it, and a + // `RootKind::Split` root is never collapsed, so what is left is a + // one-child split root. + assert_eq!( + state.center.children.len(), + 1, + "the emptied group collapsed out of the split" + ); + assert_eq!( + state.center.children[0].children.len(), + 2, + "both panels now share the surviving group" + ); + } + + #[gpui::test] + fn a_moved_panel_is_not_told_it_was_removed(cx: &mut TestAppContext) { + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + move_alpha_into_the_other_group(&area, &alpha, cx); + + assert!( + !drain(&log).contains(&("Alpha", PanelSignal::Removed)), + "moving a panel between groups must never deliver on_removed" + ); + } + + #[gpui::test] + fn removing_a_panel_does_tell_it_it_was_removed(cx: &mut TestAppContext) { + // Without this, `a_moved_panel_is_not_told_it_was_removed` would pass + // just as well against a `DockArea` that never calls `on_removed` at + // all. + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + cx.update(|window, cx| { + area.update(cx, |area, cx| area.remove_panel(alpha.clone(), window, cx)); + }); + cx.run_until_parked(); + + assert!( + drain(&log).contains(&("Alpha", PanelSignal::Removed)), + "a genuine removal must deliver on_removed" + ); + } + + #[gpui::test] + fn reconciling_an_unchanged_tree_creates_no_entities(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + let before = cx.read(|cx| area.read(cx).container_entity_ids()); + cx.update(|window, cx| area.update(cx, |area, cx| area.reconcile(window, cx))); + let after = cx.read(|cx| area.read(cx).container_entity_ids()); + + assert!(!before.is_empty(), "there were containers to preserve"); + assert_eq!( + before, after, + "a steady-state pass creates and drops nothing" + ); + cx.run_until_parked(); + assert_eq!( + drain(&log), + vec![], + "and no panel was re-added or re-activated by it" + ); + } + + #[gpui::test] + fn a_loaded_layout_round_trips_through_dump(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|_, cx| register_test_panels(cx)); + + let json = include_str!("fixtures/nested_splits.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + + cx.update(|window, cx| { + area.update(cx, |area, cx| area.load(state.clone(), window, cx).unwrap()) + }); + let dumped = cx.read(|cx| area.read(cx).dump(cx)); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.load(dumped.clone(), window, cx).unwrap() + }) + }); + let again = cx.read(|cx| area.read(cx).dump(cx)); + + assert_eq!(dumped, again, "load/dump must reach a fixpoint"); + assert_eq!( + dumped.center.children.len(), + 3, + "the fixture's nesting is flattened, as the state layer already pins" + ); + assert_eq!(dumped.center.children[0].children[0].panel_name, "Alpha"); + } + + #[gpui::test] + fn a_dumped_live_layout_has_no_zero_sizes(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + let gamma = TestPanel::new("Gamma", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::v_split() + // Unconstrained, which the tree stores as `None` and + // the writer would otherwise emit as 0.0. + .child(DockLayout::tabs().panel(alpha), None) + // Already zero. Resolving only the `None` slots would + // leave this one writing the same unsafe byte. + .child(DockLayout::tabs().panel(beta), Some(px(0.))) + .child(DockLayout::tabs().panel(gamma), Some(px(240.))), + window, + cx, + ); + }); + }); + + let state = cx.read(|cx| area.read(cx).dump(cx)); + let mut sizes = Vec::new(); + collect_sizes(&state.center, &mut sizes); + + assert!(!sizes.is_empty(), "the layout has slots to check"); + assert!( + sizes.iter().all(|size| *size > px(0.)), + "an older build reads a persisted 0.0 back as a real zero-pixel panel: {sizes:?}" + ); + } + + /// The tree only hears about slot sizes a drag finished on: the `Resized` + /// subscription fires from `done_resizing`, while every window resize + /// rescales `ResizableState::sizes()` silently. So `dump` reads the + /// measurement for a split it has on screen, and this pins that it does — + /// preferring the tree here would persist the described `300.0` after a + /// layout pass has already rescaled that slot to fit the window. + #[gpui::test] + fn a_dumped_split_writes_the_sizes_it_is_actually_drawn_at(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha), Some(px(300.))) + .child(DockLayout::tabs().panel(beta), Some(px(300.))), + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + let root = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + let measured = cx.read(|cx| area.read(cx).splits[&root].entity.read(cx).sizes().clone()); + assert_ne!( + measured, + vec![px(300.), px(300.)], + "the split has to have been rescaled by a layout pass, or this \ + test cannot tell the two preferences apart" + ); + + let state = cx.read(|cx| area.read(cx).dump(cx)); + let PanelInfo::Stack { sizes, .. } = &state.center.info else { + panic!("the center writes a stack"); + }; + assert_eq!( + sizes, &measured, + "the written sizes are the ones on screen, not the ones the tree \ + was built from" + ); + } + + /// A drop that splits carries no size — `TabGroup` builds + /// `InsertTarget::Split { size: None }` — so the split has to decide one, + /// and sharing the container equally is the decision. Passing the `None` + /// straight to `ResizableState` instead makes the new slot the flexible + /// one among fixed siblings, and it stops looking like a half. + #[gpui::test] + fn a_panel_dropped_beside_another_takes_half_the_split(cx: &mut TestAppContext) { + let log = Log::default(); + let (area, panels, cx) = one_group(&log, &["Alpha", "Beta"], None, cx); + let group = child_node(&area, 0, cx); + let beta = panel_id_of(&panels[1]); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + beta, + InsertTarget::Split { + node: group, + placement: Placement::Right, + size: None, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + let root = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + let sizes = cx.read(|cx| area.read(cx).splits[&root].entity.read(cx).sizes().clone()); + + assert_eq!(sizes.len(), 2, "the drop splits the center in two"); + let (left, right) = (sizes[0].as_f32(), sizes[1].as_f32()); + assert!( + (left - right).abs() <= (left + right) * 0.02, + "the two halves must be within 2% of each other, got {left} and {right}" + ); + } + + /// The other drop geometry: a placement whose axis differs from the + /// parent's wraps the target in a fresh split, so the sizes are decided + /// by a `ResizableState` that has never been measured. + #[gpui::test] + fn a_panel_dropped_across_the_axis_still_takes_half(cx: &mut TestAppContext) { + let log = Log::default(); + let (area, panels, cx) = one_group(&log, &["Alpha", "Beta"], None, cx); + let group = child_node(&area, 0, cx); + let beta = panel_id_of(&panels[1]); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + beta, + InsertTarget::Split { + node: group, + placement: Placement::Bottom, + size: None, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + // Dropping across the axis wraps the target in a new split, which + // becomes the center root's only child — the root itself still holds + // one slot. + let wrapper = child_node(&area, 0, cx); + let sizes = cx.read(|cx| { + area.read(cx).splits[&wrapper] + .entity + .read(cx) + .sizes() + .clone() + }); + assert_eq!(sizes.len(), 2, "the drop splits the group in two"); + let (top, bottom) = (sizes[0].as_f32(), sizes[1].as_f32()); + assert!( + (top - bottom).abs() <= (top + bottom) * 0.02, + "the two halves must be within 2% of each other, got {top} and {bottom}" + ); + } + + /// A drop into a split that already holds more than one slot, which is the + /// shape a real workspace is in by the time anyone drags anything. + #[gpui::test] + fn a_panel_dropped_into_a_populated_split_takes_an_even_share(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let panels = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + let gamma = TestPanel::new("Gamma", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha.clone()), Some(px(240.))) + .child( + DockLayout::tabs().panel(beta.clone()).panel(gamma.clone()), + None, + ), + window, + cx, + ); + }); + vec![alpha, beta, gamma] + }); + cx.run_until_parked(); + + let right = child_node(&area, 1, cx); + let gamma = panel_id_of(&panels[2]); + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + gamma, + InsertTarget::Split { + node: right, + placement: Placement::Right, + size: None, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + let root = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + let sizes = cx.read(|cx| area.read(cx).splits[&root].entity.read(cx).sizes().clone()); + assert_eq!(sizes.len(), 3, "three slots side by side"); + let dropped = sizes[2].as_f32(); + let neighbour = sizes[1].as_f32(); + assert!( + (dropped - neighbour).abs() <= (dropped + neighbour) * 0.02, + "the dropped panel splits its neighbour evenly, got neighbour {neighbour} and dropped {dropped}" + ); + } + + /// A dock's root is usually a bare tab group, so a drop beside it takes + /// the "wrap the target in a new split" path rather than the "insert into + /// the existing split" one — and that split has never been measured. + #[gpui::test] + fn a_panel_dropped_beside_a_dock_takes_half(cx: &mut TestAppContext) { + for placement in [DockPlacement::Bottom, DockPlacement::Left] { + let (area, cx) = setup(cx); + let dropped = cx.update(|window, cx| { + let resident = TestPanel::new("Resident", cx); + let dropped = TestPanel::new("Dropped", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::tabs().panel(TestPanel::new("Center", cx)), + window, + cx, + ); + area.set_dock( + placement, + DockLayout::tabs().panel(resident).panel(dropped.clone()), + window, + cx, + ); + area.set_dock_size(placement, px(400.), window, cx); + }); + dropped + }); + cx.run_until_parked(); + + let group = cx.read(|cx| { + area.read(cx) + .layout(placement) + .unwrap() + .find_panel_node(panel_id_of(&dropped)) + .expect("both panels start in the dock's only group") + }); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + panel_id_of(&dropped), + InsertTarget::Split { + node: group, + placement: Placement::Bottom, + size: None, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + let split = cx.read(|cx| { + let tree = area.read(cx).layout(placement).unwrap(); + let root = tree.root(); + match root.kind() { + PaneRef::Split { .. } => root.id(), + _ => panic!("the drop must have produced a split"), + } + }); + let sizes = cx.read(|cx| area.read(cx).splits[&split].entity.read(cx).sizes().clone()); + + assert_eq!(sizes.len(), 2, "{placement:?}: the drop splits in two"); + let (first, second) = (sizes[0].as_f32(), sizes[1].as_f32()); + assert!( + (first - second).abs() <= (first + second) * 0.02, + "{placement:?}: expected halves, got {first} and {second}" + ); + } + } + + /// A slot given an explicit size keeps it on the frame after the first. + /// + /// The layout is laid out once with a flexible sibling, and the flexible + /// slot's placeholder measurement used to drag the fixed one with it when + /// the container was first measured — the layout visibly jumped once, + /// then settled. + #[gpui::test] + fn an_explicit_slot_size_survives_the_first_layout_pass(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|window, cx| { + let sidebar = TestPanel::new("Sidebar", cx); + let content = TestPanel::new("Content", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(sidebar), Some(px(200.))) + .child(DockLayout::tabs().panel(content), None), + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + let root = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + let sizes = cx.read(|cx| area.read(cx).splits[&root].entity.read(cx).sizes().clone()); + + // Within a couple of pixels of what was asked for — the measured + // value carries the frame's own rounding. The bug this pins made it + // 587px. + let fixed = sizes + .first() + .copied() + .expect("the split has slots") + .as_f32(); + assert!( + (fixed - 200.).abs() <= 4., + "the fixed slot keeps its 200px instead of being rescaled by the \ + flexible sibling's placeholder, got {fixed}" + ); + } + + /// The headline claim of the whole extraction, in one place: a layout + /// written by the shipped dock loads into the tree world, draws, and saves + /// back to a state the next load reproduces exactly. + /// + /// The fixture is a real user's file — a two-group center plus all three + /// docks — and its panels are not registered here, so every leaf takes the + /// placeholder path that carries the original `PanelState` forward. That + /// is the load-bearing half: a build that dropped an unknown panel would + /// still reach a fixpoint, so the region assertions below check the + /// content is *there* before the fixpoint says it is stable. + #[gpui::test] + fn the_shipped_fixture_survives_a_load_dump_load_round_trip(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let fixture: DockAreaState = + serde_json::from_str(include_str!("fixtures/layout.json")).unwrap(); + + cx.update(|window, cx| area.update(cx, |area, cx| area.load(fixture, window, cx).unwrap())); + cx.run_until_parked(); + let first = cx.read(|cx| area.read(cx).dump(cx)); + + assert_eq!(first.center.children.len(), 2, "the center's two groups"); + assert_eq!(first.center.children[0].children.len(), 15); + assert_eq!(first.center.children[1].children.len(), 1); + for dock in [&first.left_dock, &first.bottom_dock, &first.right_dock] { + let dock = dock.as_ref().expect("all three docks are attached"); + assert!(dock.open()); + assert!( + !dock.panel().children.is_empty(), + "a dock that loaded empty would round-trip just as stably" + ); + } + assert_eq!(first.left_dock.as_ref().unwrap().size(), px(350.)); + assert_eq!(first.bottom_dock.as_ref().unwrap().size(), px(200.)); + assert_eq!(first.right_dock.as_ref().unwrap().size(), px(320.)); + + cx.update(|window, cx| { + area.update(cx, |area, cx| area.load(first.clone(), window, cx).unwrap()) + }); + cx.run_until_parked(); + let second = cx.read(|cx| area.read(cx).dump(cx)); + + assert_eq!(second, first, "dump == dump(load(dump))"); + } + + #[gpui::test] + fn an_unregistered_panel_survives_a_load_and_save_round_trip(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|_, cx| register_test_panels(cx)); + + let json = include_str!("fixtures/unregistered_panel.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + + cx.update(|window, cx| area.update(cx, |area, cx| area.load(state, window, cx).unwrap())); + let dumped = cx.read(|cx| area.read(cx).dump(cx)); + + let leaf = &dumped.center.children[0].children[0]; + assert_eq!(leaf.panel_name, "PanelFromTheFuture"); + assert_eq!( + leaf.info, + PanelInfo::panel(serde_json::json!({"keep": "me"})), + "a panel this build cannot construct keeps its payload" + ); + } + + #[gpui::test] + fn a_dock_carries_its_own_tree_and_survives_a_round_trip(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tabs().panel(alpha), window, cx); + area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel(beta), + window, + cx, + ); + }); + }); + + // Node ids are globally allocated, so the center and the dock never + // claim the same entity-cache slot. + // + // Comparing the two *roots* would not pin this: under a per-tree + // counter the center's root is minted after its tab group and the + // dock's is not, so the roots differ anyway. The collision is between + // the two trees' tab-group nodes, and the property the cache actually + // depends on is that no id is shared at all. + let center_ids = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .node_ids() + }); + let dock_ids = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Left) + .unwrap() + .node_ids() + }); + assert!(!center_ids.is_empty() && !dock_ids.is_empty()); + assert!( + center_ids.iter().all(|id| !dock_ids.contains(id)), + "every tree in one area must draw from one id space: \ + center {center_ids:?} vs left {dock_ids:?}" + ); + + let state = cx.read(|cx| area.read(cx).dump(cx)); + let left = state.left_dock.clone().expect("the left dock is written"); + assert_eq!(left.placement(), DockPlacement::Left); + assert!(left.open()); + assert_eq!(left.panel().children[0].panel_name, "Beta"); + assert!(state.right_dock.is_none()); + } + + #[gpui::test] + fn a_panel_moved_between_regions_keeps_its_active_state(cx: &mut TestAppContext) { + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + // Alpha is the displayed tab of its own group, so it has been told + // `true` exactly once. + assert!(drain(&log).contains(&("Alpha", PanelSignal::Active(true)))); + + move_alpha_into_the_other_group(&area, &alpha, cx); + + assert!( + !drain(&log).contains(&("Alpha", PanelSignal::Active(true))), + "a displayed panel dragged to another group must not be told `true` twice" + ); + } + + #[gpui::test] + fn a_groups_close_intent_reaches_the_tree(cx: &mut TestAppContext) { + // Nothing else here proves `DockArea` subscribes to `TabGroupEvent` + // at all: a group reports intents and does nothing itself, so an + // unsubscribed area is a dock region that silently does nothing. + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + let node = child_node(&area, 0, cx); + let alpha_id = panel_id_of(&alpha); + cx.update(|_, cx| { + let group = area.read(cx).groups.get(&node).unwrap().entity.clone(); + group.update(cx, |group, cx| group.close_panel(alpha_id, cx)); + }); + cx.run_until_parked(); + + assert!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_panel_node(alpha_id)) + .is_none(), + "the close intent was applied to the tree" + ); + assert!(drain(&log).contains(&("Alpha", PanelSignal::Removed))); + } + + #[gpui::test] + fn replacing_the_center_tells_the_panels_that_left(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + cx.update(|window, cx| { + let gamma = TestPanel::new("Gamma", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tabs().panel(gamma), window, cx) + }); + }); + cx.run_until_parked(); + + let seen = drain(&log); + assert!(seen.contains(&("Alpha", PanelSignal::Removed))); + assert!(seen.contains(&("Beta", PanelSignal::Removed))); + } + + #[gpui::test] + fn closing_a_zoomed_panel_clears_the_zoom(cx: &mut TestAppContext) { + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + + let node = child_node(&area, 0, cx); + cx.update(|window, cx| area.update(cx, |area, cx| area.set_zoomed_in(node, window, cx))); + assert!(cx.read(|cx| area.read(cx).is_zoomed())); + + cx.update(|window, cx| { + area.update(cx, |area, cx| area.remove_panel(alpha.clone(), window, cx)) + }); + + assert!( + !cx.read(|cx| area.read(cx).is_zoomed()), + "a zoomed panel that left the dock must not keep filling it" + ); + } + + /// A canvas region is the one shape `add_panel` cannot merge into a tab + /// group, and the old `DockItem::add_panel` gave it its own arm. Without + /// one, the `None` fallback splits the region and wraps the whole canvas + /// in a stack the user never asked for. + #[gpui::test] + fn adding_a_panel_to_a_tiles_region_lands_on_the_canvas(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let bounds = Bounds { + origin: gpui::point(px(40.), px(40.)), + size: gpui::size(px(200.), px(150.)), + }; + let beta = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tiles().tile(alpha, bounds), window, cx); + area.add_panel(beta.clone(), DockPlacement::Center, None, window, cx); + }); + beta + }); + cx.run_until_parked(); + + let canvas_node = child_node(&area, 0, cx); + let panels = cx.read(|cx| { + let PaneRef::Tiles { panels } = area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_node(canvas_node) + .expect("the canvas is still there, not split in two") + .kind() + else { + panic!("the region is still a tiles canvas"); + }; + panels.to_vec() + }); + assert_eq!(panels.len(), 2, "the panel joined the canvas as a tile"); + + // Registration, not just placement: a tile the area holds no view for + // is dropped by the next `reconcile` and persists as an empty name. + let beta_id = panel_id_of(&beta); + assert!( + cx.read(|cx| area.read(cx).panel(beta_id).is_some()), + "the added panel's view is registered" + ); + let state = cx.read(|cx| area.read(cx).dump(cx)); + let names: Vec<&str> = state.center.children[0] + .children + .iter() + .map(|child| child.panel_name.as_str()) + .collect(); + assert_eq!(names, vec!["Alpha", "Beta"]); + } + + /// The bounds are the whole point of this entry: a host acting on + /// `DockEvent::DragDrop { target: DropTarget::Canvas }` knows where the + /// drop landed and has no other way to say so. + #[gpui::test] + fn add_tile_places_the_panel_where_it_was_asked_to(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let first = Bounds { + origin: gpui::point(px(10.), px(10.)), + size: gpui::size(px(100.), px(100.)), + }; + let dropped = Bounds { + origin: gpui::point(px(320.), px(180.)), + size: gpui::size(px(240.), px(160.)), + }; + let beta = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tiles().tile(alpha, first), window, cx); + area.add_tile(beta.clone(), DockPlacement::Center, dropped, window, cx); + }); + beta + }); + cx.run_until_parked(); + + let beta_id = panel_id_of(&beta); + let canvas_node = child_node(&area, 0, cx); + let tile = cx.read(|cx| { + let PaneRef::Tiles { panels } = area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_node(canvas_node) + .unwrap() + .kind() + else { + panic!("the region is still a tiles canvas"); + }; + *panels.iter().find(|tile| tile.panel() == beta_id).unwrap() + }); + assert_eq!(tile.bounds(), dropped); + assert!( + cx.read(|cx| area.read(cx).panel(beta_id).is_some()), + "the added panel's view is registered" + ); + } + + /// An explicit tile names a place only a canvas has. Falling through to + /// the tab-group arm would put the panel somewhere the caller never asked + /// for and drop the bounds on the floor. + #[gpui::test] + fn add_tile_does_nothing_to_a_region_with_no_canvas(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _, cx) = one_group(&log, &["Alpha"], None, cx); + let bounds = Bounds { + origin: gpui::point(px(10.), px(10.)), + size: gpui::size(px(100.), px(100.)), + }; + let beta = cx.update(|window, cx| { + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.add_tile(beta.clone(), DockPlacement::Center, bounds, window, cx); + }); + beta + }); + cx.run_until_parked(); + + let beta_id = panel_id_of(&beta); + assert!( + cx.read(|cx| area.read(cx).panel(beta_id).is_none()), + "a panel nothing took must not linger in the view map" + ); + let state = cx.read(|cx| area.read(cx).dump(cx)); + assert_eq!(state.center.children[0].children.len(), 1); + + // Nor does asking a dock that does not exist conjure an empty one to + // decline the tile from. + cx.update(|window, cx| { + let gamma = TestPanel::new("Gamma", cx); + area.update(cx, |area, cx| { + area.add_tile(gamma, DockPlacement::Left, bounds, window, cx); + }); + }); + cx.run_until_parked(); + assert!( + cx.read(|cx| area.read(cx).layout(DockPlacement::Left).is_none()), + "a tile with nowhere to go must not leave a dock behind" + ); + } + + /// The call `add_tile` was written for is a host re-placing a panel it + /// already holds, so a failed one must leave that panel exactly as it + /// found it. Registering first and removing on failure would drop the view + /// of a panel still sitting in a tree, which `reconcile`'s `views_of` + /// asserts against in dev and answers with a shifted active index in + /// release. + #[gpui::test] + fn a_declined_add_leaves_an_already_docked_panel_untouched(cx: &mut TestAppContext) { + let log = log_of(); + let (area, panels, cx) = one_group(&log, &["Alpha"], None, cx); + let alpha = panels[0].clone(); + let alpha_id = panel_id_of(&alpha); + let bounds = Bounds { + origin: gpui::point(px(10.), px(10.)), + size: gpui::size(px(100.), px(100.)), + }; + + let registered = cx.read(|cx| { + Arc::as_ptr( + area.read(cx) + .panel(alpha_id) + .expect("one_group registers it"), + ) as *const () + }); + + // The center is a tab group, so there is no canvas to take the tile. + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.add_tile(alpha.clone(), DockPlacement::Center, bounds, window, cx); + }); + }); + cx.run_until_parked(); + + let handle = |cx: &mut VisualTestContext| { + cx.read(|cx| { + Arc::as_ptr(area.read(cx).panel(alpha_id).expect("still registered")) as *const () + }) + }; + assert_eq!( + handle(cx), + registered, + "a panel that was already docked keeps the very handle it was \ + registered with; `add_tile` takes a bare entity, so overwriting \ + would cost a panel installed through `add_panel_view` its title" + ); + assert!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_panel_node(alpha_id)) + .is_some(), + "and keeps its place in the tree" + ); + assert!( + !drain(&log).contains(&("Alpha", PanelSignal::Removed)), + "a declined add is not a removal" + ); + + // The whole dock still reconciles, which is the failure `views_of` + // would otherwise assert on. + let state = cx.read(|cx| area.read(cx).dump(cx)); + assert_eq!(state.center.children[0].children[0].panel_name, "Alpha"); + } + + #[gpui::test] + fn dragging_a_tile_writes_its_new_bounds_back_into_the_tree(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let bounds = Bounds { + origin: gpui::point(px(40.), px(40.)), + size: gpui::size(px(200.), px(150.)), + }; + let alpha = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tiles().tile(alpha.clone(), bounds), window, cx); + }); + alpha + }); + + let node = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + // A `RootKind::Split` center wraps the canvas, so the canvas is the + // wrapper's only child. + let canvas_node = child_node(&area, 0, cx); + assert_ne!(node, canvas_node); + let canvas = cx.read(|cx| { + area.read(cx) + .tiles + .get(&canvas_node) + .unwrap() + .entity + .clone() + }); + + // A drag of exactly one grid step, far from every other edge, so no + // snapping rewrites it. + cx.update(|window, cx| { + let tile = canvas.read(cx).tiles(cx)[0].clone(); + tile.begin_move(gpui::point(px(100.), px(100.)), window, cx); + tile.move_to(gpui::point(px(150.), px(100.)), window, cx); + tile.end_move(window, cx); + }); + + let node = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_node(canvas_node) + .unwrap() + .clone() + }); + let PaneRef::Tiles { panels } = node.kind() else { + panic!("expected a tiles node"); + }; + assert_eq!(panels[0].panel(), panel_id_of(&alpha)); + assert_eq!( + panels[0].bounds().origin.x, + px(90.), + "the canvas reports the move and the tree records it" + ); + } + + /// The skin's stand-in for a panel this build cannot construct. It keeps + /// the original state, which is the obligation + /// [`DockAreaRenderer::build_placeholder`] documents. + struct SkinPlaceholder { + state: PanelState, + focus_handle: FocusHandle, + } + + impl Panel for SkinPlaceholder { + fn panel_name(&self) -> &'static str { + "SkinPlaceholder" + } + + fn dump(&self, _: &App) -> PanelState { + self.state.clone() + } + } + + impl EventEmitter for SkinPlaceholder {} + + impl Focusable for SkinPlaceholder { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for SkinPlaceholder { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } + } + + struct PlaceholderSkin { + asked: Rc>>, + } + + impl DockAreaRenderer for PlaceholderSkin { + fn build_placeholder( + &self, + state: &PanelState, + _: &mut Window, + cx: &mut App, + ) -> Option> { + self.asked.borrow_mut().push(state.panel_name.clone()); + let state = state.clone(); + Some(Arc::new(cx.new(|cx| SkinPlaceholder { + state, + focus_handle: cx.focus_handle(), + }))) + } + + fn tab_group_renderer(&self) -> Rc { + Rc::new(BareTabGroup) + } + + fn tiles_renderer(&self) -> Rc { + Rc::new(BareTiles) + } + } + + /// A panel no builder answers for becomes the skin's placeholder rather + /// than base's draw-nothing one, so the "unknown panel" message the old + /// `InvalidPanel` drew has somewhere to live. + #[gpui::test] + fn an_unbuildable_panel_becomes_the_skins_placeholder(cx: &mut TestAppContext) { + cx.update(|cx| { + let _ = crate::Theme::global_mut(cx); + }); + let asked: Rc>> = Rc::default(); + let skin = Rc::new(PlaceholderSkin { + asked: asked.clone(), + }); + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("test-dock", None, window, cx).with_renderer(skin) + }); + + // Nothing is registered, so the round trip cannot rebuild this panel. + cx.update(|window, cx| { + let ghost = TestPanel::new("Ghost", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tabs().panel(ghost), window, cx) + }); + }); + let state = cx.read(|cx| area.read(cx).dump(cx)); + cx.update(|window, cx| area.update(cx, |area, cx| area.load(state, window, cx).unwrap())); + cx.run_until_parked(); + + assert_eq!(*asked.borrow(), vec!["Ghost".to_string()]); + assert_eq!( + cx.read(|cx| area + .read(cx) + .panels + .values() + .map(|panel| panel.panel_name(cx)) + .collect::>()), + vec!["SkinPlaceholder"], + "base installed the skin's placeholder, not its own" + ); + assert_eq!( + cx.read(|cx| area.read(cx).dump(cx)).center.children[0].children[0].panel_name, + "Ghost", + "and the unknown panel still survives the next save" + ); + } + + #[gpui::test] + fn a_persisted_tiles_canvas_restores_its_panels(cx: &mut TestAppContext) { + // Every tiles canvas the old dock ever wrote has `TabPanel`-shaped + // children. Read literally, each one misses the registry, becomes a + // placeholder, and the user's panels inside it are never built at + // all — a saved canvas comes back as blank tiles. + let (area, cx) = setup(cx); + cx.update(|_, cx| register_test_panels(cx)); + + let json = include_str!("fixtures/tiles_tab_panel_children.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + cx.update(|window, cx| area.update(cx, |area, cx| area.load(state, window, cx).unwrap())); + + let dumped = cx.read(|cx| area.read(cx).dump(cx)); + let tiles = &dumped.center.children[0]; + assert_eq!(tiles.panel_name, "Tiles"); + assert_eq!( + tiles + .children + .iter() + .map(|child| child.panel_name.as_str()) + .collect::>(), + vec!["Alpha", "Beta", "Gamma"], + "the real panels are restored, not `InvalidPanel` placeholders" + ); + + // And they are live entities the canvas can draw, not just bytes. + let canvas_node = child_node(&area, 0, cx); + let canvas = cx.read(|cx| { + area.read(cx) + .tiles + .get(&canvas_node) + .unwrap() + .entity + .clone() + }); + let names = cx.read(|cx| { + canvas + .read(cx) + .tiles(cx) + .iter() + .map(|tile| tile.panel().panel_name(cx)) + .collect::>() + }); + assert_eq!(names, vec!["Alpha", "Beta", "Gamma"]); + } + + #[gpui::test] + fn removing_a_non_tail_child_shifts_the_split_sizes_with_it(cx: &mut TestAppContext) { + // `ResizableState` keeps the authoritative size on `panels[ix]`, so a + // tail-truncating sync leaves the survivors of a non-tail removal + // wearing their predecessors' widths. + let log = log_of(); + let (area, cx) = setup(cx); + let alpha = cx.update(|window, cx| { + let alpha = TestPanel::logging("Alpha", &log, cx); + let beta = TestPanel::logging("Beta", &log, cx); + let gamma = TestPanel::logging("Gamma", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha.clone()), Some(px(100.))) + .child(DockLayout::tabs().panel(beta), Some(px(200.))) + .child(DockLayout::tabs().panel(gamma), Some(px(300.))), + window, + cx, + ); + }); + alpha + }); + + let root = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + let split = cx.read(|cx| area.read(cx).splits.get(&root).unwrap().entity.clone()); + let sizes = |cx: &mut VisualTestContext| cx.read(|cx| split.read(cx).sizes().clone()); + + // Absolute widths are `ResizableState`'s business — it redistributes + // against the measured container — so what is asserted is which slot + // disappeared, through the ratio the survivors keep. + let before = sizes(cx); + assert_eq!(before.len(), 3); + let kept_if_correct = before[1] / before[2]; + let kept_if_truncated = before[0] / before[1]; + assert!( + (kept_if_correct - kept_if_truncated).abs() > 0.1, + "the fixture must be able to tell the two outcomes apart" + ); + + cx.update(|window, cx| area.update(cx, |area, cx| area.remove_panel(alpha, window, cx))); + + let after = sizes(cx); + assert_eq!(after.len(), 2); + assert!( + (after[0] / after[1] - kept_if_correct).abs() < 0.01, + "the survivors kept their own proportions: slot 0 was removed, not \ + the tail — got {after:?} from {before:?}" + ); + } + + #[gpui::test] + fn a_panel_moves_between_the_center_and_a_dock(cx: &mut TestAppContext) { + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.update(|window, cx| { + let gamma = TestPanel::logging("Gamma", &log, cx); + area.update(cx, |area, cx| { + area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel(gamma), + window, + cx, + ); + }); + }); + cx.run_until_parked(); + drain(&log); + + let alpha_id = panel_id_of(&alpha); + let dock_group = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Left) + .unwrap() + .root() + .id() + }); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + alpha_id, + InsertTarget::Tabs { + node: dock_group, + ix: None, + activate: true, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + assert!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .find_panel_node(alpha_id)) + .is_none(), + "the panel left the center" + ); + assert_eq!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Left) + .unwrap() + .find_panel_node(alpha_id)), + Some(dock_group), + "and arrived in the dock's group" + ); + + let seen = drain(&log); + assert!( + !seen.contains(&("Alpha", PanelSignal::Removed)), + "crossing regions is still a move, not a removal" + ); + assert!( + !seen.contains(&("Alpha", PanelSignal::Active(true))), + "and it was displayed in both, so it is not told `true` twice" + ); + } + + #[gpui::test] + fn a_move_onto_an_unusable_target_leaves_no_stranded_panel(cx: &mut TestAppContext) { + // `apply_insert` is a silent no-op when the target node's kind does + // not match. Committing on the insert alone would early-return with + // the panel already gone from the source tree but still in the view + // map, for some later unrelated edit to prune and destroy. + let log = log_of(); + let (area, _alpha, cx) = two_groups(&log, cx); + let gamma = cx.update(|window, cx| { + let gamma = TestPanel::logging("Gamma", &log, cx); + area.update(cx, |area, cx| { + area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel(gamma.clone()), + window, + cx, + ); + }); + gamma + }); + cx.run_until_parked(); + drain(&log); + + let gamma_id = panel_id_of(&gamma); + // The center root is a split, so a `Tabs` insert naming it does + // nothing at all. + let center_root = cx.read(|cx| { + area.read(cx) + .layout(DockPlacement::Center) + .unwrap() + .root() + .id() + }); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.move_panel( + gamma_id, + InsertTarget::Tabs { + node: center_root, + ix: None, + activate: true, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + assert!( + cx.read(|cx| area.read(cx).panel(gamma_id).is_none()), + "the view map agrees with the trees straight away, rather than \ + carrying a panel that belongs to no tree" + ); + assert!( + drain(&log).contains(&("Gamma", PanelSignal::Removed)), + "and the panel was told so at the point of the call" + ); + } + + #[gpui::test] + fn an_all_hidden_container_reports_itself_invisible(cx: &mut TestAppContext) { + // What the old `StackPanel::render` asked of each slot before hiding + // it. `render_node` feeds this straight to `resizable_panel().visible`. + let (area, cx) = setup(cx); + let beta = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha), None) + .child(DockLayout::tabs().panel(beta.clone()), None), + window, + cx, + ); + }); + beta + }); + + let visible = |ix: usize, cx: &mut VisualTestContext| { + let node = child_node(&area, ix, cx); + cx.read(|cx| { + let area = area.read(cx); + let tree = area.layout(DockPlacement::Center).unwrap(); + area.is_node_visible(tree.find_node(node).unwrap(), cx) + }) + }; + + assert!(visible(0, cx) && visible(1, cx)); + + cx.update(|_, cx| beta.update(cx, |beta, cx| beta.set_visible(false, cx))); + + assert!(visible(0, cx), "the visible group still holds its slot"); + assert!( + !visible(1, cx), + "a group whose every panel is hidden must give its slot up" + ); + } + + #[gpui::test] + fn a_locked_area_seals_its_groups(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + + let node = child_node(&area, 0, cx); + let group = cx.read(|cx| area.read(cx).groups.get(&node).unwrap().entity.clone()); + assert!( + cx.read(|cx| group.read(cx).can_close(cx)), + "an unlocked group's panel can be closed" + ); + + cx.update(|window, cx| area.update(cx, |area, cx| area.set_locked(true, window, cx))); + + assert!( + !cx.read(|cx| group.read(cx).can_close(cx)), + "the lock reaches every group through the constraints push" + ); + } + + // The tests below were ported from `crates/ui/src/dock/tab_panel.rs` when + // the dock skin was rebuilt on this crate. They are the surviving record + // of the `is_empty` semantics and the documented `set_active` contract. + + /// An empty `StackPanel` used to dump as `PanelInfo::Panel`, the + /// `PanelState` default, so restoring looked it up in `PanelRegistry` and + /// failed. + #[gpui::test] + fn empty_center_round_trips_as_a_stack(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let center = cx.read(|cx| area.read(cx).dump(cx).center); + + assert_eq!(center.panel_name, "StackPanel"); + assert!( + matches!(center.info, PanelInfo::Stack { .. }), + "got {:?}", + center.info + ); + } + + #[gpui::test] + fn fresh_center_is_empty(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + + assert!( + is_center_empty(&area, cx), + "DockArea::new starts with an empty split centre" + ); + } + + #[gpui::test] + fn center_holding_a_tab_group_is_not_empty(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _panels, cx) = one_group(&log, &["A", "B"], None, cx); + cx.run_until_parked(); + + assert!(!is_center_empty(&area, cx)); + } + + /// The tree still lists the group's node here until the last panel goes, + /// so anything reading node counts rather than panels would report + /// non-empty. + #[gpui::test] + fn center_is_empty_again_once_every_panel_is_removed(cx: &mut TestAppContext) { + let log = log_of(); + let (area, panels, cx) = one_group(&log, &["A", "B"], None, cx); + cx.run_until_parked(); + + for panel in panels { + cx.update(|window, cx| { + area.update(cx, |area, cx| area.remove_panel(panel.clone(), window, cx)) + }); + } + cx.run_until_parked(); + + assert!(is_center_empty(&area, cx)); + } + + #[gpui::test] + fn center_is_not_empty_after_adding_to_a_tab_group(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + assert!(is_center_empty(&area, cx)); + + cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + area.update(cx, |area, cx| { + area.add_panel(alpha, DockPlacement::Center, None, window, cx) + }); + }); + cx.run_until_parked(); + + assert!(!is_center_empty(&area, cx)); + } + + /// The pre-wrapped companion of `add_panel`, for a layer that hands base + /// its own handle. The panel it registers is the very handle it was + /// given, keyed by the id that handle reports. + #[gpui::test] + fn add_panel_view_registers_the_handle_it_was_given(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + + let view = cx.update(|window, cx| { + let view: Arc = Arc::new(TestPanel::new("Alpha", cx)); + area.update(cx, |area, cx| { + area.add_panel_view(view.clone(), DockPlacement::Center, None, window, cx) + }); + view + }); + cx.run_until_parked(); + + let id = cx.read(|cx| view.panel_id(cx)); + assert!( + cx.read(|cx| area + .read(cx) + .panel(id) + .is_some_and(|stored| Arc::ptr_eq(stored, &view))), + "the stored handle is the one that was handed over, under its own id" + ); + assert!(!is_center_empty(&area, cx)); + } + + /// Rendering skips invisible panels, so a centre holding only hidden ones + /// draws nothing and counts as empty. + #[gpui::test] + fn center_holding_only_hidden_panels_is_empty(cx: &mut TestAppContext) { + let log = log_of(); + let (area, panels, cx) = one_group(&log, &["A", "B"], None, cx); + cx.run_until_parked(); + assert!(!is_center_empty(&area, cx)); + + cx.update(|_, cx| { + for panel in &panels { + panel.update(cx, |panel, cx| panel.set_visible(false, cx)); + } + }); + cx.run_until_parked(); + + assert_eq!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .panels() + .count()), + 2, + "hiding a panel does not remove it from the tab group" + ); + assert!(is_center_empty(&area, cx)); + } + + /// The old `TabPanel` inside `Tiles` had no parent `StackPanel` to remove + /// itself from, so emptying it left the tile behind and the walk had to + /// recurse. `normalize` now removes the emptied canvas outright, which is + /// the stronger outcome and is what this pins. + #[gpui::test] + fn center_holding_only_empty_tiles_is_empty(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let bounds = Bounds { + origin: gpui::point(px(10.), px(10.)), + size: gpui::size(px(200.), px(200.)), + }; + let alpha = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tiles().tile(alpha.clone(), bounds), window, cx) + }); + alpha + }); + cx.run_until_parked(); + assert!(!is_center_empty(&area, cx)); + + cx.update(|window, cx| area.update(cx, |area, cx| area.remove_panel(alpha, window, cx))); + cx.run_until_parked(); + + assert!(is_center_empty(&area, cx)); + } + + /// The recursion the previous test no longer reaches: a canvas that still + /// holds its tile, but whose every panel is hidden. + #[gpui::test] + fn center_holding_only_hidden_tiles_is_empty(cx: &mut TestAppContext) { + let (area, cx) = setup(cx); + let bounds = Bounds { + origin: gpui::point(px(10.), px(10.)), + size: gpui::size(px(200.), px(200.)), + }; + let alpha = cx.update(|window, cx| { + let alpha = TestPanel::new("Alpha", cx); + area.update(cx, |area, cx| { + area.set_center(DockLayout::tiles().tile(alpha.clone(), bounds), window, cx) + }); + alpha + }); + cx.run_until_parked(); + assert!(!is_center_empty(&area, cx)); + + cx.update(|_, cx| alpha.update(cx, |alpha, cx| alpha.set_visible(false, cx))); + + assert_eq!( + cx.read(|cx| area + .read(cx) + .layout(DockPlacement::Center) + .unwrap() + .panels() + .count()), + 1, + "the tile is still on the canvas" + ); + assert!(is_center_empty(&area, cx)); + } + + #[gpui::test] + fn single_panel_group_receives_initial_active(cx: &mut TestAppContext) { + let log = log_of(); + let (_area, _panels, cx) = one_group(&log, &["A"], None, cx); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), [("A", true)]); + } + + #[gpui::test] + fn multi_tab_construction_notifies_only_displayed_panel(cx: &mut TestAppContext) { + let log = log_of(); + let (_area, _panels, cx) = one_group(&log, &["A", "B", "C"], None, cx); + cx.run_until_parked(); + + // No false-then-true flip on A, no duplicate true, B/C silent. + assert_eq!(drain_active(&log), [("A", true)]); + } + + #[gpui::test] + fn active_index_restore_notifies_that_panel_only(cx: &mut TestAppContext) { + let log = log_of(); + let (_area, _panels, cx) = one_group(&log, &["A", "B", "C"], Some(2), cx); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), [("C", true)]); + } + + #[gpui::test] + fn switching_tabs_sends_false_then_true(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _panels, cx) = one_group(&log, &["A", "B"], None, cx); + cx.run_until_parked(); + drain(&log); + + let group = group_of(&area, 0, cx); + cx.update(|window, cx| group.update(cx, |group, cx| group.select_tab(1, window, cx))); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), [("A", false), ("B", true)]); + } + + #[gpui::test] + fn reselecting_active_tab_stays_silent(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _panels, cx) = one_group(&log, &["A", "B"], None, cx); + cx.run_until_parked(); + drain(&log); + + let group = group_of(&area, 0, cx); + cx.update(|window, cx| group.update(cx, |group, cx| group.select_tab(0, window, cx))); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), []); + } + + /// The old `TabPanel::insert_panel_at` took a brand-new panel; the tree + /// API inserts a panel that is already in the dock, so `C` arrives from a + /// second group. It was a background tab there and so has been told + /// nothing, which is what makes the arrival a genuine activation rather + /// than a seeded handoff. + #[gpui::test] + fn inserting_at_active_ix_swaps_notifications(cx: &mut TestAppContext) { + let log = log_of(); + let (area, cx) = setup(cx); + let c = cx.update(|window, cx| { + let a = TestPanel::logging("A", &log, cx); + let b = TestPanel::logging("B", &log, cx); + let x = TestPanel::logging("X", &log, cx); + let c = TestPanel::logging("C", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(a).panel(b), None) + .child(DockLayout::tabs().panel(x).panel(c.clone()), None), + window, + cx, + ); + }); + c + }); + cx.run_until_parked(); + drain(&log); + + let destination = child_node(&area, 0, cx); + let c_id = panel_id_of(&c); + move_panel_into(&area, c_id, destination, Some(0), true, cx); + + assert_eq!(drain_active(&log), [("A", false), ("C", true)]); + let group = group_of(&area, 0, cx); + assert_eq!(cx.read(|cx| group.read(cx).active_ix()), 0); + assert_eq!( + cx.read(|cx| group.read(cx).panels()[0].panel_id(cx)), + c_id, + "the arriving panel took the slot it named" + ); + } + + #[gpui::test] + fn removing_before_active_keeps_displayed_panel(cx: &mut TestAppContext) { + let log = log_of(); + let (area, panels, cx) = one_group(&log, &["A", "B", "C"], None, cx); + let group = group_of(&area, 0, cx); + cx.update(|window, cx| group.update(cx, |group, cx| group.select_tab(1, window, cx))); + cx.run_until_parked(); + drain(&log); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.remove_panel(panels[0].clone(), window, cx) + }) + }); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), []); + assert_eq!(cx.read(|cx| group.read(cx).active_ix()), 0); + assert_eq!( + cx.read(|cx| group.read(cx).panels()[0].panel_id(cx)), + panel_id_of(&panels[1]), + "the same panel is still displayed, at its new index" + ); + } + + /// Collapsing is now a dock closing, which is what + /// `TabGroupConstraints::collapsed` carries. + #[gpui::test] + fn collapse_and_expand_notify_active_panel(cx: &mut TestAppContext) { + let log = log_of(); + let (area, cx) = setup(cx); + cx.update(|window, cx| { + let a = TestPanel::logging("A", &log, cx); + let b = TestPanel::logging("B", &log, cx); + area.update(cx, |area, cx| { + area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel(a).panel(b), + window, + cx, + ); + }); + }); + cx.run_until_parked(); + drain(&log); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.toggle_dock(DockPlacement::Left, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(drain_active(&log), [("A", false)]); + + cx.update(|window, cx| { + area.update(cx, |area, cx| { + area.toggle_dock(DockPlacement::Left, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(drain_active(&log), [("A", true)]); + } + + #[gpui::test] + fn background_add_is_silent_but_first_panel_is_not(cx: &mut TestAppContext) { + let log = log_of(); + let (area, cx) = setup(cx); + let d = cx.update(|window, cx| { + let a = TestPanel::logging("A", &log, cx); + let b = TestPanel::logging("B", &log, cx); + let c = TestPanel::logging("C", &log, cx); + let d = TestPanel::logging("D", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(a).panel(b), None) + .child(DockLayout::tabs().panel(c).panel(d.clone()), None), + window, + cx, + ); + }); + d + }); + cx.run_until_parked(); + drain(&log); + + // D is a background tab in its own group and arrives as a background + // tab in the other one, so nothing changes for anybody. + let destination = child_node(&area, 0, cx); + move_panel_into(&area, panel_id_of(&d), destination, None, false, cx); + assert_eq!(drain_active(&log), []); + + // The first panel of a region that had none is displayed regardless, + // so it must be told. + cx.update(|window, cx| { + let e = TestPanel::logging("E", &log, cx); + area.update(cx, |area, cx| { + area.add_panel(e, DockPlacement::Left, None, window, cx) + }); + }); + cx.run_until_parked(); + assert_eq!(drain_active(&log), [("E", true)]); + } + + #[gpui::test] + fn drag_active_panel_to_other_group_stays_silent_for_it(cx: &mut TestAppContext) { + let log = log_of(); + let (area, cx) = setup(cx); + let a = cx.update(|window, cx| { + let a = TestPanel::logging("A", &log, cx); + let b = TestPanel::logging("B", &log, cx); + let c = TestPanel::logging("C", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(a.clone()).panel(b), None) + .child(DockLayout::tabs().panel(c), None), + window, + cx, + ); + }); + a + }); + cx.run_until_parked(); + drain(&log); + + // A was already told `true`; becoming the target's displayed tab must + // not repeat it. + let destination = child_node(&area, 1, cx); + move_panel_into(&area, panel_id_of(&a), destination, None, true, cx); + + // Two groups reconcile independently, so their deliveries interleave + // in no guaranteed order; what is pinned is which ones happen. + let seen = drain_active(&log); + assert!(seen.contains(&("B", true)), "got {seen:?}"); + assert!(seen.contains(&("C", false)), "got {seen:?}"); + assert!( + !seen.iter().any(|(name, _)| *name == "A"), + "the moved panel was displayed before and after: {seen:?}" + ); + } + + #[gpui::test] + fn drag_active_panel_to_background_slot_deactivates_it(cx: &mut TestAppContext) { + let log = log_of(); + let (area, cx) = setup(cx); + let a = cx.update(|window, cx| { + let a = TestPanel::logging("A", &log, cx); + let c = TestPanel::logging("C", &log, cx); + let d = TestPanel::logging("D", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(a.clone()), None) + .child(DockLayout::tabs().panel(c).panel(d), None), + window, + cx, + ); + }); + a + }); + cx.run_until_parked(); + drain(&log); + + // A was told `true` and becomes a background tab, so it gets one + // `false`. + let destination = child_node(&area, 1, cx); + move_panel_into(&area, panel_id_of(&a), destination, None, false, cx); + + assert_eq!(drain_active(&log), [("A", false)]); + } + + #[gpui::test] + fn closing_a_tile_removes_its_panel(cx: &mut TestAppContext) { + // `TileContext::can_close` would otherwise be a control a skin can + // draw and never wire up. + let log = log_of(); + let (area, cx) = setup(cx); + let bounds = Bounds { + origin: gpui::point(px(10.), px(10.)), + size: gpui::size(px(200.), px(200.)), + }; + let alpha = cx.update(|window, cx| { + let alpha = TestPanel::logging("Alpha", &log, cx); + let beta = TestPanel::logging("Beta", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::tiles() + .tile(alpha.clone(), bounds) + .tile(beta, bounds), + window, + cx, + ); + }); + alpha + }); + cx.run_until_parked(); + drain(&log); + + let canvas_node = child_node(&area, 0, cx); + let canvas = cx.read(|cx| { + area.read(cx) + .tiles + .get(&canvas_node) + .unwrap() + .entity + .clone() + }); + cx.update(|window, cx| { + let tile = canvas.read(cx).tiles(cx)[0].clone(); + assert!(tile.can_close()); + tile.close(window, cx); + }); + cx.run_until_parked(); + + assert!( + cx.read(|cx| area.read(cx).panel(panel_id_of(&alpha)).is_none()), + "the closed tile's panel left the dock" + ); + assert!(drain(&log).contains(&("Alpha", PanelSignal::Removed))); + } + + /// A skin that records what it was asked to draw. + /// + /// The chrome is the point: a tab bar is drawn by the *group*, and a + /// tile's drag bar by the *canvas*. Neither runs if the area renders the + /// bare panel instead, so what lands in these logs says which of the two + /// is on screen — a question no reading of `is_zoomed()` can answer. + struct RecordingSkin { + tab_bars: Rc>>, + drag_bars: Rc>>, + } + + struct RecordingTabGroup { + drawn: Rc>>, + } + + struct RecordingTiles { + drawn: Rc>>, + } + + impl DockAreaRenderer for RecordingSkin { + fn tab_group_renderer(&self) -> Rc { + Rc::new(RecordingTabGroup { + drawn: self.tab_bars.clone(), + }) + } + + fn tiles_renderer(&self) -> Rc { + Rc::new(RecordingTiles { + drawn: self.drag_bars.clone(), + }) + } + } + + impl TabGroupRenderer for RecordingTabGroup { + fn render_tab_bar( + &self, + group: &TabGroupContext, + _: &mut Window, + _: &mut App, + ) -> AnyElement { + self.drawn.borrow_mut().push(group.node()); + Empty.into_any_element() + } + } + + impl TilesRenderer for RecordingTiles { + fn render_drag_bar(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> AnyElement { + self.drawn.borrow_mut().push(tile.panel_id()); + Empty.into_any_element() + } + } + + type DrawLog = (Rc>>, Rc>>); + + /// [`setup`], with a skin that records the tab bars and drag bars drawn. + fn setup_recording( + cx: &mut TestAppContext, + ) -> (Entity, DrawLog, &mut VisualTestContext) { + cx.update(|cx| { + let _ = crate::Theme::global_mut(cx); + }); + let tab_bars: Rc>> = Rc::default(); + let drag_bars: Rc>> = Rc::default(); + let skin = Rc::new(RecordingSkin { + tab_bars: tab_bars.clone(), + drag_bars: drag_bars.clone(), + }); + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("test-dock", None, window, cx).with_renderer(skin) + }); + (area, (tab_bars, drag_bars), cx) + } + + fn zoom_signals(log: &Log) -> Vec<(&'static str, PanelSignal)> { + drain(log) + .into_iter() + .filter(|(_, signal)| matches!(signal, PanelSignal::Zoomed(_))) + .collect() + } + + /// The regression this exists for: zooming shows the *group*, tab bar and + /// all, not the panel inside it. + /// + /// The old dock zoomed the whole `TabPanel` — every `subscribe_panel` call + /// site handed it one — so the tab bar, the toolbar and the panel menu + /// stayed on screen, and that is where the control that zooms back out + /// lives. A zoom rendering the bare panel would still fill the area and + /// still answer `is_zoomed()`; only the tab bar tells the two apart. + #[gpui::test] + fn a_zoomed_group_is_drawn_whole_rather_than_as_its_bare_panel(cx: &mut TestAppContext) { + let log = log_of(); + let (area, (tab_bars, _), cx) = setup_recording(cx); + cx.update(|window, cx| { + let alpha = TestPanel::logging("Alpha", &log, cx); + let beta = TestPanel::logging("Beta", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha), None) + .child(DockLayout::tabs().panel(beta), None), + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + let zoomed = child_node(&area, 0, cx); + let other = child_node(&area, 1, cx); + assert!( + tab_bars.borrow().contains(&zoomed) && tab_bars.borrow().contains(&other), + "both groups draw their own tab bar while nothing is zoomed" + ); + + tab_bars.borrow_mut().clear(); + let group = group_of(&area, 0, cx); + cx.update(|window, cx| group.update(cx, |group, cx| group.toggle_zoom(window, cx))); + cx.run_until_parked(); + + assert!( + tab_bars.borrow().contains(&zoomed), + "a zoomed group is rendered whole: its own tab bar is still drawn, \ + which is exactly what the bare panel does not carry" + ); + assert!( + !tab_bars.borrow().contains(&other), + "and it is the only thing on screen" + ); + } + + /// Zooming a tile shows its canvas drawing that one tile with its chrome. + /// + /// A tile was a `TabPanel` in the old dock, so it zoomed with its own bar + /// too. The canvas is what draws a tile's chrome, so the canvas is what + /// the area renders. + #[gpui::test] + fn a_zoomed_tile_is_drawn_by_its_canvas_with_its_chrome(cx: &mut TestAppContext) { + let log = log_of(); + let (area, (_, drag_bars), cx) = setup_recording(cx); + let bounds = Bounds { + origin: gpui::point(px(40.), px(40.)), + size: gpui::size(px(200.), px(150.)), + }; + let (alpha, beta) = cx.update(|window, cx| { + let alpha = TestPanel::logging("Alpha", &log, cx); + let beta = TestPanel::logging("Beta", &log, cx); + area.update(cx, |area, cx| { + area.set_center( + DockLayout::tiles() + .tile(alpha.clone(), bounds) + .tile(beta.clone(), bounds), + window, + cx, + ); + }); + (alpha, beta) + }); + cx.run_until_parked(); + drain(&log); + + let canvas_node = child_node(&area, 0, cx); + let canvas = cx.read(|cx| { + area.read(cx) + .tiles + .get(&canvas_node) + .unwrap() + .entity + .clone() + }); + assert!( + drag_bars.borrow().contains(&panel_id_of(&alpha)) + && drag_bars.borrow().contains(&panel_id_of(&beta)), + "both tiles draw their own drag bar while nothing is zoomed" + ); + + drag_bars.borrow_mut().clear(); + cx.update(|window, cx| { + let tile = canvas.read(cx).tiles(cx)[0].clone(); + assert!(tile.can_zoom()); + tile.toggle_zoom(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + cx.read(|cx| area.read(cx).zoomed_tile()), + Some(panel_id_of(&alpha)) + ); + assert!( + drag_bars.borrow().contains(&panel_id_of(&alpha)), + "the zoomed tile keeps the chrome the bare panel does not carry" + ); + assert!( + !drag_bars.borrow().contains(&panel_id_of(&beta)), + "and the tiles beside it are no longer drawn" + ); + assert_eq!( + zoom_signals(&log), + vec![("Alpha", PanelSignal::Zoomed(true))], + "the panel is told it was zoomed, as its group would have told it" + ); + + // A zoomed tile is no longer at its stored bounds, so there is + // nothing for a move to mean — the tiles counterpart of a zoomed + // group reporting itself locked. + cx.update(|window, cx| { + let tile = canvas.read(cx).tiles(cx)[0].clone(); + tile.begin_move(gpui::point(px(100.), px(100.)), window, cx); + }); + assert!(!cx.read(|cx| canvas.read(cx).tiles(cx)[0].is_moving())); + } + + /// The area's zoom and the container's own flag are written together, so + /// neither can be left believing something the other does not. + /// + /// A group left flagged zoomed reports itself locked for good, and a + /// locked group refuses every drop. + #[gpui::test] + fn clearing_the_zoom_from_outside_puts_the_groups_own_flag_back(cx: &mut TestAppContext) { + let log = log_of(); + let (area, _alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + drain(&log); + + let node = child_node(&area, 0, cx); + let group = group_of(&area, 0, cx); + cx.update(|window, cx| group.update(cx, |group, cx| group.toggle_zoom(window, cx))); + cx.run_until_parked(); + assert_eq!(cx.read(|cx| area.read(cx).zoomed_group()), Some(node)); + assert!(cx.read(|cx| group.read(cx).is_zoomed())); + assert_eq!( + zoom_signals(&log), + vec![("Alpha", PanelSignal::Zoomed(true))] + ); + + cx.update(|window, cx| area.update(cx, |area, cx| area.set_zoomed_out(window, cx))); + cx.run_until_parked(); + + assert!(!cx.read(|cx| area.read(cx).is_zoomed())); + assert!( + !cx.read(|cx| group.read(cx).is_zoomed()), + "a group left flagged zoomed would stay locked and refuse every drop" + ); + assert!( + cx.read(|cx| group.read(cx).context(cx).is_droppable()), + "and the lock the zoom imposed is lifted with it" + ); + assert_eq!( + zoom_signals(&log), + vec![("Alpha", PanelSignal::Zoomed(false))], + "the panel hears the zoom end too, not just the group" + ); + } + + /// A group that refuses to zoom must not leave the area showing it as + /// zoomed: the area records a zoom only once the container agrees. + #[gpui::test] + fn a_group_that_refuses_to_zoom_leaves_the_area_unzoomed(cx: &mut TestAppContext) { + let log = log_of(); + let (area, alpha, cx) = two_groups(&log, cx); + cx.run_until_parked(); + cx.update(|_, cx| alpha.update(cx, |panel, cx| panel.set_zoomable(false, cx))); + + let node = child_node(&area, 0, cx); + let group = group_of(&area, 0, cx); + cx.update(|window, cx| area.update(cx, |area, cx| area.set_zoomed_in(node, window, cx))); + cx.run_until_parked(); + + assert!(!cx.read(|cx| group.read(cx).is_zoomed())); + assert!( + !cx.read(|cx| area.read(cx).is_zoomed()), + "the area must not fill itself with a group that never zoomed" + ); + } +} diff --git a/vendor/gpui-base/src/dock/dock_placement.rs b/vendor/gpui-base/src/dock/dock_placement.rs new file mode 100644 index 00000000..2da9f2f7 --- /dev/null +++ b/vendor/gpui-base/src/dock/dock_placement.rs @@ -0,0 +1,250 @@ +//! Pure size arithmetic for resizing one dock (left/right/bottom), and the +//! dock's runtime state (open/collapsible/size/resizing). +//! +//! This module decides *how big a dock is allowed to be*. It draws nothing: +//! the resize-handle chrome and the collapsed/expanded animation are +//! appearance and live in `crates/ui`. + +use gpui::{Bounds, Pixels, Point, px}; + +use crate::PANEL_MIN_SIZE; + +use super::state::DockPlacement; + +/// Pure arithmetic for resizing one dock. The caller supplies the area +/// bounds and the opposite dock's size; base does not reach across entities +/// to find them — the original `Dock::resize` read sibling dock sizes +/// straight off the (application-owned) `DockArea` entity, which base has no +/// way to do. +#[derive(Clone, Copy, Debug)] +pub struct DockSizing { + placement: DockPlacement, + area: Bounds, + opposite_dock_size: Pixels, +} + +impl DockSizing { + pub fn new(placement: DockPlacement) -> Self { + Self { + placement, + area: Bounds::default(), + opposite_dock_size: px(0.), + } + } + + /// Set the full area bounds (origin and size). Needed whenever the + /// dock's placement depends on the area's origin, e.g. a bottom dock + /// measuring from the area's bottom edge. + pub fn with_area_bounds(mut self, area: Bounds) -> Self { + self.area = area; + self + } + + /// Set only the area's width, leaving its origin and height untouched. + /// Convenient for left/right dock clamping, which only reads + /// `area.size.width`. + pub fn with_area_width(mut self, width: Pixels) -> Self { + self.area.size.width = width; + self + } + + /// Set only the area's height, leaving its origin and width untouched. + /// Convenient for bottom dock clamping, which only reads + /// `area.size.height`. + pub fn with_area_height(mut self, height: Pixels) -> Self { + self.area.size.height = height; + self + } + + /// Set the size of the dock on the opposite side (right, for a left + /// dock; left, for a right dock). Bottom docks have no opposite side. + pub fn with_opposite_dock_size(mut self, size: Pixels) -> Self { + self.opposite_dock_size = size; + self + } + + /// The dock size the pointer implies, before clamping. + /// + /// `DockPlacement::Center` names the canvas, which has no edge to measure + /// from and no dock size, so it answers zero. Both this and [`Self::clamp`] + /// are public and take a `pub` enum, so a caller can name that variant; a + /// base-layer arithmetic helper must not panic a desktop application over + /// it. [`DockPlacement::axis`] resolves the same variant the same way, + /// silently rather than by panicking. + pub fn size_from_pointer(&self, pointer: Point) -> Pixels { + match self.placement { + DockPlacement::Left => pointer.x - self.area.left(), + DockPlacement::Right => self.area.right() - pointer.x, + DockPlacement::Bottom => self.area.bottom() - pointer.y, + DockPlacement::Center => px(0.), + } + } + + /// Clamp a size into the range this dock may occupy: never below + /// `PANEL_MIN_SIZE`, and never so large it would squeeze the opposite + /// dock (if any) below `PANEL_MIN_SIZE` either. The `.max(PANEL_MIN_SIZE)` + /// on the computed maximum matters when the area itself is narrower than + /// both minimums combined — it keeps the clamp range non-empty. + /// + /// `DockPlacement::Center` constrains nothing, so it hands the size back + /// unchanged. See [`Self::size_from_pointer`] for why that variant is + /// answered rather than rejected. + pub fn clamp(&self, size: Pixels) -> Pixels { + let max_size = match self.placement { + DockPlacement::Left | DockPlacement::Right => { + (self.area.size.width - PANEL_MIN_SIZE - self.opposite_dock_size) + .max(PANEL_MIN_SIZE) + } + DockPlacement::Bottom => (self.area.size.height - PANEL_MIN_SIZE).max(PANEL_MIN_SIZE), + DockPlacement::Center => return size, + }; + size.clamp(PANEL_MIN_SIZE, max_size) + } +} + +/// Runtime state for one dock: whether it is open, collapsible, its current +/// size, and whether it is mid-resize. +/// +/// This does not include the dock's placement or its panel content. `DockArea` +/// owns one of these per dock, paired with that dock's `PaneTree` and keyed +/// by its [`DockPlacement`]; the placement is the key, and the content is the +/// tree. +#[derive(Clone, Copy, Debug)] +pub struct Dock { + open: bool, + collapsible: bool, + size: Pixels, + resizing: bool, +} + +impl Dock { + pub fn new(size: Pixels) -> Self { + Self { + open: true, + collapsible: true, + size, + resizing: false, + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn set_open(&mut self, open: bool) { + self.open = open; + } + + pub fn is_collapsible(&self) -> bool { + self.collapsible + } + + pub fn set_collapsible(&mut self, collapsible: bool) { + self.collapsible = collapsible; + } + + pub fn size(&self) -> Pixels { + self.size + } + + /// Set the dock's size, never below [`PANEL_MIN_SIZE`]. + /// + /// The floor is here rather than at the call sites because + /// `DockArea::set_dock_size` is public and unclamped: a smaller value + /// would collapse the dock to nothing, the skin clips the resize handle + /// that would drag it back out, and the collapsed size persists. + pub fn set_size(&mut self, size: Pixels) { + self.size = size.max(PANEL_MIN_SIZE); + } + + pub fn is_resizing(&self) -> bool { + self.resizing + } + + pub fn set_resizing(&mut self, resizing: bool) { + self.resizing = resizing; + } +} + +#[cfg(test)] +mod dock_tests { + use super::*; + use gpui::{point, size}; + + #[test] + fn a_left_dock_cannot_squeeze_past_the_right_dock() { + let sizing = DockSizing::new(DockPlacement::Left) + .with_area_width(px(1000.)) + .with_opposite_dock_size(px(300.)); + + assert_eq!( + sizing.clamp(px(900.)), + px(1000.) - PANEL_MIN_SIZE - px(300.) + ); + } + + #[test] + fn a_dock_never_clamps_below_the_minimum() { + let sizing = DockSizing::new(DockPlacement::Bottom).with_area_height(px(120.)); + assert_eq!(sizing.clamp(px(1.)), PANEL_MIN_SIZE); + } + + #[test] + fn a_bottom_dock_measures_from_the_area_bottom() { + let sizing = DockSizing::new(DockPlacement::Bottom).with_area_bounds(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(800.), px(600.)), + }); + + assert_eq!( + sizing.size_from_pointer(point(px(400.), px(400.))), + px(200.) + ); + } + + #[test] + fn a_right_dock_measures_from_the_area_right_edge() { + let sizing = DockSizing::new(DockPlacement::Right).with_area_bounds(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(800.), px(600.)), + }); + + assert_eq!(sizing.size_from_pointer(point(px(500.), px(0.))), px(300.)); + } + + /// `DockArea::set_dock_size` is public and hands its argument straight + /// here, so without this floor a caller could collapse a dock to nothing + /// and persist it that way. + #[test] + fn a_dock_never_shrinks_below_the_minimum() { + let mut dock = Dock::new(px(240.)); + dock.set_size(px(1.)); + assert_eq!(dock.size(), PANEL_MIN_SIZE); + + dock.set_size(px(400.)); + assert_eq!(dock.size(), px(400.), "a size above the floor is kept"); + } + + /// Both are `pub` and take a `pub` enum, so a caller can name the center. + /// Answering it is what keeps a base arithmetic helper from panicking a + /// desktop application over a value its own type permits. + #[test] + fn the_center_placement_is_answered_rather_than_panicking() { + let sizing = DockSizing::new(DockPlacement::Center).with_area_bounds(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(800.), px(600.)), + }); + + assert_eq!(sizing.clamp(px(7.)), px(7.), "the center clamps nothing"); + assert_eq!(sizing.size_from_pointer(point(px(400.), px(300.))), px(0.)); + } + + #[test] + fn dock_state_defaults_to_open_and_collapsible() { + let dock = Dock::new(px(240.)); + assert!(dock.is_open()); + assert!(dock.is_collapsible()); + assert_eq!(dock.size(), px(240.)); + assert!(!dock.is_resizing()); + } +} diff --git a/vendor/gpui-base/src/dock/drag.rs b/vendor/gpui-base/src/dock/drag.rs new file mode 100644 index 00000000..704bcebc --- /dev/null +++ b/vendor/gpui-base/src/dock/drag.rs @@ -0,0 +1,372 @@ +//! Drag hit-testing and drop geometry for the dock. +//! +//! This module decides *where* a drop would land and what shape a hovering +//! drag session occupies. It draws nothing: the styled drag preview and the +//! rendered drop indicator are appearance and live in `crates/ui`. + +use std::{ + any::Any, + cell::Cell, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use gpui::{ + Bounds, Context, Empty, IntoElement, Pixels, Point, Render, Size, Window, point, px, size, +}; + +use crate::Placement; + +use super::layout::{NodeId, PanelId}; + +/// A panel being dragged out of a tab group. +/// +/// The panel is carried as a [`PanelId`] rather than a view handle: the base +/// layer has no `PanelView` trait or `TabPanel` entity of its own (those are +/// layered above), and the layout algebra already addresses panels this way +/// (see `insert_panel`/`remove_panel`/`move_panel` in `layout::edit`). A +/// consumer resolves the id back to a view through the dock area's panel +/// map. +#[derive(Clone)] +pub struct DragPanel { + panel: PanelId, + source: NodeId, + drag_offset: Rc>>, + preview_size: Rc>>, + drag_session_id: u64, +} + +static NEXT_DRAG_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +/// Stands in for [`DragPanel::drag_session_id`] on host-owned drag items, which +/// carry no session of their own. `NEXT_DRAG_SESSION_ID` starts at 1, so 0 never +/// collides. +pub(crate) const ITEM_DRAG_SESSION_ID: u64 = 0; + +impl DragPanel { + pub fn new(panel: PanelId, source: NodeId) -> Self { + Self { + panel, + source, + drag_offset: Rc::new(Cell::new(Point::default())), + preview_size: Rc::new(Cell::new(Size::default())), + drag_session_id: NEXT_DRAG_SESSION_ID.fetch_add(1, Ordering::Relaxed), + } + } + + pub fn panel(&self) -> PanelId { + self.panel + } + + /// The tab group this panel was dragged out of. + pub fn source(&self) -> NodeId { + self.source + } + + pub fn drag_offset(&self) -> Point { + self.drag_offset.get() + } + + /// Records where inside the panel the drag started, so a preview can be + /// positioned relative to the cursor. + pub fn set_drag_offset(&self, offset: Point) { + self.drag_offset.set(offset); + } + + /// How large the drag preview is on screen. + /// + /// The dock reads it to decide where a drop placeholder flies in from, + /// which is hit geometry rather than styling; the preview's own size is a + /// visual decision, so the skin that draws the preview reports it here. + /// Defaults to zero, which degrades to a placeholder that grows out of the + /// cursor rather than out of the preview. + pub fn preview_size(&self) -> Size { + self.preview_size.get() + } + + pub fn set_preview_size(&self, size: Size) { + self.preview_size.set(size); + } + + pub fn drag_session_id(&self) -> u64 { + self.drag_session_id + } +} + +impl Render for DragPanel { + /// Base draws nothing: the styled drag preview is appearance and belongs + /// to `crates/ui`, which reintroduces it as a separate render type. + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Empty + } +} + +/// A host-owned value being dragged over the dock, opaque to the dock itself. +#[derive(Clone, Debug)] +pub struct AnyDrag { + value: Arc, +} + +impl AnyDrag { + pub fn new(value: impl Any) -> Self { + Self { + value: Arc::new(value), + } + } + + pub fn value(&self) -> &Arc { + &self.value + } +} + +/// Where a host-owned drag landed. +#[derive(Clone, Debug)] +pub enum DropTarget { + /// A tiles canvas, where the cursor position is the landing position and + /// the host can read it directly. + Canvas, + /// A tab group in a split layout. A split layout has no free coordinates, + /// so the container reports the group and the edge it resolved instead. + /// + /// `placement` is `None` for the centre zone, meaning merge into the group + /// rather than split. + Group { + node: NodeId, + placement: Option, + }, +} + +/// What the skin should draw while a drag hovers a group. +/// +/// It carries geometry over time — where the placeholder comes from, where it +/// settles, and which run of the animation this is — but not the tween. Easing +/// and duration are styling and belong to the skin that draws it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropIndicator { + bounds: Bounds, + placement: Option, + from: DropPlaceholderBounds, + to: DropPlaceholderBounds, + drag_session_id: u64, + epoch: u64, +} + +impl DropIndicator { + pub(crate) fn new( + bounds: Bounds, + placement: Option, + from: DropPlaceholderBounds, + to: DropPlaceholderBounds, + drag_session_id: u64, + epoch: u64, + ) -> Self { + Self { + bounds, + placement, + from, + to, + drag_session_id, + epoch, + } + } + + /// The hovered group's content bounds, in window coordinates. + pub fn bounds(&self) -> Bounds { + self.bounds + } + + /// `None` means the drop merges into the tab group. + pub fn placement(&self) -> Option { + self.placement + } + + /// Where the placeholder starts, relative to [`Self::bounds`]. + pub fn from(&self) -> DropPlaceholderBounds { + self.from + } + + /// Where the placeholder settles, relative to [`Self::bounds`]. + pub fn to(&self) -> DropPlaceholderBounds { + self.to + } + + /// Which drag session this indicator belongs to. Host-owned drag items + /// share [`ITEM_DRAG_SESSION_ID`]. + pub fn drag_session_id(&self) -> u64 { + self.drag_session_id + } + + /// Bumped on every restart, so an animation keyed on it replays instead of + /// resuming when the target placement changes. + pub fn epoch(&self) -> u64 { + self.epoch + } +} + +/// The bounds a drop placeholder should occupy within a tab group, given +/// where the drop would land. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropPlaceholderBounds { + origin: Point, + size: Size, +} + +impl DropPlaceholderBounds { + pub(crate) fn new(origin: Point, size: Size) -> Self { + Self { origin, size } + } + + pub fn for_placement(bounds: Bounds, placement: Option) -> Self { + let half_width = bounds.size.width * 0.5; + let half_height = bounds.size.height * 0.5; + + match placement { + Some(Placement::Left) => Self { + origin: Point::default(), + size: size(half_width, bounds.size.height), + }, + Some(Placement::Right) => Self { + origin: point(half_width, px(0.)), + size: size(half_width, bounds.size.height), + }, + Some(Placement::Top) => Self { + origin: Point::default(), + size: size(bounds.size.width, half_height), + }, + Some(Placement::Bottom) => Self { + origin: point(px(0.), half_height), + size: size(bounds.size.width, half_height), + }, + None => Self { + origin: Point::default(), + size: bounds.size, + }, + } + } + + pub fn origin(&self) -> Point { + self.origin + } + + pub fn size(&self) -> Size { + self.size + } +} + +/// Which split zone `position` falls into within `bounds`, or `None` for the +/// centre zone (merge into the tab group rather than split). +pub fn split_placement_at(bounds: Bounds, position: Point) -> Option { + if position.x < bounds.left() + bounds.size.width * 0.35 { + Some(Placement::Left) + } else if position.x > bounds.left() + bounds.size.width * 0.65 { + Some(Placement::Right) + } else if position.y < bounds.top() + bounds.size.height * 0.35 { + Some(Placement::Top) + } else if position.y > bounds.top() + bounds.size.height * 0.65 { + Some(Placement::Bottom) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use gpui::point; + + use super::*; + + #[test] + fn drop_placeholder_bounds_cover_each_target_placement() { + let bounds = gpui::Bounds { + origin: point(px(120.), px(80.)), + size: gpui::size(px(400.), px(300.)), + }; + + assert_eq!( + DropPlaceholderBounds::for_placement(bounds, Some(Placement::Left)), + DropPlaceholderBounds { + origin: point(px(0.), px(0.)), + size: gpui::size(px(200.), px(300.)), + } + ); + assert_eq!( + DropPlaceholderBounds::for_placement(bounds, Some(Placement::Right)), + DropPlaceholderBounds { + origin: point(px(200.), px(0.)), + size: gpui::size(px(200.), px(300.)), + } + ); + assert_eq!( + DropPlaceholderBounds::for_placement(bounds, Some(Placement::Top)), + DropPlaceholderBounds { + origin: point(px(0.), px(0.)), + size: gpui::size(px(400.), px(150.)), + } + ); + assert_eq!( + DropPlaceholderBounds::for_placement(bounds, Some(Placement::Bottom)), + DropPlaceholderBounds { + origin: point(px(0.), px(150.)), + size: gpui::size(px(400.), px(150.)), + } + ); + assert_eq!( + DropPlaceholderBounds::for_placement(bounds, None), + DropPlaceholderBounds { + origin: point(px(0.), px(0.)), + size: gpui::size(px(400.), px(300.)), + } + ); + } + + #[test] + fn split_placement_follows_the_cursor_zone() { + let bounds = gpui::Bounds { + origin: point(px(120.), px(80.)), + size: gpui::size(px(400.), px(300.)), + }; + // 35% / 65% of 400x300 from origin (120, 80). + let at = |x: f32, y: f32| split_placement_at(bounds, point(px(x), px(y))); + + assert_eq!(at(130., 230.), Some(Placement::Left)); + assert_eq!(at(510., 230.), Some(Placement::Right)); + assert_eq!(at(320., 90.), Some(Placement::Top)); + assert_eq!(at(320., 370.), Some(Placement::Bottom)); + assert_eq!(at(320., 230.), None, "centre merges into the tab group"); + } + + #[test] + fn split_placement_prefers_horizontal_in_the_corners() { + let bounds = gpui::Bounds { + origin: Point::default(), + size: gpui::size(px(400.), px(300.)), + }; + + // Top-left corner satisfies both Left and Top; x is tested first. + assert_eq!( + split_placement_at(bounds, point(px(10.), px(10.))), + Some(Placement::Left) + ); + assert_eq!( + split_placement_at(bounds, point(px(390.), px(290.))), + Some(Placement::Right) + ); + } + + #[test] + fn split_placement_boundaries_fall_into_the_centre() { + let bounds = gpui::Bounds { + origin: Point::default(), + size: gpui::size(px(400.), px(300.)), + }; + + // Comparisons are strict, so the threshold itself is the centre zone. + assert_eq!(split_placement_at(bounds, point(px(140.), px(150.))), None); + assert_eq!(split_placement_at(bounds, point(px(260.), px(150.))), None); + assert_eq!(split_placement_at(bounds, point(px(200.), px(105.))), None); + assert_eq!(split_placement_at(bounds, point(px(200.), px(195.))), None); + } +} diff --git a/vendor/gpui-base/src/dock/fixtures/bare_tab_panel_root.json b/vendor/gpui-base/src/dock/fixtures/bare_tab_panel_root.json new file mode 100644 index 00000000..234debcf --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/bare_tab_panel_root.json @@ -0,0 +1,19 @@ +{ + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ListStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } +} diff --git a/vendor/gpui-component/tests/fixtures/layout.json b/vendor/gpui-base/src/dock/fixtures/layout.json similarity index 99% rename from vendor/gpui-component/tests/fixtures/layout.json rename to vendor/gpui-base/src/dock/fixtures/layout.json index d7cfa48a..13b7a76e 100644 --- a/vendor/gpui-component/tests/fixtures/layout.json +++ b/vendor/gpui-base/src/dock/fixtures/layout.json @@ -73,7 +73,7 @@ "children": [], "info": { "panel": { - "story_klass": "TableStory" + "story_klass": "DataTableStory" } } }, diff --git a/vendor/gpui-base/src/dock/fixtures/legacy_empty_tab_group.json b/vendor/gpui-base/src/dock/fixtures/legacy_empty_tab_group.json new file mode 100644 index 00000000..b2405383 --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/legacy_empty_tab_group.json @@ -0,0 +1,9 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { "panel_name": "TabPanel", "children": [], "info": { "panel": null } } + ], + "info": { "stack": { "sizes": [0.0], "axis": 0 } } + } +} diff --git a/vendor/gpui-base/src/dock/fixtures/nested_splits.json b/vendor/gpui-base/src/dock/fixtures/nested_splits.json new file mode 100644 index 00000000..c3b45e67 --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/nested_splits.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "center": { + "panel_name": "StackPanel", + "children": [ + { + "panel_name": "StackPanel", + "children": [ + { "panel_name": "TabPanel", "children": [{ "panel_name": "Alpha", "children": [], "info": { "panel": null } }], "info": { "tabs": { "active_index": 0 } } }, + { "panel_name": "TabPanel", "children": [{ "panel_name": "Beta", "children": [], "info": { "panel": null } }], "info": { "tabs": { "active_index": 0 } } } + ], + "info": { "stack": { "sizes": [50.0, 150.0], "axis": 0 } } + }, + { + "panel_name": "StackPanel", + "children": [ + { "panel_name": "TabPanel", "children": [{ "panel_name": "Gamma", "children": [], "info": { "panel": null } }], "info": { "tabs": { "active_index": 0 } } } + ], + "info": { "stack": { "sizes": [300.0], "axis": 1 } } + } + ], + "info": { "stack": { "sizes": [400.0, 300.0], "axis": 0 } } + } +} diff --git a/vendor/gpui-base/src/dock/fixtures/tiles.json b/vendor/gpui-base/src/dock/fixtures/tiles.json new file mode 100644 index 00000000..1251754c --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/tiles.json @@ -0,0 +1,23 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { + "panel_name": "Tiles", + "children": [ + { "panel_name": "Alpha", "children": [], "info": { "panel": null } }, + { "panel_name": "Beta", "children": [], "info": { "panel": null } } + ], + "info": { + "tiles": { + "metas": [ + { "bounds": { "origin": { "x": 10.0, "y": 20.0 }, "size": { "width": 200.0, "height": 150.0 } }, "z_index": 0 }, + { "bounds": { "origin": { "x": 220.0, "y": 20.0 }, "size": { "width": 200.0, "height": 150.0 } }, "z_index": 1 } + ] + } + } + } + ], + "info": { "stack": { "sizes": [0.0], "axis": 0 } } + } +} diff --git a/vendor/gpui-base/src/dock/fixtures/tiles_tab_panel_children.json b/vendor/gpui-base/src/dock/fixtures/tiles_tab_panel_children.json new file mode 100644 index 00000000..f0548f80 --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/tiles_tab_panel_children.json @@ -0,0 +1,36 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { + "panel_name": "Tiles", + "children": [ + { + "panel_name": "TabPanel", + "children": [ + { "panel_name": "Alpha", "children": [], "info": { "panel": null } }, + { "panel_name": "Beta", "children": [], "info": { "panel": null } } + ], + "info": { "tabs": { "active_index": 0 } } + }, + { + "panel_name": "TabPanel", + "children": [ + { "panel_name": "Gamma", "children": [], "info": { "panel": null } } + ], + "info": { "tabs": { "active_index": 0 } } + } + ], + "info": { + "tiles": { + "metas": [ + { "bounds": { "origin": { "x": 10.0, "y": 20.0 }, "size": { "width": 200.0, "height": 150.0 } }, "z_index": 0 }, + { "bounds": { "origin": { "x": 400.0, "y": 60.0 }, "size": { "width": 220.0, "height": 180.0 } }, "z_index": 3 } + ] + } + } + } + ], + "info": { "stack": { "sizes": [800.0], "axis": 0 } } + } +} diff --git a/vendor/gpui-base/src/dock/fixtures/unregistered_panel.json b/vendor/gpui-base/src/dock/fixtures/unregistered_panel.json new file mode 100644 index 00000000..710071bf --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/unregistered_panel.json @@ -0,0 +1,15 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { + "panel_name": "TabPanel", + "children": [ + { "panel_name": "PanelFromTheFuture", "children": [], "info": { "panel": { "keep": "me" } } } + ], + "info": { "tabs": { "active_index": 0 } } + } + ], + "info": { "stack": { "sizes": [0.0], "axis": 0 } } + } +} diff --git a/vendor/gpui-base/src/dock/fixtures/zero_size_sentinel.json b/vendor/gpui-base/src/dock/fixtures/zero_size_sentinel.json new file mode 100644 index 00000000..980fc907 --- /dev/null +++ b/vendor/gpui-base/src/dock/fixtures/zero_size_sentinel.json @@ -0,0 +1,10 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { "panel_name": "TabPanel", "children": [{ "panel_name": "Alpha", "children": [], "info": { "panel": null } }], "info": { "tabs": { "active_index": 0 } } }, + { "panel_name": "TabPanel", "children": [{ "panel_name": "Beta", "children": [], "info": { "panel": null } }], "info": { "tabs": { "active_index": 0 } } } + ], + "info": { "stack": { "sizes": [0.0, 200.0], "axis": 0 } } + } +} diff --git a/vendor/gpui-base/src/dock/layout/builder.rs b/vendor/gpui-base/src/dock/layout/builder.rs new file mode 100644 index 00000000..8cd1e1db --- /dev/null +++ b/vendor/gpui-base/src/dock/layout/builder.rs @@ -0,0 +1,380 @@ +//! Describing a layout without building it. + +use std::sync::Arc; + +use gpui::{App, Axis, Bounds, Entity, Pixels}; + +use super::node::{NodeKind, PaneNode, PanelId, TilePanel}; +use super::tree::{PaneTree, RootKind}; +use crate::dock::panel::{Panel, PanelView}; + +/// Describes a layout without constructing any entity. +/// +/// Building a layout used to require `window` and `cx` because every node was +/// an entity. Now a layout is data, and `DockArea` reconciles it into entities +/// when it is installed. +/// +/// Misuse — a panel added to a split, a child container added to a tab group, +/// an active index on anything but a tab group — trips a `debug_assert!` and +/// is otherwise ignored, matching how the old `DockItem::active_index` guarded +/// itself. A type-state builder would reject the same mistakes at compile +/// time, but it needs four marker types and leaks into every consumer +/// signature; the runtime assertion is proportionate for a builder whose +/// misuse shows up on the first debug run. +pub struct DockLayout { + kind: BuilderKind, +} + +enum BuilderKind { + Split { + axis: Axis, + children: Vec<(DockLayout, Option)>, + }, + Tabs { + panels: Vec<(PanelId, Arc)>, + active_ix: usize, + }, + Tiles { + panels: Vec<(PanelId, Arc, Bounds)>, + }, +} + +impl DockLayout { + /// A split whose children sit side by side. + pub fn h_split() -> Self { + Self::split(Axis::Horizontal) + } + + /// A split whose children stack. + pub fn v_split() -> Self { + Self::split(Axis::Vertical) + } + + /// A tab group: panels stacked one at a time, selected from a tab bar. + pub fn tabs() -> Self { + Self { + kind: BuilderKind::Tabs { + panels: Vec::new(), + active_ix: 0, + }, + } + } + + /// A tiles canvas: panels placed at free coordinates, each dragged and + /// resized on its own. + pub fn tiles() -> Self { + Self { + kind: BuilderKind::Tiles { panels: Vec::new() }, + } + } + + fn split(axis: Axis) -> Self { + Self { + kind: BuilderKind::Split { + axis, + children: Vec::new(), + }, + } + } + + /// Add a child container to a split. `size` is the child's slot along the + /// split's axis; `None` leaves it unconstrained. + pub fn child(mut self, child: DockLayout, size: Option) -> Self { + debug_assert!( + matches!(self.kind, BuilderKind::Split { .. }), + "child() is only valid on h_split() or v_split()" + ); + if let BuilderKind::Split { children, .. } = &mut self.kind { + children.push((child, size)); + } + self + } + + /// Add a panel to a tab group. + pub fn panel(mut self, panel: Entity

) -> Self { + debug_assert!( + matches!(self.kind, BuilderKind::Tabs { .. }), + "panel() is only valid on tabs()" + ); + if let BuilderKind::Tabs { panels, .. } = &mut self.kind { + panels.push((PanelId::from(panel.entity_id()), Arc::new(panel))); + } + self + } + + /// Add an already-wrapped panel handle to a tab group. + /// + /// The companion to [`Self::panel`], for a layer that hands base its own + /// concrete handle — see [`PanelView::as_any`] — rather than a bare + /// entity. `cx` is here because the id has to come from + /// [`PanelView::panel_id`]: unlike [`Self::panel`], there is no entity in + /// hand to take it from. + pub fn panel_view(mut self, panel: Arc, cx: &App) -> Self { + debug_assert!( + matches!(self.kind, BuilderKind::Tabs { .. }), + "panel_view() is only valid on tabs()" + ); + if let BuilderKind::Tabs { panels, .. } = &mut self.kind { + panels.push((panel.panel_id(cx), panel)); + } + self + } + + /// Place a panel on a tiles canvas. + pub fn tile(mut self, panel: Entity

, bounds: Bounds) -> Self { + debug_assert!( + matches!(self.kind, BuilderKind::Tiles { .. }), + "tile() is only valid on tiles()" + ); + if let BuilderKind::Tiles { panels } = &mut self.kind { + panels.push((PanelId::from(panel.entity_id()), Arc::new(panel), bounds)); + } + self + } + + /// Place an already-wrapped panel handle on a tiles canvas. The companion + /// to [`Self::tile`], for the same reason [`Self::panel_view`] is the + /// companion to [`Self::panel`]. + pub fn tile_view( + mut self, + panel: Arc, + bounds: Bounds, + cx: &App, + ) -> Self { + debug_assert!( + matches!(self.kind, BuilderKind::Tiles { .. }), + "tile_view() is only valid on tiles()" + ); + if let BuilderKind::Tiles { panels } = &mut self.kind { + panels.push((panel.panel_id(cx), panel, bounds)); + } + self + } + + /// Which tab is displayed. Out-of-range values are clamped by + /// `normalize` once the layout is installed. + pub fn active_index(mut self, ix: usize) -> Self { + debug_assert!( + matches!(self.kind, BuilderKind::Tabs { .. }), + "active_index() is only valid on tabs()" + ); + if let BuilderKind::Tabs { active_ix, .. } = &mut self.kind { + *active_ix = ix; + } + self + } + + /// Lower into a tree plus the panel views the area must register. + /// + /// The views come back paired with the ids the tree was built from rather + /// than as bare views: recovering an id from a view means calling + /// [`PanelView::panel_id`], and nothing here would notice if a `PanelView` + /// implementation ever answered with something other than its entity id. + pub(crate) fn build( + self, + tree: &mut PaneTree, + ) -> (PaneNode, Vec<(PanelId, Arc)>) { + let mut panels = Vec::new(); + let node = self.build_node(tree, &mut panels); + (node, panels) + } + + fn build_node( + self, + tree: &mut PaneTree, + collected: &mut Vec<(PanelId, Arc)>, + ) -> PaneNode { + let id = tree.allocate_node_id(); + match self.kind { + BuilderKind::Split { axis, children } => { + let mut nodes = Vec::with_capacity(children.len()); + let mut sizes = Vec::with_capacity(children.len()); + for (child, size) in children { + nodes.push(child.build_node(tree, collected)); + sizes.push(size); + } + PaneNode::new( + id, + NodeKind::Split { + axis, + children: nodes, + sizes, + }, + ) + } + BuilderKind::Tabs { panels, active_ix } => { + let ids = panels.iter().map(|(id, _)| *id).collect(); + collected.extend(panels); + PaneNode::new( + id, + NodeKind::Tabs { + panels: ids, + active_ix, + }, + ) + } + BuilderKind::Tiles { panels } => { + let tiles = panels + .iter() + .enumerate() + .map(|(ix, (panel, _, bounds))| { + TilePanel::new(*panel, *bounds).with_z_index(ix) + }) + .collect(); + collected.extend(panels.into_iter().map(|(id, view, _)| (id, view))); + PaneNode::new(id, NodeKind::Tiles { panels: tiles }) + } + } + } +} + +impl PaneTree { + /// Build a whole tree from a described layout. + /// + /// The `RootKind::Split` wrap mirrors the one in + /// [`PaneTree::from_state`](crate::dock::PaneTree::from_state): a + /// center whose described root is a tab group or a tiles canvas still has + /// to serialize as a `StackPanel`. + pub(crate) fn from_layout( + layout: DockLayout, + root_kind: RootKind, + ) -> (Self, Vec<(PanelId, Arc)>) { + let mut tree = PaneTree::new(root_kind); + let (root, panels) = layout.build(&mut tree); + + let root = match (root_kind, root.kind_ref()) { + (RootKind::Split, NodeKind::Split { .. }) | (RootKind::Any, _) => root, + (RootKind::Split, _) => { + let id = tree.allocate_node_id(); + PaneNode::new( + id, + NodeKind::Split { + axis: Axis::Horizontal, + children: vec![root], + sizes: vec![None], + }, + ) + } + }; + + tree.replace_root(root); + tree.normalize(); + (tree, panels) + } +} + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, px}; + + use super::super::PaneRef; + use super::*; + use crate::dock::test_support::TestPanel; + + #[gpui::test] + fn a_described_split_lowers_to_a_split_of_tab_groups(cx: &mut TestAppContext) { + let (tree, panels) = cx.update(|cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + PaneTree::from_layout( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha), Some(px(300.))) + .child(DockLayout::tabs().panel(beta), None), + RootKind::Split, + ) + }); + + assert_eq!(panels.len(), 2); + let PaneRef::Split { + axis, + children, + sizes, + } = tree.root().kind() + else { + panic!("expected a split root"); + }; + assert_eq!(axis, gpui::Axis::Horizontal); + assert_eq!(children.len(), 2); + assert_eq!(sizes, &[Some(px(300.)), None]); + assert!(matches!(children[0].kind(), PaneRef::Tabs { panels, .. } if panels.len() == 1)); + } + + /// Ported from `DockItem::split_with_sizes_adds_each_child_once`, which + /// guarded a `StackPanel` that once added every child twice. + #[gpui::test] + fn split_with_sizes_adds_each_child_once(cx: &mut TestAppContext) { + let (tree, _) = cx.update(|cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + PaneTree::from_layout( + DockLayout::h_split() + .child(DockLayout::tabs().panel(alpha), None) + .child(DockLayout::tabs().panel(beta), None), + RootKind::Split, + ) + }); + + let PaneRef::Split { + children, sizes, .. + } = tree.root().kind() + else { + panic!("expected a split root"); + }; + assert_eq!( + children.len(), + 2, + "each described child appears exactly once" + ); + assert_eq!(sizes.len(), children.len()); + assert_ne!(children[0].id(), children[1].id()); + } + + #[gpui::test] + fn a_bare_tab_group_is_wrapped_for_a_split_root(cx: &mut TestAppContext) { + let (tree, _) = cx.update(|cx| { + let alpha = TestPanel::new("Alpha", cx); + PaneTree::from_layout(DockLayout::tabs().panel(alpha), RootKind::Split) + }); + + assert!(matches!(tree.root().kind(), PaneRef::Split { .. })); + } + + #[gpui::test] + fn an_active_index_selects_the_displayed_tab(cx: &mut TestAppContext) { + let (tree, _) = cx.update(|cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + PaneTree::from_layout( + DockLayout::tabs().panel(alpha).panel(beta).active_index(1), + RootKind::Any, + ) + }); + + let PaneRef::Tabs { active_ix, .. } = tree.root().kind() else { + panic!("expected a tab group root"); + }; + assert_eq!(active_ix, 1); + } + + #[gpui::test] + fn tiles_are_stacked_in_the_order_they_are_placed(cx: &mut TestAppContext) { + let bounds = Bounds { + origin: gpui::point(px(0.), px(0.)), + size: gpui::size(px(100.), px(100.)), + }; + let (tree, _) = cx.update(|cx| { + let alpha = TestPanel::new("Alpha", cx); + let beta = TestPanel::new("Beta", cx); + PaneTree::from_layout( + DockLayout::tiles().tile(alpha, bounds).tile(beta, bounds), + RootKind::Any, + ) + }); + + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!("expected a tiles root"); + }; + assert_eq!(panels[0].z_index(), 0); + assert_eq!(panels[1].z_index(), 1); + } +} diff --git a/vendor/gpui-base/src/dock/layout/edit.rs b/vendor/gpui-base/src/dock/layout/edit.rs new file mode 100644 index 00000000..07476ad8 --- /dev/null +++ b/vendor/gpui-base/src/dock/layout/edit.rs @@ -0,0 +1,799 @@ +use std::collections::HashMap; + +use gpui::{Bounds, Pixels}; + +use crate::Placement; + +use super::node::{NodeId, NodeKind, PaneNode, PanelId, TilePanel}; +use super::tree::PaneTree; + +/// Where a panel should land. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InsertTarget { + /// Into an existing tab group, optionally at a specific index. + Tabs { + node: NodeId, + ix: Option, + activate: bool, + }, + /// Beside an existing node, creating a new tab group for the panel. + Split { + node: NodeId, + placement: Placement, + size: Option, + }, + /// Onto a tiles canvas at the given bounds. + Tile { + node: NodeId, + bounds: Bounds, + }, +} + +/// What one edit changed. +/// +/// Only whether anything changed, for now. An earlier revision also carried +/// the created and removed nodes, the removed panels, and the activation +/// edges — but nothing outside tests ever read them, and computing them meant +/// cloning the whole tree on every edit to diff against, on a path that a +/// tile drag walks once per mouse move. Fields are private, so any of them +/// can come back the day something needs one. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EditResult { + changed: bool, +} + +impl EditResult { + pub fn changed(&self) -> bool { + self.changed + } +} + +impl PaneTree { + pub fn insert_panel(&mut self, panel: PanelId, target: InsertTarget) -> EditResult { + self.edit(|tree| tree.apply_insert(panel, target)) + } + + pub fn remove_panel(&mut self, panel: PanelId) -> EditResult { + self.edit(|tree| tree.detach_panel(panel)) + } + + /// Move a panel to a new home without ever removing it from the tree's + /// perspective, so the caller never fires `on_removed` for a drag. + pub fn move_panel(&mut self, panel: PanelId, target: InsertTarget) -> EditResult { + self.edit(|tree| { + let detached = tree.detach_panel(panel); + let inserted = tree.apply_insert(panel, target); + detached || inserted + }) + } + + pub fn split( + &mut self, + at: NodeId, + panel: PanelId, + placement: Placement, + size: Option, + ) -> EditResult { + self.insert_panel( + panel, + InsertTarget::Split { + node: at, + placement, + size, + }, + ) + } + + pub fn set_active(&mut self, node: NodeId, ix: usize) -> EditResult { + self.edit(|tree| { + let Some(path) = tree.path_of_node(node) else { + return false; + }; + let NodeKind::Tabs { active_ix, .. } = tree.node_at_mut(&path).kind_mut() else { + return false; + }; + if *active_ix == ix { + return false; + } + *active_ix = ix; + true + }) + } + + /// Replace a split's slot sizes wholesale. + /// + /// A no-op, like every other operation given input it cannot resolve, if + /// `new_sizes.len()` does not match the split's child count: no rule in + /// `normalize` repairs a length mismatch, so applying it would otherwise + /// leave `children.len() != sizes.len()` and trip `normalize`'s + /// `debug_assert!`. + pub fn set_sizes(&mut self, node: NodeId, new_sizes: Vec>) -> EditResult { + self.edit(|tree| { + let Some(path) = tree.path_of_node(node) else { + return false; + }; + let NodeKind::Split { + children, sizes, .. + } = tree.node_at_mut(&path).kind_mut() + else { + return false; + }; + if new_sizes.len() != children.len() || *sizes == new_sizes { + return false; + } + *sizes = new_sizes; + true + }) + } + + pub fn set_tile_bounds(&mut self, panel: PanelId, bounds: Bounds) -> EditResult { + self.edit(|tree| { + let mut changed = false; + tree.with_tile(panel, |tile| { + if tile.bounds() != bounds { + *tile = tile.with_bounds(bounds); + changed = true; + } + }); + changed + }) + } + + pub fn bring_to_front(&mut self, panel: PanelId) -> EditResult { + self.edit(|tree| { + let top = tree.max_z_index(); + let mut changed = false; + tree.with_tile(panel, |tile| { + // Already on top is a no-op rather than another increment, so + // repeatedly grabbing the front tile neither churns the tree + // nor lets z-indices climb forever. + if tile.z_index() < top { + *tile = tile.with_z_index(top + 1); + changed = true; + } + }); + changed + }) + } +} + +impl PaneTree { + /// Replace every split's slot sizes with what that split is actually drawn + /// at. + /// + /// A layout is usually built with unconstrained slots, and `None` stays + /// `None` in the tree no matter how the split is later measured or + /// dragged. That is fine until an edit has to divide space — dropping a + /// panel beside another one has to halve *something*, and it cannot halve + /// an unknown. Adopting the measured sizes first turns the question into + /// arithmetic on real pixels, and it matches what the person dragging + /// sees: the layout on screen is the layout being divided. + pub(crate) fn adopt_measured_sizes(&mut self, measured: &HashMap>) { + fn walk(node: &mut PaneNode, measured: &HashMap>) { + let id = node.id(); + let NodeKind::Split { + children, sizes, .. + } = node.kind_mut() + else { + return; + }; + + if let Some(actual) = measured.get(&id) { + if actual.len() == sizes.len() { + for (slot, size) in sizes.iter_mut().zip(actual) { + // A zero means the split has not been laid out yet, so + // the slot keeps whatever it already had. + if *size > Pixels::ZERO { + *slot = Some(*size); + } + } + } + } + + for child in children.iter_mut() { + walk(child, measured); + } + } + + walk(self.root_mut(), measured); + } +} + +impl PaneTree { + /// Apply one mutation, then collapse. + /// + /// `apply` reports whether it changed anything and normalization reports + /// the same, so no snapshot of the previous tree is needed to answer the + /// only question a caller asks. A mutation that cannot resolve its target + /// returns `false` and the whole edit is a no-op. + fn edit(&mut self, apply: impl FnOnce(&mut Self) -> bool) -> EditResult { + let mutated = apply(self); + let collapsed = self.normalize_reporting(); + + EditResult { + changed: mutated || collapsed, + } + } + + /// Whether `panel` is anywhere in this tree. + pub fn contains_panel(&self, panel: PanelId) -> bool { + self.panels().any(|candidate| candidate == panel) + } +} + +impl PaneTree { + /// Returns whether the panel actually landed. A target this tree cannot + /// resolve — a stale node id, or one whose kind does not match the + /// target — is a no-op rather than an error, and reports `false`. + fn apply_insert(&mut self, panel: PanelId, target: InsertTarget) -> bool { + match target { + InsertTarget::Tabs { node, ix, activate } => { + let Some(path) = self.path_of_node(node) else { + return false; + }; + let NodeKind::Tabs { panels, active_ix } = self.node_at_mut(&path).kind_mut() + else { + return false; + }; + let ix = ix.unwrap_or(panels.len()).min(panels.len()); + panels.insert(ix, panel); + if activate { + *active_ix = ix; + } else if ix <= *active_ix && panels.len() > 1 { + // Keep the displayed panel displayed. + *active_ix += 1; + } + true + } + InsertTarget::Split { + node, + placement, + size, + } => self.insert_beside(node, panel, placement, size), + InsertTarget::Tile { node, bounds } => { + let Some(path) = self.path_of_node(node) else { + return false; + }; + let top = self.max_z_index(); + let NodeKind::Tiles { panels } = self.node_at_mut(&path).kind_mut() else { + return false; + }; + panels.push(TilePanel::new(panel, bounds).with_z_index(top + 1)); + true + } + } + } + + /// Place `panel` in a new tab group beside `node`. + /// + /// When the parent split already runs along the placement's axis the new + /// group becomes a sibling. Otherwise `node` is wrapped in a fresh split of + /// the placement's axis. Rule 3 of `normalize` then flattens any redundant + /// nesting this creates, which is why no "reuse the parent split" special + /// case is needed here. + fn insert_beside( + &mut self, + node: NodeId, + panel: PanelId, + placement: Placement, + size: Option, + ) -> bool { + let Some(path) = self.path_of_node(node) else { + return false; + }; + let group_id = self.allocate_node_id(); + let group = PaneNode::new( + group_id, + NodeKind::Tabs { + panels: vec![panel], + active_ix: 0, + }, + ); + let before = matches!(placement, Placement::Left | Placement::Top); + + if let Some((parent_path, ix)) = split_parent_of(&path) { + let parent_axis = match self.node_at(&parent_path).kind_ref() { + NodeKind::Split { axis, .. } => Some(*axis), + _ => None, + }; + + if parent_axis == Some(placement.axis()) { + let NodeKind::Split { + children, sizes, .. + } = self.node_at_mut(&parent_path).kind_mut() + else { + return false; + }; + // The new group splits the slot it landed beside, rather + // than the whole row being re-divided: drop a panel next to + // one neighbour and it is that neighbour's space you take. + // A caller that named a size gets exactly it, and the + // neighbour is left alone. + // + // An unconstrained neighbour halves to another `None`, which + // is right for the same reason — the two of them go on + // sharing whatever the fixed slots leave over, now between + // themselves. + let share = match size { + Some(size) => Some(size), + None => { + let half = sizes[ix].map(|slot| slot / 2.); + sizes[ix] = half; + half + } + }; + let at = if before { ix } else { ix + 1 }; + children.insert(at, group); + sizes.insert(at, share); + return true; + } + } + + // Wrap the target in a new split of the placement's axis. The target + // is swapped out rather than cloned: it can be a whole subtree, and + // the placeholder left behind is overwritten two statements later. + let wrapper_id = self.allocate_node_id(); + let target = std::mem::replace( + self.node_at_mut(&path), + PaneNode::new( + wrapper_id, + NodeKind::Tabs { + panels: Vec::new(), + active_ix: 0, + }, + ), + ); + let (children, sizes) = if before { + (vec![group, target], vec![size, None]) + } else { + (vec![target, group], vec![None, size]) + }; + let wrapper = PaneNode::new( + wrapper_id, + NodeKind::Split { + axis: placement.axis(), + children, + sizes, + }, + ); + *self.node_at_mut(&path) = wrapper; + true + } + + /// Remove `panel` wherever it lives. Returns whether it was found. + fn detach_panel(&mut self, panel: PanelId) -> bool { + let Some(node) = self.find_panel_node(panel) else { + return false; + }; + let Some(path) = self.path_of_node(node) else { + return false; + }; + + match self.node_at_mut(&path).kind_mut() { + NodeKind::Tabs { panels, active_ix } => { + let Some(ix) = panels.iter().position(|p| *p == panel) else { + return false; + }; + panels.remove(ix); + if ix < *active_ix { + *active_ix -= 1; + } + true + } + NodeKind::Tiles { panels } => { + let Some(ix) = panels.iter().position(|p| p.panel() == panel) else { + return false; + }; + panels.remove(ix); + true + } + NodeKind::Split { .. } => false, + } + } + + fn with_tile(&mut self, panel: PanelId, f: impl FnOnce(&mut TilePanel)) { + let Some(node) = self.find_panel_node(panel) else { + return; + }; + let Some(path) = self.path_of_node(node) else { + return; + }; + if let NodeKind::Tiles { panels } = self.node_at_mut(&path).kind_mut() { + if let Some(tile) = panels.iter_mut().find(|tile| tile.panel() == panel) { + f(tile); + } + } + } + + /// Highest z-index across every tiles canvas in the tree. + fn max_z_index(&self) -> usize { + let mut top = 0; + self.root().walk(&mut |node| { + if let NodeKind::Tiles { panels } = node.kind_ref() { + top = top.max(panels.iter().map(TilePanel::z_index).max().unwrap_or(0)); + } + }); + top + } +} + +/// Split the path into its parent path and the child index, or `None` at the root. +fn split_parent_of(path: &super::tree::NodePath) -> Option<(super::tree::NodePath, usize)> { + let (&ix, parent) = path.split_last()?; + Some((parent.iter().copied().collect(), ix)) +} + +#[cfg(test)] +mod tests { + use super::super::*; + use crate::Placement; + use gpui::{Axis, Bounds, Pixels, point, px, size}; + + fn panel(n: u64) -> PanelId { + PanelId::from_u64(n) + } + + fn tree_with_one_group() -> (PaneTree, NodeId) { + let mut tree = PaneTree::new(RootKind::Split); + let tabs = tree.push_tabs_for_test(tree.root().id(), vec![panel(1)]); + tree.normalize(); + (tree, tabs) + } + + #[test] + fn inserting_into_a_tab_group_appends_and_can_activate() { + let (mut tree, tabs) = tree_with_one_group(); + let result = tree.insert_panel( + panel(2), + InsertTarget::Tabs { + node: tabs, + ix: None, + activate: true, + }, + ); + + assert!(result.changed()); + let PaneRef::Tabs { panels, active_ix } = tree.find_node(tabs).unwrap().kind() else { + panic!() + }; + assert_eq!(panels, [panel(1), panel(2)]); + assert_eq!(active_ix, 1, "the inserted panel becomes the displayed one"); + } + + #[test] + fn a_background_insert_leaves_the_active_panel_alone() { + let (mut tree, tabs) = tree_with_one_group(); + let result = tree.insert_panel( + panel(2), + InsertTarget::Tabs { + node: tabs, + ix: None, + activate: false, + }, + ); + + assert!(result.changed()); + let PaneRef::Tabs { panels, active_ix } = tree.find_node(tabs).unwrap().kind() else { + panic!() + }; + assert_eq!(panels, [panel(1), panel(2)]); + assert_eq!(active_ix, 0, "the displayed panel is left alone"); + } + + #[test] + fn removing_the_last_panel_collapses_the_group_and_reports_it() { + let (mut tree, tabs) = tree_with_one_group(); + let result = tree.remove_panel(panel(1)); + + assert!(result.changed()); + assert!(!tree.contains_panel(panel(1)), "the panel left the tree"); + assert!(tree.find_node(tabs).is_none(), "its empty group collapsed"); + assert!( + matches!(tree.root().kind(), PaneRef::Split { children, .. } if children.is_empty()) + ); + } + + #[test] + fn splitting_creates_a_sibling_group_on_the_requested_side() { + let (mut tree, tabs) = tree_with_one_group(); + let result = tree.split(tabs, panel(2), Placement::Right, Some(px(240.))); + + assert!(result.changed()); + let PaneRef::Split { + axis, + children, + sizes, + } = tree.root().kind() + else { + panic!() + }; + assert_eq!(axis, Axis::Horizontal); + assert_eq!(children.len(), 2); + assert_eq!(sizes[1], Some(px(240.))); + + let PaneRef::Tabs { panels, .. } = children[0].kind() else { + panic!() + }; + assert_eq!(panels, [panel(1)]); + let PaneRef::Tabs { panels, .. } = children[1].kind() else { + panic!() + }; + assert_eq!(panels, [panel(2)]); + } + + #[test] + fn splitting_left_puts_the_new_group_first() { + let (mut tree, tabs) = tree_with_one_group(); + tree.split(tabs, panel(2), Placement::Left, None); + + let PaneRef::Split { children, .. } = tree.root().kind() else { + panic!() + }; + let PaneRef::Tabs { panels, .. } = children[0].kind() else { + panic!() + }; + assert_eq!(panels, [panel(2)]); + } + + #[test] + fn splitting_across_the_parent_axis_nests_a_new_split() { + let (mut tree, tabs) = tree_with_one_group(); + tree.push_tabs_for_test(tree.root().id(), vec![panel(9)]); + tree.normalize(); + + tree.split(tabs, panel(2), Placement::Bottom, None); + + let PaneRef::Split { axis, children, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!(axis, Axis::Horizontal); + let PaneRef::Split { + axis: inner, + children: inner_children, + .. + } = children[0].kind() + else { + panic!("the split target is wrapped in a vertical split") + }; + assert_eq!(inner, Axis::Vertical); + assert_eq!(inner_children.len(), 2); + + // `Bottom` puts the new group after the original target: the + // wrapper's first child is still the target, the second is new. + let PaneRef::Tabs { panels, .. } = inner_children[0].kind() else { + panic!() + }; + assert_eq!(panels, [panel(1)], "the original target stays first"); + let PaneRef::Tabs { panels, .. } = inner_children[1].kind() else { + panic!() + }; + assert_eq!(panels, [panel(2)], "the new group lands second, below"); + } + + #[test] + fn splitting_top_across_the_parent_axis_puts_the_new_group_first() { + let (mut tree, tabs) = tree_with_one_group(); + tree.push_tabs_for_test(tree.root().id(), vec![panel(9)]); + tree.normalize(); + + tree.split(tabs, panel(2), Placement::Top, None); + + let PaneRef::Split { children, .. } = tree.root().kind() else { + panic!() + }; + let PaneRef::Split { + axis: inner, + children: inner_children, + .. + } = children[0].kind() + else { + panic!("the split target is wrapped in a vertical split") + }; + assert_eq!(inner, Axis::Vertical); + assert_eq!(inner_children.len(), 2); + + // `Top` is the mirror of `Bottom`: the new group lands first, above + // the original target. + let PaneRef::Tabs { panels, .. } = inner_children[0].kind() else { + panic!() + }; + assert_eq!(panels, [panel(2)], "the new group lands first, above"); + let PaneRef::Tabs { panels, .. } = inner_children[1].kind() else { + panic!() + }; + assert_eq!(panels, [panel(1)], "the original target moves second"); + } + + #[test] + fn the_split_target_keeps_its_node_id_so_its_entity_survives() { + let (mut tree, tabs) = tree_with_one_group(); + tree.split(tabs, panel(2), Placement::Right, None); + + assert!( + tree.find_node(tabs).is_some(), + "the target group is reused, not rebuilt" + ); + } + + #[test] + fn moving_a_panel_between_groups_preserves_its_identity() { + let (mut tree, tabs) = tree_with_one_group(); + assert!(tree.split(tabs, panel(2), Placement::Right, None).changed()); + let other = tree + .find_panel_node(panel(2)) + .expect("the split put panel 2 in a group of its own"); + + let result = tree.move_panel( + panel(1), + InsertTarget::Tabs { + node: other, + ix: None, + activate: true, + }, + ); + + assert!(result.changed()); + assert_eq!( + tree.panels().collect::>(), + vec![panel(2), panel(1)], + "a move is not a removal; both panels are still in the tree" + ); + assert!( + tree.find_node(tabs).is_none(), + "the emptied group collapses" + ); + } + + #[test] + fn a_no_op_edit_reports_no_change() { + let (mut tree, tabs) = tree_with_one_group(); + let result = tree.set_active(tabs, 0); + assert!(!result.changed()); + } + + #[test] + fn set_sizes_replaces_a_matching_length_vector() { + let (mut tree, tabs) = tree_with_one_group(); + tree.split(tabs, panel(2), Placement::Right, None); + let root = tree.root().id(); + + let result = tree.set_sizes(root, vec![Some(px(100.)), Some(px(200.))]); + + assert!(result.changed()); + let PaneRef::Split { sizes, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!(sizes, &[Some(px(100.)), Some(px(200.))]); + } + + #[test] + fn set_sizes_ignores_a_mismatched_length_vector() { + let (mut tree, tabs) = tree_with_one_group(); + tree.split(tabs, panel(2), Placement::Right, None); + let root = tree.root().id(); + let PaneRef::Split { + sizes: before_sizes, + .. + } = tree.root().kind() + else { + panic!() + }; + let before_sizes = before_sizes.to_vec(); + + // The split has 2 children; hand it 3 sizes. + let result = tree.set_sizes(root, vec![Some(px(10.)), Some(px(20.)), Some(px(30.))]); + + assert!( + !result.changed(), + "a mismatched vector must not report a change" + ); + let PaneRef::Split { sizes, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!( + sizes, + before_sizes.as_slice(), + "the mismatched vector is rejected" + ); + } + + fn tile_bounds(x: f32) -> Bounds { + Bounds { + origin: point(px(x), px(0.)), + size: size(px(10.), px(10.)), + } + } + + #[test] + fn inserting_a_tile_places_it_at_the_given_bounds_on_top() { + let mut tree = PaneTree::new(RootKind::Any); + let canvas = tree.set_root_tiles_for_test(vec![ + TilePanel::new(panel(1), tile_bounds(0.)).with_z_index(4), + ]); + + let result = tree.insert_panel( + panel(2), + InsertTarget::Tile { + node: canvas, + bounds: tile_bounds(70.), + }, + ); + + assert!(result.changed()); + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!("the canvas stays a canvas rather than being split") + }; + assert_eq!(panels.len(), 2); + let added = panels.iter().find(|tile| tile.panel() == panel(2)).unwrap(); + assert_eq!(added.bounds(), tile_bounds(70.), "the bounds are honoured"); + assert!( + added.z_index() > panels[0].z_index(), + "a new tile lands on top of the ones already there" + ); + } + + #[test] + fn set_tile_bounds_moves_one_tile_and_leaves_its_peers_alone() { + let mut tree = PaneTree::new(RootKind::Any); + tree.set_root_tiles_for_test(vec![ + TilePanel::new(panel(1), tile_bounds(0.)).with_z_index(1), + TilePanel::new(panel(2), tile_bounds(40.)).with_z_index(2), + ]); + + let result = tree.set_tile_bounds(panel(1), tile_bounds(90.)); + + assert!(result.changed()); + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!() + }; + let moved = panels.iter().find(|tile| tile.panel() == panel(1)).unwrap(); + let other = panels.iter().find(|tile| tile.panel() == panel(2)).unwrap(); + assert_eq!(moved.bounds(), tile_bounds(90.)); + assert_eq!( + moved.z_index(), + 1, + "moving a tile does not raise it; that is `bring_to_front`'s job" + ); + assert_eq!(other.bounds(), tile_bounds(40.), "its peer does not move"); + } + + #[test] + fn bring_to_front_raises_the_tile_above_its_peers() { + let mut tree = PaneTree::new(RootKind::Any); + let bounds = Bounds { + origin: point(px(0.), px(0.)), + size: size(px(10.), px(10.)), + }; + tree.set_root_tiles_for_test(vec![ + TilePanel::new(panel(1), bounds).with_z_index(0), + TilePanel::new(panel(2), bounds).with_z_index(1), + ]); + + tree.bring_to_front(panel(1)); + + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!() + }; + let raised = panels.iter().find(|p| p.panel() == panel(1)).unwrap(); + let other = panels.iter().find(|p| p.panel() == panel(2)).unwrap(); + assert!(raised.z_index() > other.z_index()); + } + + #[test] + fn every_edit_leaves_the_tree_normalized() { + let (mut tree, tabs) = tree_with_one_group(); + tree.split(tabs, panel(2), Placement::Bottom, None); + tree.insert_panel( + panel(3), + InsertTarget::Tabs { + node: tabs, + ix: None, + activate: false, + }, + ); + tree.remove_panel(panel(2)); + tree.remove_panel(panel(3)); + + assert!(tree.is_normalized()); + } +} diff --git a/vendor/gpui-base/src/dock/layout/mod.rs b/vendor/gpui-base/src/dock/layout/mod.rs new file mode 100644 index 00000000..e7d05ec5 --- /dev/null +++ b/vendor/gpui-base/src/dock/layout/mod.rs @@ -0,0 +1,16 @@ +mod builder; +mod edit; +mod node; +mod normalize; +mod tree; + +pub use builder::DockLayout; +pub use edit::{EditResult, InsertTarget}; + +// `NodeKind` stays crate-private: `dock_area` reads through it to resolve slot +// sizes before a dump, but nothing outside this crate may build a node without +// going through `PaneTree`, which is what guarantees normalization always +// runs. +pub(crate) use node::NodeKind; +pub use node::{NodeId, PaneNode, PaneRef, PanelId, TilePanel}; +pub use tree::{PaneTree, RootKind}; diff --git a/vendor/gpui-base/src/dock/layout/node.rs b/vendor/gpui-base/src/dock/layout/node.rs new file mode 100644 index 00000000..c9e13cca --- /dev/null +++ b/vendor/gpui-base/src/dock/layout/node.rs @@ -0,0 +1,166 @@ +use gpui::{Axis, Bounds, EntityId, Pixels}; + +/// Stable container identity. Survives structural edits and normalization so +/// the `DockArea` view cache does not tear down and rebuild entities. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] +pub struct NodeId(u64); + +impl NodeId { + pub(crate) fn from_u64(raw: u64) -> Self { + Self(raw) + } + + pub fn as_u64(self) -> u64 { + self.0 + } +} + +/// Stable panel identity. Wraps the `EntityId` of the panel entity so the +/// layout algebra can be exercised without an `App`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] +pub struct PanelId(u64); + +impl PanelId { + pub fn from_u64(raw: u64) -> Self { + Self(raw) + } + + pub fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for PanelId { + fn from(id: EntityId) -> Self { + Self(id.as_u64()) + } +} + +/// One panel placed on a tiles canvas. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct TilePanel { + panel: PanelId, + bounds: Bounds, + z_index: usize, +} + +impl TilePanel { + pub fn new(panel: PanelId, bounds: Bounds) -> Self { + Self { + panel, + bounds, + z_index: 0, + } + } + + pub fn with_z_index(mut self, z_index: usize) -> Self { + self.z_index = z_index; + self + } + + pub fn with_bounds(mut self, bounds: Bounds) -> Self { + self.bounds = bounds; + self + } + + pub fn panel(&self) -> PanelId { + self.panel + } + + pub fn bounds(&self) -> Bounds { + self.bounds + } + + pub fn z_index(&self) -> usize { + self.z_index + } +} + +/// The shape of one container. Private: every mutation goes through +/// [`PaneTree`] so normalization always runs. +/// +/// [`PaneTree`]: super::PaneTree +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum NodeKind { + Split { + axis: Axis, + children: Vec, + sizes: Vec>, + }, + Tabs { + panels: Vec, + active_ix: usize, + }, + Tiles { + panels: Vec, + }, +} + +/// Borrowed read-only projection of a node. +pub enum PaneRef<'a> { + Split { + axis: Axis, + children: &'a [PaneNode], + sizes: &'a [Option], + }, + Tabs { + panels: &'a [PanelId], + active_ix: usize, + }, + Tiles { + panels: &'a [TilePanel], + }, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct PaneNode { + id: NodeId, + kind: NodeKind, +} + +impl PaneNode { + pub(crate) fn new(id: NodeId, kind: NodeKind) -> Self { + Self { id, kind } + } + + pub fn id(&self) -> NodeId { + self.id + } + + pub fn kind(&self) -> PaneRef<'_> { + match &self.kind { + NodeKind::Split { + axis, + children, + sizes, + } => PaneRef::Split { + axis: *axis, + children, + sizes, + }, + NodeKind::Tabs { panels, active_ix } => PaneRef::Tabs { + panels, + active_ix: *active_ix, + }, + NodeKind::Tiles { panels } => PaneRef::Tiles { panels }, + } + } + + pub(crate) fn kind_mut(&mut self) -> &mut NodeKind { + &mut self.kind + } + + pub(crate) fn kind_ref(&self) -> &NodeKind { + &self.kind + } + + /// Depth-first pre-order walk over this node and its descendants. + pub fn walk(&self, f: &mut impl FnMut(&PaneNode)) { + f(self); + if let NodeKind::Split { children, .. } = &self.kind { + for child in children { + child.walk(f); + } + } + } +} diff --git a/vendor/gpui-base/src/dock/layout/normalize.rs b/vendor/gpui-base/src/dock/layout/normalize.rs new file mode 100644 index 00000000..a11eec29 --- /dev/null +++ b/vendor/gpui-base/src/dock/layout/normalize.rs @@ -0,0 +1,522 @@ +use gpui::Pixels; + +use super::node::{NodeKind, PaneNode}; +use super::tree::{PaneTree, RootKind}; + +/// Upper bound on `normalize` passes. Every pass that changes anything +/// strictly reduces node count or nesting depth, so real dock layouts +/// converge in a small handful of passes; this is a generous ceiling against +/// a future rule change that fights another rule rather than a bound tuned +/// to today's rule set. +const MAX_NORMALIZE_PASSES: u32 = 64; + +impl PaneTree { + /// Collapse the tree to canonical shape. + /// + /// One post-order pass repeated to a fixpoint. This is the only place a + /// container is removed for being empty, replacing the mutually recursive + /// `remove_self_if_empty` pair the old implementation used. It needs no + /// parent pointers and no deferred work, so the tree is self-consistent + /// the instant an edit returns. + /// + /// Rules, applied bottom up: + /// + /// 1. An empty `Tabs`, `Tiles`, or `Split` is removed from its parent. + /// 2. A `Split` with one child is replaced by that child. The child keeps + /// its own `NodeId` and inherits the split's slot size. + /// 3. A `Split` whose child is a `Split` of the same axis splices that + /// child's children into itself. + /// 4. `active_ix` is clamped. + /// 5. The root is preserved according to [`RootKind`]. + /// + /// Idempotent: `normalize(normalize(t)) == normalize(t)`. + pub fn normalize(&mut self) { + self.normalize_reporting(); + } + + /// [`Self::normalize`], reporting whether it changed anything. + /// + /// `edit` uses this instead of comparing whole trees: collapse is the only + /// thing that can change a tree after a mutation has already reported what + /// it did, so the two booleans together are exactly the answer, and no + /// snapshot of the previous tree has to be kept to reach it. + pub(crate) fn normalize_reporting(&mut self) -> bool { + let changed = self.run_normalize_passes().1; + debug_assert!(self.is_normalized(), "normalize did not reach a fixpoint"); + changed + } + + /// Run passes until nothing changes, or until [`MAX_NORMALIZE_PASSES`] is + /// exhausted. Returns the number of passes run, and whether any pass + /// changed the tree. + /// + /// Split out from [`Self::normalize`] so a `#[cfg(test)]` caller can pin + /// how many passes convergence actually takes, without widening the + /// public API with a pass count nobody outside tests needs. + fn run_normalize_passes(&mut self) -> (u32, bool) { + let mut passes = 0; + let mut changed = true; + let mut any_change = false; + // Bounded because every pass that changes anything strictly reduces + // node count or nesting depth. + while changed && passes < MAX_NORMALIZE_PASSES { + changed = false; + normalize_node(self.root_mut(), &mut changed); + collapse_root(self, &mut changed); + any_change |= changed; + passes += 1; + } + + // `debug_assert!` in `normalize` disappears in release builds, so a + // desktop build left silently short of the fixpoint would otherwise + // render a non-canonical layout with no trace of why. This keeps the + // failure observable without turning it into a user-facing panic: + // rendering a slightly non-canonical layout beats crashing the app. + if changed { + tracing::warn!( + passes, + "PaneTree::normalize exhausted {MAX_NORMALIZE_PASSES} passes without reaching \ + a fixpoint; the tree may still contain an empty container, a single-child \ + split, same-axis split nesting, or an unclamped Tabs active_ix" + ); + } + + (passes, any_change) + } + + /// Test-only hook so a test can pin how many passes convergence takes, + /// without exposing a pass count through the public `normalize` API. + #[cfg(test)] + pub(crate) fn normalize_pass_count_for_test(&mut self) -> u32 { + self.run_normalize_passes().0 + } + + /// Whether the tree satisfies every structural invariant. + pub(crate) fn is_normalized(&self) -> bool { + let mut ok = true; + let root_id = self.root().id(); + self.root().walk(&mut |node| match node.kind_ref() { + NodeKind::Split { + children, + sizes, + axis, + } => { + ok &= children.len() == sizes.len(); + // The root may legitimately be an empty or single-child split. + if node.id() != root_id { + ok &= children.len() > 1; + } + ok &= !children.iter().any(|child| { + matches!(child.kind_ref(), NodeKind::Split { axis: inner, .. } if inner == axis) + }); + } + NodeKind::Tabs { panels, active_ix } => { + ok &= panels.is_empty() || *active_ix < panels.len(); + if node.id() != root_id { + ok &= !panels.is_empty(); + } + } + NodeKind::Tiles { panels } => { + if node.id() != root_id { + ok &= !panels.is_empty(); + } + } + }); + ok + } +} + +fn normalize_node(node: &mut PaneNode, changed: &mut bool) { + match node.kind_mut() { + NodeKind::Tabs { panels, active_ix } => { + let clamped = (*active_ix).min(panels.len().saturating_sub(1)); + if *active_ix != clamped { + *active_ix = clamped; + *changed = true; + } + } + NodeKind::Tiles { .. } => {} + NodeKind::Split { + axis, + children, + sizes, + } => { + let axis = *axis; + + for child in children.iter_mut() { + normalize_node(child, changed); + } + + // Rule 1: drop empty children. + let mut ix = 0; + while ix < children.len() { + if is_empty_container(&children[ix]) { + children.remove(ix); + sizes.remove(ix); + *changed = true; + } else { + ix += 1; + } + } + + // Rule 2: a single-child split child is replaced by its child, + // which inherits the slot size the split occupied. The child is + // moved out rather than cloned — it can carry an arbitrarily deep + // subtree, and this runs on every edit. + for ix in 0..children.len() { + let is_single = matches!( + children[ix].kind_ref(), + NodeKind::Split { children: inner, .. } if inner.len() == 1 + ); + if !is_single { + continue; + } + let NodeKind::Split { + children: inner, .. + } = children[ix].kind_mut() + else { + continue; + }; + let replacement = inner.remove(0); + children[ix] = replacement; + *changed = true; + } + + // Rule 3: splice same-axis nesting. + let mut ix = 0; + while ix < children.len() { + let same_axis = matches!( + children[ix].kind_ref(), + NodeKind::Split { axis: inner, .. } if *inner == axis + ); + if !same_axis { + ix += 1; + continue; + } + + // Taken, not cloned: the spliced children move up a level + // rather than being copied and discarded. + let NodeKind::Split { + children: inner, + sizes: inner_sizes, + .. + } = children[ix].kind_mut() + else { + ix += 1; + continue; + }; + let inner = std::mem::take(inner); + let inner_sizes = std::mem::take(inner_sizes); + + let slot = sizes[ix]; + let inner_sizes = distribute_slot(slot, inner_sizes); + let count = inner.len(); + children.splice(ix..=ix, inner); + sizes.splice(ix..=ix, inner_sizes); + ix += count; + *changed = true; + } + } + } +} + +/// Spread an outer slot size across the inner sizes that replace it. +/// +/// When the outer slot is unconstrained the inner sizes pass through. When it +/// is fixed and every inner size is known, they are scaled to fill the slot; +/// otherwise the slot is dropped, matching how an unconstrained child behaves. +fn distribute_slot(slot: Option, inner: Vec>) -> Vec> { + let Some(slot) = slot else { return inner }; + // `Option` has no `Sum` impl; fold so one unknown size makes the + // whole total unknown. + let total = inner + .iter() + .try_fold(Pixels::ZERO, |acc, size| size.map(|size| acc + size)); + match total { + Some(total) if total > Pixels::ZERO => inner + .into_iter() + .map(|size| size.map(|size| size * (slot / total))) + .collect(), + _ => inner, + } +} + +fn is_empty_container(node: &PaneNode) -> bool { + match node.kind_ref() { + NodeKind::Split { children, .. } => children.is_empty(), + NodeKind::Tabs { panels, .. } => panels.is_empty(), + NodeKind::Tiles { panels } => panels.is_empty(), + } +} + +/// Rule 5. A `RootKind::Split` tree keeps a split root no matter what, so an +/// empty center still serializes as a `StackPanel`. A `RootKind::Any` tree +/// lets rule 2 collapse the root like any other node. +fn collapse_root(tree: &mut PaneTree, changed: &mut bool) { + if tree.root_kind() == RootKind::Split { + return; + } + + let replacement = match tree.root().kind_ref() { + NodeKind::Split { children, .. } if children.len() == 1 => Some(children[0].clone()), + _ => None, + }; + + if let Some(replacement) = replacement { + tree.replace_root(replacement); + *changed = true; + } +} + +#[cfg(test)] +mod tests { + use super::super::*; + use gpui::{Axis, Pixels, px}; + + fn panel(n: u64) -> PanelId { + PanelId::from_u64(n) + } + + #[test] + fn empty_tab_groups_are_dropped() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + tree.push_tabs_for_test(root, vec![]); + tree.push_tabs_for_test(root, vec![panel(1)]); + + tree.normalize(); + + // The empty tab group is dropped by rule 1, leaving the root split + // holding the one surviving child. + assert!( + matches!(tree.root().kind(), PaneRef::Split { children, .. } if children.len() == 1) + ); + assert_eq!(tree.panels().collect::>(), vec![panel(1)]); + } + + #[test] + fn a_single_child_split_is_replaced_by_its_child_keeping_the_child_id() { + let mut tree = PaneTree::new(RootKind::Any); + let outer = tree.set_root_split_for_test(Axis::Horizontal); + let inner = tree.push_split_for_test(outer, Axis::Vertical, Some(px(120.))); + let tabs = tree.push_tabs_for_test(inner, vec![panel(1)]); + + tree.normalize(); + + assert_eq!(tree.root().id(), tabs, "child keeps its own NodeId"); + assert!(tree.find_node(inner).is_none()); + } + + #[test] + fn a_collapsing_split_hands_its_slot_size_to_the_child() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + let inner = tree.push_split_for_test(root, Axis::Vertical, Some(px(300.))); + tree.push_tabs_for_test(inner, vec![panel(1)]); + tree.push_tabs_for_test(root, vec![panel(2)]); + + tree.normalize(); + + let PaneRef::Split { sizes, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!( + sizes[0], + Some(px(300.)), + "the child inherits the collapsed split's slot" + ); + } + + #[test] + fn same_axis_nesting_is_spliced_into_the_parent() { + let mut tree = PaneTree::new(RootKind::Split); + tree.set_root_axis_for_test(Axis::Horizontal); + let root = tree.root().id(); + tree.push_tabs_for_test(root, vec![panel(1)]); + let inner = tree.push_split_for_test(root, Axis::Horizontal, None); + tree.push_tabs_for_test(inner, vec![panel(2)]); + tree.push_tabs_for_test(inner, vec![panel(3)]); + + tree.normalize(); + + let PaneRef::Split { children, axis, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!(axis, Axis::Horizontal); + assert_eq!( + children.len(), + 3, + "the inner split's children are spliced in" + ); + assert_eq!( + tree.panels().collect::>(), + vec![panel(1), panel(2), panel(3)], + "order is preserved" + ); + } + + #[test] + fn active_index_is_clamped_to_the_panel_count() { + let mut tree = PaneTree::new(RootKind::Any); + let tabs = tree.set_root_tabs_for_test(vec![panel(1), panel(2)], 9); + + tree.normalize(); + + let PaneRef::Tabs { active_ix, .. } = tree.find_node(tabs).unwrap().kind() else { + panic!() + }; + assert_eq!(active_ix, 1); + } + + #[test] + fn a_split_root_survives_being_emptied() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + tree.push_tabs_for_test(root, vec![]); + + tree.normalize(); + + assert!( + matches!(tree.root().kind(), PaneRef::Split { children, .. } if children.is_empty()), + "the center must still serialize as a StackPanel when empty" + ); + } + + /// Rule 3 splices a same-axis child's slots into the parent, scaling them + /// to the slot they replace. When one inner slot is unconstrained there is + /// no total to scale against, so they pass through — and then the known + /// ones are absolute values that no longer relate to the space they landed + /// in. This pins what actually happens, so a future change to + /// `distribute_slot` has to decide about this case deliberately. + #[test] + fn a_same_axis_splice_with_one_unknown_inner_size_passes_them_through() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + let inner = tree.push_split_for_test(root, Axis::Horizontal, Some(px(400.))); + tree.push_sized_tabs_for_test(inner, vec![panel(1)], Some(px(100.))); + tree.push_sized_tabs_for_test(inner, vec![panel(2)], None); + + tree.normalize(); + + let PaneRef::Split { sizes, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!( + sizes, + &[Some(px(100.)), None], + "an unknown inner size leaves every sibling unscaled; the 400px \ + slot they replaced constrains nothing" + ); + } + + /// Dropping a container mid-row hands its space to nobody in the tree — + /// the surviving slots keep their absolute sizes and no longer sum to + /// anything in particular. The renderer's own resizable state is what + /// redistributes on the next layout pass. + #[test] + fn removing_a_middle_container_leaves_its_siblings_untouched() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + tree.push_sized_tabs_for_test(root, vec![panel(1)], Some(px(400.))); + tree.push_sized_tabs_for_test(root, vec![], Some(px(800.))); + tree.push_sized_tabs_for_test(root, vec![panel(3)], Some(px(400.))); + + tree.normalize(); + + let PaneRef::Split { + sizes, children, .. + } = tree.root().kind() + else { + panic!() + }; + assert_eq!(children.len(), 2); + assert_eq!( + sizes, + &[Some(px(400.)), Some(px(400.))], + "the survivors keep their own sizes; the 800px the empty group \ + held is not handed to either of them here" + ); + } + + #[test] + fn normalize_is_idempotent() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + let inner = tree.push_split_for_test(root, Axis::Horizontal, None); + tree.push_tabs_for_test(inner, vec![panel(1)]); + tree.push_tabs_for_test(inner, vec![]); + tree.push_tabs_for_test(root, vec![panel(2)]); + + tree.normalize(); + let once = tree.clone(); + tree.normalize(); + + assert_eq!(once, tree); + } + + #[test] + fn same_axis_splice_scales_inner_sizes_to_fill_the_outer_slot() { + // Every other test that reaches a same-axis splice pushes children + // with an unknown (`None`) size, so it only ever exercises + // `distribute_slot`'s pass-through branches. This is the one test + // that gives every sibling a known size, forcing the scaling arm. + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + let inner = tree.push_split_for_test(root, Axis::Horizontal, Some(px(400.))); + tree.push_sized_tabs_for_test(inner, vec![panel(1)], Some(px(50.))); + tree.push_sized_tabs_for_test(inner, vec![panel(2)], Some(px(150.))); + + tree.normalize(); + + let PaneRef::Split { sizes, .. } = tree.root().kind() else { + panic!() + }; + assert_eq!( + sizes, + &[Some(px(100.)), Some(px(300.))], + "sizes scale by the outer/inner ratio (400/200 = 2x), not by its reverse" + ); + let total: Pixels = sizes.iter().flatten().copied().sum(); + assert_eq!( + total, + px(400.), + "the scaled sizes sum back to the outer slot" + ); + } + + #[test] + fn normalize_converges_within_two_passes_on_an_adversarial_tree() { + // root(H) -> D(V) -> A(H) -> { empty, B(V) -> C(V) -> [leaf1, leaf2] } + // + // `RootKind::Any` lets rule 5 collapse the root itself, so this tree + // combines every rule at once: single-child splits nested five + // levels deep (root, D, A, B all start single-child), an empty + // container dropped mid-chain (under A), and same-axis nesting + // spliced twice (C into B, then the surviving B into D). Everything + // still has to bottom out at a fixpoint within 2 passes: one pass + // that resolves every rule bottom-up plus the root collapse, one + // pass that confirms nothing is left to change. + let mut tree = PaneTree::new(RootKind::Any); + let root = tree.root().id(); + let d = tree.push_split_for_test(root, Axis::Vertical, None); + let a = tree.push_split_for_test(d, Axis::Horizontal, None); + tree.push_tabs_for_test(a, vec![]); + let b = tree.push_split_for_test(a, Axis::Vertical, None); + let c = tree.push_split_for_test(b, Axis::Vertical, None); + tree.push_tabs_for_test(c, vec![panel(1)]); + tree.push_tabs_for_test(c, vec![panel(2)]); + + let passes = tree.normalize_pass_count_for_test(); + + assert!( + passes <= 2, + "expected the fixpoint within 2 passes, took {passes}" + ); + assert!(tree.is_normalized()); + assert_eq!( + tree.panels().collect::>(), + vec![panel(1), panel(2)], + "every panel survives the collapse, in order" + ); + } +} diff --git a/vendor/gpui-base/src/dock/layout/tree.rs b/vendor/gpui-base/src/dock/layout/tree.rs new file mode 100644 index 00000000..8b45a40e --- /dev/null +++ b/vendor/gpui-base/src/dock/layout/tree.rs @@ -0,0 +1,334 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use smallvec::SmallVec; + +use super::node::{NodeId, NodeKind, PaneNode, PaneRef, PanelId, TilePanel}; + +/// Whether the root of this tree is pinned to a split. +/// +/// The center of a `DockArea` must serialize as a `StackPanel` even when +/// empty, which `RootKind::Split` guarantees. A dock's root is unconstrained. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RootKind { + Split, + Any, +} + +/// Path from the root as a sequence of child indices. +pub(crate) type NodePath = SmallVec<[usize; 8]>; + +/// Source of every [`NodeId`], shared by every tree in the process. +/// +/// A per-tree counter would restart at the same value in each tree, and a +/// `DockArea` owns four of them (the center plus one per dock). Its entity +/// cache is keyed by `NodeId` alone, so two trees minting the same id would +/// hand two different containers the same cache slot. Allocating globally is +/// what makes `NodeId` the stable *container* identity its documentation +/// claims, rather than an identity that is only unique within one tree. +static NEXT_NODE_ID: AtomicU64 = AtomicU64::new(0); + +fn next_node_id() -> NodeId { + NodeId::from_u64(NEXT_NODE_ID.fetch_add(1, Ordering::Relaxed)) +} + +/// One region's layout, as pure data. +/// +/// A `DockArea` owns one of these per region — the center plus each dock it +/// has. Containers are addressed by [`NodeId`] and panels by [`PanelId`]; +/// no entity handle lives in here, so a tree can be built, edited, compared +/// and serialized with no `App` in sight. +/// +/// Every edit method normalizes before returning and reports what it did as +/// an [`EditResult`](super::EditResult), so there is no window in which a +/// caller can observe a tree with an empty container, a one-child split, or an +/// out-of-range active index. +#[derive(Clone, PartialEq, Debug)] +pub struct PaneTree { + root: PaneNode, + root_kind: RootKind, +} + +impl PaneTree { + pub fn new(root_kind: RootKind) -> Self { + let root = PaneNode::new( + next_node_id(), + NodeKind::Split { + axis: gpui::Axis::Horizontal, + children: Vec::new(), + sizes: Vec::new(), + }, + ); + Self { root, root_kind } + } + + pub fn root(&self) -> &PaneNode { + &self.root + } + + /// Mutable access to the root, for normalization's post-order pass. + pub(crate) fn root_mut(&mut self) -> &mut PaneNode { + &mut self.root + } + + pub fn root_kind(&self) -> RootKind { + self.root_kind + } + + pub(crate) fn allocate_node_id(&mut self) -> NodeId { + next_node_id() + } + + /// Every container id in the tree, in pre-order. + pub fn node_ids(&self) -> Vec { + let mut ids = Vec::new(); + self.root.walk(&mut |node| ids.push(node.id())); + ids + } + + /// Every panel in the tree, in pre-order. + pub fn panels(&self) -> impl Iterator { + let mut found = Vec::new(); + self.root.walk(&mut |node| match node.kind() { + PaneRef::Tabs { panels, .. } => found.extend_from_slice(panels), + PaneRef::Tiles { panels } => found.extend(panels.iter().map(TilePanel::panel)), + PaneRef::Split { .. } => {} + }); + found.into_iter() + } + + pub fn find_node(&self, id: NodeId) -> Option<&PaneNode> { + self.path_of_node(id).map(|path| self.node_at(&path)) + } + + /// The tab or tiles node holding `panel`. + pub fn find_panel_node(&self, panel: PanelId) -> Option { + let mut found = None; + self.root.walk(&mut |node| { + let holds = match node.kind() { + PaneRef::Tabs { panels, .. } => panels.contains(&panel), + PaneRef::Tiles { panels } => panels.iter().any(|p| p.panel() == panel), + PaneRef::Split { .. } => false, + }; + if holds { + found = Some(node.id()); + } + }); + found + } + + pub(crate) fn path_of_node(&self, id: NodeId) -> Option { + fn search(node: &PaneNode, id: NodeId, path: &mut NodePath) -> bool { + if node.id() == id { + return true; + } + if let NodeKind::Split { children, .. } = node.kind_ref() { + for (ix, child) in children.iter().enumerate() { + path.push(ix); + if search(child, id, path) { + return true; + } + path.pop(); + } + } + false + } + + let mut path = NodePath::new(); + search(&self.root, id, &mut path).then_some(path) + } + + pub(crate) fn node_at(&self, path: &NodePath) -> &PaneNode { + let mut node = &self.root; + for ix in path { + let NodeKind::Split { children, .. } = node.kind_ref() else { + unreachable!("path traverses a non-split node"); + }; + node = &children[*ix]; + } + node + } + + pub(crate) fn node_at_mut(&mut self, path: &NodePath) -> &mut PaneNode { + let mut node = &mut self.root; + for ix in path { + let NodeKind::Split { children, .. } = node.kind_mut() else { + unreachable!("path traverses a non-split node"); + }; + node = &mut children[*ix]; + } + node + } + + /// Replace the whole tree with `node`, keeping `node`'s own id and + /// `root_kind` unchanged. Used by normalization's root-collapse rule. + pub(crate) fn replace_root(&mut self, node: PaneNode) { + self.root = node; + } +} + +#[cfg(test)] +use gpui::{Axis, Pixels}; + +#[cfg(test)] +impl PaneTree { + pub(crate) fn push_tabs_for_test(&mut self, parent: NodeId, panels: Vec) -> NodeId { + let id = self.allocate_node_id(); + let path = self.path_of_node(parent).expect("parent must exist"); + let NodeKind::Split { + children, sizes, .. + } = self.node_at_mut(&path).kind_mut() + else { + panic!("parent must be a split"); + }; + children.push(PaneNode::new( + id, + NodeKind::Tabs { + panels, + active_ix: 0, + }, + )); + sizes.push(None); + id + } + + /// Like [`Self::push_tabs_for_test`], but with a concrete slot size + /// instead of always pushing `None`. Needed to exercise the scaling + /// arithmetic in `normalize`'s same-axis splice rule, which only runs + /// when every sibling size is known. + pub(crate) fn push_sized_tabs_for_test( + &mut self, + parent: NodeId, + panels: Vec, + size: Option, + ) -> NodeId { + let id = self.allocate_node_id(); + let path = self.path_of_node(parent).expect("parent must exist"); + let NodeKind::Split { + children, sizes, .. + } = self.node_at_mut(&path).kind_mut() + else { + panic!("parent must be a split"); + }; + children.push(PaneNode::new( + id, + NodeKind::Tabs { + panels, + active_ix: 0, + }, + )); + sizes.push(size); + id + } + + pub(crate) fn set_root_tiles_for_test(&mut self, panels: Vec) -> NodeId { + let id = self.allocate_node_id(); + self.root = PaneNode::new(id, NodeKind::Tiles { panels }); + id + } + + pub(crate) fn set_root_split_for_test(&mut self, axis: Axis) -> NodeId { + let id = self.allocate_node_id(); + self.root = PaneNode::new( + id, + NodeKind::Split { + axis, + children: Vec::new(), + sizes: Vec::new(), + }, + ); + id + } + + pub(crate) fn set_root_axis_for_test(&mut self, new_axis: Axis) { + if let NodeKind::Split { axis, .. } = self.root.kind_mut() { + *axis = new_axis; + } + } + + pub(crate) fn set_root_tabs_for_test( + &mut self, + panels: Vec, + active_ix: usize, + ) -> NodeId { + let id = self.allocate_node_id(); + self.root = PaneNode::new(id, NodeKind::Tabs { panels, active_ix }); + id + } + + pub(crate) fn push_split_for_test( + &mut self, + parent: NodeId, + axis: Axis, + size: Option, + ) -> NodeId { + let id = self.allocate_node_id(); + let path = self.path_of_node(parent).expect("parent must exist"); + let NodeKind::Split { + children, sizes, .. + } = self.node_at_mut(&path).kind_mut() + else { + panic!("parent must be a split"); + }; + children.push(PaneNode::new( + id, + NodeKind::Split { + axis, + children: Vec::new(), + sizes: Vec::new(), + }, + )); + sizes.push(size); + id + } +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, px, size}; + + use super::*; + + fn panel_id(n: u64) -> PanelId { + PanelId::from_u64(n) + } + + #[test] + fn a_fresh_split_root_tree_has_no_panels() { + let tree = PaneTree::new(RootKind::Split); + assert!( + matches!(tree.root().kind(), PaneRef::Split { children, .. } if children.is_empty()) + ); + assert_eq!(tree.panels().count(), 0); + } + + #[test] + fn node_ids_are_unique_and_resolvable() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + let tabs = tree.push_tabs_for_test(root, vec![panel_id(1)]); + assert_ne!(tabs, tree.root().id()); + assert!(tree.find_node(tabs).is_some()); + assert_eq!(tree.find_panel_node(panel_id(1)), Some(tabs)); + } + + #[test] + fn tile_panels_carry_bounds_and_z_index() { + let mut tree = PaneTree::new(RootKind::Any); + let tiles = tree.set_root_tiles_for_test(vec![ + TilePanel::new( + panel_id(7), + Bounds { + origin: point(px(10.), px(20.)), + size: size(px(100.), px(50.)), + }, + ) + .with_z_index(3), + ]); + let PaneRef::Tiles { panels } = tree.find_node(tiles).unwrap().kind() else { + panic!("expected tiles root"); + }; + assert_eq!(panels[0].panel(), panel_id(7)); + assert_eq!(panels[0].z_index(), 3); + assert_eq!(panels[0].bounds().size.width, px(100.)); + } +} diff --git a/vendor/gpui-base/src/dock/mod.rs b/vendor/gpui-base/src/dock/mod.rs new file mode 100644 index 00000000..a83ee80c --- /dev/null +++ b/vendor/gpui-base/src/dock/mod.rs @@ -0,0 +1,218 @@ +//! Dockable layout: splits, tab groups, and tiles canvases that a host can +//! rearrange, persist, and restore — with no appearance of its own. +//! +//! # The tree is the source of truth +//! +//! Each region — the center, plus an optional left, right, and bottom dock — +//! is one [`PaneTree`]. A tree is pure data. Containers are addressed by +//! [`NodeId`], panels by [`PanelId`], and no GPUI entity handle is stored +//! anywhere in it, which is what lets the layout algebra be exercised without +//! an `App` and lets a whole layout be compared, normalized, and serialized as +//! an ordinary value. A container is a `Split`, a `Tabs`, or a `Tiles`; there +//! is no leaf variant, so a panel can only ever live inside a `Tabs` or a +//! `Tiles`. +//! +//! Both ids are *stable*, and the rest of the design leans on it: +//! +//! - A [`NodeId`] survives every edit and every normalization rule. A +//! container still present after a drag carries the same id it had before, +//! which is what keeps a reconcile from tearing down entities the drag never +//! touched. +//! - A [`PanelId`] is the panel entity's `EntityId`, so it identifies the +//! panel for as long as the entity lives, across any number of moves between +//! groups and regions. +//! +//! Every mutation goes through the tree — [`PaneTree::insert_panel`], +//! [`remove_panel`](PaneTree::remove_panel), +//! [`move_panel`](PaneTree::move_panel), [`split`](PaneTree::split), +//! [`set_active`](PaneTree::set_active), and the rest — and each normalizes +//! before returning, so the tree is self-consistent the instant an edit +//! returns. Every edit reports what it did as an [`EditResult`]. +//! +//! # The area reconciles the tree into entities +//! +//! [`DockArea`] owns the trees and a cache of container entities keyed by +//! `NodeId` ([`TabGroup`] for a `Tabs` node, [`TilesState`] for a `Tiles` +//! one), plus the panel handles keyed by `PanelId`. After any edit that +//! reports a change it walks the tree, creates entities for ids the cache does +//! not have, drops entries for ids that are gone — telling those panels +//! [`Panel::on_removed`] — pushes sizes and active indices into the survivors, +//! and emits [`DockEvent::LayoutChanged`]. Nothing else turns a tree edit into +//! live entities. +//! +//! Because node ids are stable, a steady-state pass creates and drops nothing. +//! +//! A layout is described the same entity-free way: [`DockLayout`] builds a +//! tree, and [`DockArea::set_center`] / [`DockArea::set_dock`] install it. +//! [`DockArea::dump`] and [`DockArea::load`] round-trip a whole area through +//! [`DockAreaState`], rebuilding panels through the [`PanelRegistry`]. +//! +//! # The renderer seam +//! +//! Base supplies behavior; the host supplies appearance. Nothing in this +//! module paints a color, a border, or a size. Three traits carry the +//! appearance in: +//! +//! - [`DockAreaRenderer`] — the area frame, each split's frame and the divider +//! between its slots, one dock's chrome, and the stand-in for a panel this +//! build cannot construct. +//! - [`TabGroupRenderer`] — the tab bar, how the displayed panel is placed, +//! and the drop indicator. +//! - [`TilesRenderer`] — a tiles canvas, its tile frames, and their drag bars. +//! +//! A renderer never sees a drag event or a mouse position. Base attaches the +//! drag sources, drop hit-testing, focus, and keyboard handling to the very +//! elements the renderer returns, and hands it resolved state through +//! [`DockContext`], [`TabGroupContext`], and [`TileContext`] — each of which +//! also carries the callbacks (`toggle`, `select_tab`, `close`, `resize_to`) +//! that the renderer invokes rather than reimplementing. +//! +//! An area built without a renderer still docks, drags, resizes and persists. +//! It simply draws nothing but the panels themselves. +//! +//! [`Panel`] splits at the same seam: this trait covers behavior, and a +//! presentation layer — `gpui_component::dock::Panel` — extends it with +//! titles, toolbars, and menus. A panel type implements both. +//! +//! Every hook is optional in the same way: a renderer that declines one gets +//! base's own minimum for it. [`DockAreaRenderer::render_split_handle`] is the +//! clearest case — return `None` and the divider falls back to a one-pixel +//! line colored from `Theme::resizable`, so a skin with no opinion about +//! dividers implements nothing, while one that has an opinion replaces the +//! paint without touching the hit area, the cursor, or the drag. +//! +//! # Why the layout is data +//! +//! The usual way to build a dock is to make each container a live view that +//! holds its children, so the widget tree *is* the layout. That is what this +//! module replaced, and the three costs it carries are the reason: +//! +//! - **Emptiness has to propagate.** When the last panel leaves a tab group, +//! the group must remove itself from its parent, which may empty the parent +//! in turn. With containers as views this is mutual recursion between two +//! types, reaching upward through parent handles — and those handles have to +//! be installed after construction, which in GPUI means a deferred pass. +//! There is a window in which the tree disagrees with itself. +//! - **Structure and identity are the same thing.** Rearranging the widget +//! tree means creating and dropping views, so a drag can reset the state of +//! containers it never touched. +//! - **Nothing is testable without a window.** Asserting that a split collapses +//! correctly requires an `App`, an entity, and a frame. +//! +//! Here the tree is a value and the entities are its projection. Collapse is +//! [`PaneTree::normalize`]: one post-order pass repeated to a fixpoint, no +//! parent pointers, no deferred work, and idempotent by construction. Identity +//! is a [`NodeId`] that survives every edit and every normalization rule, so +//! reconciliation is a diff rather than a rebuild. And the whole layout algebra +//! runs as plain `#[test]`. +//! +//! What this buys, stated as properties rather than adjectives: a layout can be +//! compared, cloned and serialized as an ordinary value; a steady-state +//! reconcile creates and drops nothing, so a drag leaves untouched panels +//! untouched; and `normalize(normalize(t)) == normalize(t)` holds for every +//! tree, which is what makes the persisted format canonical. +//! +//! # Where this sits among docking libraries +//! +//! Editors tend to build the layout engine into the application — Zed's +//! `PaneGroup` and VS Code's workbench are not reusable outside their hosts. +//! Standalone libraries split the engine from the view to varying degrees: +//! `golden-layout` owns its DOM outright, `FlexLayout` keeps a JSON model +//! beside a React renderer, and `dockview` goes furthest, running a +//! framework-agnostic engine behind thin adapters. All of them still paint +//! their own chrome and expose CSS as the way to change it. +//! +//! This module takes the same separation one step further: the engine paints +//! nothing at all. A renderer returns elements and base attaches the drag +//! sources, drop hit-testing, focus and keyboard handling to the elements it +//! got back, so appearance is not a set of overrides on top of a default look — +//! there is no default look. `crates/ui/src/dock` and +//! `crates/base/examples/showcase/components/dock.rs` are two unrelated +//! appearances over one behavior. +//! +//! The naming follows the same neighborhood where it can. A tab group here is +//! what Zed calls a `Pane` and `dockview` calls a group; a [`Dock`] is Zed's +//! dock; a [`Panel`] is the dockable content, as in `dockview` — note that +//! VS Code uses "panel" for the bottom *region* instead, and `rc-dock` uses it +//! for the tab container. +//! +//! # A minimal area +//! +//! ```ignore +//! use std::rc::Rc; +//! use gpui::{px, Context, Window}; +//! use gpui_base::dock::{DockArea, DockLayout, DockPlacement}; +//! +//! let area = cx.new(|cx| { +//! DockArea::new("workspace", Some(1), window, cx).with_renderer(Rc::new(MySkin)) +//! }); +//! +//! area.update(cx, |area, cx| { +//! area.set_center( +//! DockLayout::h_split() +//! .child(DockLayout::tabs().panel(files.clone()), Some(px(240.))) +//! .child(DockLayout::tabs().panel(editor.clone()), None), +//! window, +//! cx, +//! ); +//! area.set_dock( +//! DockPlacement::Bottom, +//! DockLayout::tabs().panel(terminal.clone()), +//! window, +//! cx, +//! ); +//! }); +//! ``` +//! +//! `crates/base/examples/showcase/components/dock.rs` is that program in full, +//! renderers included — run it with `cargo run -p gpui-base dock`. +//! `crates/ui/src/dock` is the production skin over the same seam. + +mod active; +mod dock_area; +mod dock_placement; +mod drag; +pub mod layout; +mod panel; +mod registry; +mod state; +mod state_convert; +mod tab_group; +#[cfg(test)] +pub(crate) mod test_support; +mod tiles_geometry; +mod tiles_state; + +pub use dock_area::{DockArea, DockAreaRenderer, DockContext, DockEvent}; +pub use dock_placement::{Dock, DockSizing}; +pub use drag::{AnyDrag, DragPanel, DropIndicator, DropPlaceholderBounds, DropTarget}; +// `split_placement_at` stays internal for the same reason: where a drop lands +// is base's decision, and a renderer is told the result through +// `TabGroupContext::drop_indicator`. +pub use layout::{ + DockLayout, EditResult, InsertTarget, NodeId, PaneNode, PaneRef, PaneTree, PanelId, RootKind, + TilePanel, +}; +pub use panel::{Panel, PanelEvent, PanelView}; +pub use registry::{PanelBuildContext, PanelRegistry, register_panel}; +pub use state::{DockAreaState, DockPlacement, DockState, PanelInfo, PanelState, TileMeta}; +/// Both halves of the persistence seam. `PaneTree::to_state` reads panel +/// properties through `PanelSource`; `PaneTree::from_state` turns persisted +/// leaves back into panels through `PanelBuilder`. Exporting only the first +/// left `from_state` public but uncallable, since no caller outside this crate +/// could name the trait its parameter requires. +pub use state_convert::{PanelBuilder, PanelSource}; +pub use tab_group::{ + TabGroup, TabGroupConstraints, TabGroupContext, TabGroupEvent, TabGroupRenderer, +}; +/// What a skin actually needs off the tiles geometry: the two sizes it has to +/// draw to, and which edge a resize is pulling. +pub use tiles_geometry::{DRAG_BAR_HEIGHT, HANDLE_SIZE, ResizeSide}; +// The arithmetic itself is deliberately not re-exported. Base resolves every +// bound before a renderer sees it — a skin is handed finished `Bounds`, never +// asked to snap anything — so `magnetic_snap`, `snap_edge`, `round_to_grid`, +// `round_point_to_grid`, `compute_resized_bounds`, `apply_boundary_constraints` +// and `content_size` had no caller outside this crate and no purpose there. +// `ResizeDrag` and `TileChange` are `TilesState`'s own fields, and +// `MINIMUM_SIZE` is a constraint base applies on the skin's behalf. +pub use tiles_state::{TileContext, TilesEvent, TilesRenderer, TilesState}; diff --git a/vendor/gpui-base/src/dock/panel.rs b/vendor/gpui-base/src/dock/panel.rs new file mode 100644 index 00000000..e4c1b781 --- /dev/null +++ b/vendor/gpui-base/src/dock/panel.rs @@ -0,0 +1,310 @@ +use std::{any::Any, collections::HashMap, sync::Arc}; + +use gpui::{ + AnyView, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, WeakEntity, Window, +}; + +use super::layout::PanelId; +use super::state::PanelState; +use super::state_convert::PanelSource; +use super::tab_group::TabGroup; + +pub enum PanelEvent { + ZoomIn, + ZoomOut, + LayoutChanged, +} + +/// Behavior a dockable panel provides. Presentation lives in the layer above: +/// `gpui_component::dock::Panel` extends this with titles, toolbars, and menus. +#[allow(unused_variables)] +pub trait Panel: EventEmitter + Render + Focusable { + /// Identifies the panel in persisted layouts. Once chosen, never change it. + fn panel_name(&self) -> &'static str; + + /// Whether the panel is drawn at all. A hidden panel keeps its place in + /// the layout tree and its tab, and reappears when this turns back on; + /// a container whose panels are all hidden gives up its slot. + fn visible(&self, cx: &App) -> bool { + true + } + + /// Whether the panel may be closed. A container can still refuse — the + /// last group of a dock does — so this is permission, not a guarantee. + fn closable(&self, cx: &App) -> bool { + true + } + + /// Whether the panel can zoom at all. Where the zoom control appears is a + /// presentation decision and belongs to the layer above. + fn zoomable(&self, cx: &App) -> bool { + true + } + + /// Called with the frame-end net state when this panel becomes, or stops + /// being, the displayed tab of its group: exactly one notification per + /// edge, delivered on the next tick after the change — never same-value + /// repeats nor false-then-true flips within one frame. + /// + /// A panel removed from its group is NOT told `false`; [`Panel::on_removed`] + /// is the deactivation signal. A hidden panel occupying the active slot + /// still receives `true` even though rendering falls back to the first + /// visible panel. + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) {} + + /// Called when the group displaying this panel zooms in or out. + /// + /// Only the panel that is currently displayed is told: a group has one + /// zoom state, and it is the visible panel that fills the dock. Panels + /// sharing the group's other tabs hear nothing, and a panel that is not + /// displayed when the zoom changes is never told about it retroactively. + fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context) {} + + /// Called when the panel joins a tab group, with a weak handle on it. + /// + /// Delivered before any `set_active`, so a panel can hold the handle and + /// act on the first activation. A panel moved between groups is told + /// again, with the new group; it is not told it was removed in between. + fn on_added_to( + &mut self, + group: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + } + + /// Called when the panel leaves the dock for good — closed, or displaced + /// by a wholesale `set_center`, `set_dock`, `remove_dock` or `load`. + /// + /// This is also the deactivation signal: a panel that was displayed is not + /// told `set_active(false)` on its way out. A panel dragged from one group + /// to another never leaves the dock, so it never hears this. + fn on_removed(&mut self, window: &mut Window, cx: &mut Context) {} + + /// The panel's own persisted state, written into the layout under its + /// [`panel_name`](Panel::panel_name) and handed back to the + /// [`PanelRegistry`](crate::dock::PanelRegistry) builder on the next load. + /// + /// The default records the name and nothing else, which is enough for a + /// panel whose builder can reconstruct it from the name alone. + fn dump(&self, cx: &App) -> PanelState { + PanelState::new(self.panel_name()) + } +} + +/// Object-safe counterpart of [`Panel`], used to hold heterogeneous panel +/// entities behind a single handle. +#[allow(unused_variables)] +pub trait PanelView: 'static + Send + Sync { + fn panel_name(&self, cx: &App) -> &'static str; + fn panel_id(&self, cx: &App) -> PanelId; + fn closable(&self, cx: &App) -> bool; + fn zoomable(&self, cx: &App) -> bool; + fn visible(&self, cx: &App) -> bool; + fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); + fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App); + fn on_added_to(&self, group: WeakEntity, window: &mut Window, cx: &mut App); + fn on_removed(&self, window: &mut Window, cx: &mut App); + fn view(&self) -> AnyView; + fn focus_handle(&self, cx: &App) -> FocusHandle; + fn dump(&self, cx: &App) -> PanelState; + + /// The concrete value behind this handle. + /// + /// A layer above base cannot recover its own richer panel trait object + /// from this one: `Arc` coerces *to* + /// `Arc`, and Rust has no coercion back — a sub-trait + /// object cannot be recovered from a super-trait object. The registry + /// documents the same wall one layer in. + /// + /// So a layer that needs more off a panel than this trait carries defines + /// a *concrete* handle type, implements this trait for it by delegation, + /// hands base that, and recovers it here with + /// `panel.as_any().downcast_ref::()`. That downcast works + /// precisely because the type it names is concrete. + /// + /// The blanket implementation for `Entity` answers with the entity, so + /// a caller that knows the panel type can recover `Entity` instead. + /// Which of the two a given handle holds is not fixed: a failed + /// `downcast_ref` means only that this handle was built by someone else, + /// and every caller needs a path for that. + fn as_any(&self) -> &dyn Any; +} + +impl PanelView for Entity { + fn panel_name(&self, cx: &App) -> &'static str { + self.read(cx).panel_name() + } + + fn panel_id(&self, _: &App) -> PanelId { + PanelId::from(self.entity_id()) + } + + fn closable(&self, cx: &App) -> bool { + self.read(cx).closable(cx) + } + + fn zoomable(&self, cx: &App) -> bool { + self.read(cx).zoomable(cx) + } + + fn visible(&self, cx: &App) -> bool { + self.read(cx).visible(cx) + } + + fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_active(active, window, cx); + }) + } + + fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_zoomed(zoomed, window, cx); + }) + } + + fn on_added_to(&self, group: WeakEntity, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.on_added_to(group, window, cx)); + } + + fn on_removed(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.on_removed(window, cx)); + } + + fn view(&self) -> AnyView { + self.clone().into() + } + + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.read(cx).focus_handle(cx) + } + + fn dump(&self, cx: &App) -> PanelState { + self.read(cx).dump(cx) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +impl From<&dyn PanelView> for AnyView { + fn from(handle: &dyn PanelView) -> Self { + handle.view() + } +} + +impl From<&dyn PanelView> for Entity { + fn from(value: &dyn PanelView) -> Self { + value.view().downcast::().unwrap() + } +} + +impl PartialEq for dyn PanelView { + fn eq(&self, other: &Self) -> bool { + self.view() == other.view() + } +} + +/// Reads panel properties out of the live entity map that `DockArea` keeps. +/// +/// This is the `PanelSource` implementation `PaneTree::to_state` runs +/// against when `DockArea::dump` writes a live layout out. +pub(crate) struct LivePanels<'a> { + panels: &'a HashMap>, + cx: &'a App, +} + +impl<'a> LivePanels<'a> { + pub(crate) fn new(panels: &'a HashMap>, cx: &'a App) -> Self { + Self { panels, cx } + } +} + +impl PanelSource for LivePanels<'_> { + fn panel_name(&self, id: PanelId) -> &'static str { + self.panels + .get(&id) + .map(|panel| panel.panel_name(self.cx)) + .unwrap_or("") + } + + fn is_visible(&self, id: PanelId) -> bool { + self.panels + .get(&id) + .is_some_and(|panel| panel.visible(self.cx)) + } + + fn dump(&self, id: PanelId) -> PanelState { + self.panels + .get(&id) + .map(|panel| panel.dump(self.cx)) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::super::state::PanelInfo; + use super::*; + use gpui::{ + AppContext as _, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render, + TestAppContext, Window, + }; + + struct Probe { + focus_handle: FocusHandle, + visible: bool, + } + + impl Panel for Probe { + fn panel_name(&self) -> &'static str { + "Probe" + } + + fn visible(&self, _: &App) -> bool { + self.visible + } + } + + impl EventEmitter for Probe {} + impl Focusable for Probe { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } + } + + #[gpui::test] + fn a_panel_entity_answers_through_the_object_safe_view(cx: &mut TestAppContext) { + let panel = cx.new(|cx| Probe { + focus_handle: cx.focus_handle(), + visible: false, + }); + let view: Arc = Arc::new(panel.clone()); + + cx.read(|cx| { + assert_eq!(view.panel_name(cx), "Probe"); + assert_eq!(view.visible(cx), false); + assert_eq!(view.panel_id(cx), PanelId::from(panel.entity_id())); + }); + } + + #[gpui::test] + fn the_default_dump_records_only_the_panel_name(cx: &mut TestAppContext) { + let panel = cx.new(|cx| Probe { + focus_handle: cx.focus_handle(), + visible: true, + }); + let state = cx.read(|cx| panel.read(cx).dump(cx)); + + assert_eq!(state.panel_name, "Probe"); + assert!(state.children.is_empty()); + assert_eq!(state.info, PanelInfo::panel(serde_json::Value::Null)); + } +} diff --git a/vendor/gpui-base/src/dock/registry.rs b/vendor/gpui-base/src/dock/registry.rs new file mode 100644 index 00000000..538eb340 --- /dev/null +++ b/vendor/gpui-base/src/dock/registry.rs @@ -0,0 +1,119 @@ +use std::{collections::HashMap, sync::Arc}; + +use gpui::{App, Global, WeakEntity, Window}; + +use super::{DockArea, PanelInfo, PanelState, PanelView}; + +/// Everything a panel builder needs to reconstruct a panel from persisted data. +pub struct PanelBuildContext<'a> { + dock_area: WeakEntity, + state: &'a PanelState, + info: &'a PanelInfo, +} + +impl<'a> PanelBuildContext<'a> { + pub fn new( + dock_area: WeakEntity, + state: &'a PanelState, + info: &'a PanelInfo, + ) -> Self { + Self { + dock_area, + state, + info, + } + } + + pub fn dock_area(&self) -> WeakEntity { + self.dock_area.clone() + } + + pub fn state(&self) -> &PanelState { + self.state + } + + pub fn info(&self) -> &PanelInfo { + self.info + } +} + +/// Global registry of panel builders, keyed by panel name, used to reconstruct +/// a panel view from persisted [`PanelState`]/[`PanelInfo`] data. +/// +/// A builder returns an [`Arc`](PanelView), not a bare +/// `AnyView`. An earlier revision of this module returned `AnyView` so that a +/// builder would not have to depend on the panel traits, on the reasoning +/// that "a caller that needs a richer handle downcasts or wraps the +/// `AnyView` itself". [`DockArea::load`](super::DockArea::load) is that +/// caller, and it cannot: downcasting needs the concrete type, which the +/// registry is precisely the mechanism for not knowing. Without a +/// `PanelView` a restored panel could not be asked for its +/// [`dump`](PanelView::dump), so every registered panel's own persisted +/// payload would be dropped on the first save after a load. +/// +/// Building a panel that was never registered returns `None` rather than a +/// placeholder view: rendering an "invalid panel" placeholder is presentation +/// behavior that belongs above this seam, not inside `gpui-base`. `DockArea` +/// substitutes a draw-nothing placeholder that carries the original +/// `PanelState` forward, so an unknown panel survives a round trip. +pub struct PanelRegistry { + items: HashMap< + String, + Arc Arc>, + >, +} + +impl PanelRegistry { + /// Initialize the panel registry. + pub(crate) fn init(cx: &mut App) { + if cx.try_global::().is_none() { + cx.set_global(PanelRegistry::new()); + } + } + + pub fn new() -> Self { + Self { + items: HashMap::new(), + } + } + + pub fn global(cx: &App) -> &Self { + cx.global::() + } + + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } + + /// Build a panel by name. + /// + /// Returns `None` if no builder is registered for `panel_name`. + pub fn build_panel( + panel_name: &str, + context: PanelBuildContext, + window: &mut Window, + cx: &mut App, + ) -> Option> { + let build = Self::global(cx).items.get(panel_name).cloned()?; + Some(build(context, window, cx)) + } +} + +impl Default for PanelRegistry { + fn default() -> Self { + Self::new() + } +} + +impl Global for PanelRegistry {} + +/// Register the Panel init by panel_name to global registry. +pub fn register_panel(cx: &mut App, panel_name: &str, deserialize: F) +where + F: Fn(PanelBuildContext, &mut Window, &mut App) -> Arc + 'static, +{ + PanelRegistry::init(cx); + PanelRegistry::global_mut(cx) + .items + .insert(panel_name.to_string(), Arc::new(deserialize)); +} diff --git a/vendor/gpui-base/src/dock/state.rs b/vendor/gpui-base/src/dock/state.rs new file mode 100644 index 00000000..2525e3db --- /dev/null +++ b/vendor/gpui-base/src/dock/state.rs @@ -0,0 +1,297 @@ +use gpui::{Axis, Bounds, Pixels, point, px, size}; +use serde::{Deserialize, Serialize}; + +/// Used to serialize and deserialize the DockArea. +/// +/// This mirrors a persisted, on-disk schema shipped to end users. Its fields +/// stay `pub` rather than following the seam's builder/reader convention — +/// see "Public Data Types Across the Seam" in `docs/ARCHITECTURE.md`. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +pub struct DockAreaState { + /// The version is used to mark this persisted state is compatible with the current version + /// For example, some times we many totally changed the structure of the Panel, + /// then we can compare the version to decide whether we can use the state or ignore. + #[serde(default)] + pub version: Option, + pub center: PanelState, + #[serde(skip_serializing_if = "Option::is_none")] + pub left_dock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_dock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bottom_dock: Option, +} + +/// Used to serialize and deserialize the Dock. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DockState { + panel: PanelState, + placement: DockPlacement, + size: Pixels, + open: bool, +} + +impl DockState { + pub fn new(panel: PanelState, placement: DockPlacement, size: Pixels, open: bool) -> Self { + Self { + panel, + placement, + size, + open, + } + } + + pub fn panel(&self) -> &PanelState { + &self.panel + } + + pub fn placement(&self) -> DockPlacement { + self.placement + } + + pub fn size(&self) -> Pixels { + self.size + } + + pub fn open(&self) -> bool { + self.open + } +} + +/// Used to serialize and deserialize the DockerItem. +/// +/// This mirrors a persisted, on-disk schema shipped to end users. Its fields +/// stay `pub` rather than following the seam's builder/reader convention — +/// see "Public Data Types Across the Seam" in `docs/ARCHITECTURE.md`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PanelState { + pub panel_name: String, + pub children: Vec, + pub info: PanelInfo, +} + +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +pub struct TileMeta { + pub bounds: Bounds, + pub z_index: usize, +} + +impl Default for TileMeta { + fn default() -> Self { + Self { + bounds: Bounds { + origin: point(px(10.), px(10.)), + size: size(px(200.), px(200.)), + }, + z_index: 0, + } + } +} + +impl From> for TileMeta { + fn from(bounds: Bounds) -> Self { + Self { bounds, z_index: 0 } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PanelInfo { + #[serde(rename = "stack")] + Stack { + sizes: Vec, + axis: usize, // 0 for horizontal, 1 for vertical + }, + #[serde(rename = "tabs")] + Tabs { active_index: usize }, + #[serde(rename = "panel")] + Panel(serde_json::Value), + #[serde(rename = "tiles")] + Tiles { metas: Vec }, +} + +impl PanelInfo { + pub fn stack(sizes: Vec, axis: Axis) -> Self { + Self::Stack { + sizes, + axis: if axis == Axis::Horizontal { 0 } else { 1 }, + } + } + + pub fn tabs(active_index: usize) -> Self { + Self::Tabs { active_index } + } + + pub fn panel(info: serde_json::Value) -> Self { + Self::Panel(info) + } + + pub fn tiles(metas: Vec) -> Self { + Self::Tiles { metas } + } + + pub fn axis(&self) -> Option { + match self { + Self::Stack { axis, .. } => Some(if *axis == 0 { + Axis::Horizontal + } else { + Axis::Vertical + }), + _ => None, + } + } + + pub fn sizes(&self) -> Option<&Vec> { + match self { + Self::Stack { sizes, .. } => Some(sizes), + _ => None, + } + } + + pub fn active_index(&self) -> Option { + match self { + Self::Tabs { active_index } => Some(*active_index), + _ => None, + } + } +} + +impl Default for PanelState { + fn default() -> Self { + Self { + panel_name: "".to_string(), + children: Vec::new(), + info: PanelInfo::Panel(serde_json::Value::Null), + } + } +} + +impl PanelState { + /// Create a new leaf state for a panel with the given name. + /// + /// The base layer has no `Panel` trait yet (`gpui_component::dock::Panel` + /// is layered above), so this takes the name directly rather than + /// deriving it from a panel value. + pub fn new(panel_name: impl Into) -> Self { + Self { + panel_name: panel_name.into(), + ..Default::default() + } + } + + pub fn add_child(&mut self, panel: PanelState) { + self.children.push(panel); + } +} + +/// Placement of a [`Dock`](super::Dock) relative to the center area. +/// +/// This mirrors a persisted, on-disk schema shipped to end users: the +/// `#[serde(rename = ...)]` tags below are frozen and must not change. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum DockPlacement { + #[serde(rename = "center")] + Center, + #[serde(rename = "left")] + Left, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "right")] + Right, +} + +impl DockPlacement { + pub fn axis(&self) -> Axis { + match self { + Self::Left | Self::Right | Self::Center => Axis::Horizontal, + Self::Bottom => Axis::Vertical, + } + } + + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + pub fn is_bottom(&self) -> bool { + matches!(self, Self::Bottom) + } + + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::px; + + /// The whole of a real user's file, not just its outline: every dock and + /// the nesting under each one. Ported from the old + /// `test_deserialize_item_state`, which checked all three docks and their + /// children — a fixture test that only reads the center and one dock + /// would keep passing if a dock's shape stopped deserializing at all. + #[test] + fn the_shipped_fixture_still_deserializes() { + let json = include_str!("fixtures/layout.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + + assert_eq!(state.version, None); + assert_eq!(state.center.panel_name, "StackPanel"); + assert_eq!(state.center.children.len(), 2); + assert_eq!(state.center.children[0].panel_name, "TabPanel"); + assert_eq!(state.center.children[1].panel_name, "TabPanel"); + assert_eq!(state.center.children[1].children.len(), 1); + assert_eq!( + state.center.children[1].children[0].panel_name, + "StoryContainer" + ); + + let left = state.left_dock.unwrap(); + assert_eq!(left.open(), true); + assert_eq!(left.size(), px(350.0)); + assert_eq!(left.placement(), DockPlacement::Left); + assert_eq!(left.panel().panel_name, "TabPanel"); + assert_eq!(left.panel().children.len(), 1); + assert_eq!(left.panel().children[0].panel_name, "StoryContainer"); + + let bottom = state.bottom_dock.unwrap(); + assert_eq!(bottom.open(), true); + assert_eq!(bottom.size(), px(200.0)); + assert_eq!(bottom.placement(), DockPlacement::Bottom); + assert_eq!(bottom.panel().panel_name, "TabPanel"); + assert_eq!(bottom.panel().children.len(), 2); + assert_eq!(bottom.panel().children[0].panel_name, "StoryContainer"); + + let right = state.right_dock.unwrap(); + assert_eq!(right.open(), true); + assert_eq!(right.size(), px(320.0)); + assert_eq!(right.placement(), DockPlacement::Right); + assert_eq!(right.panel().panel_name, "TabPanel"); + assert_eq!(right.panel().children.len(), 1); + assert_eq!(right.panel().children[0].panel_name, "StoryContainer"); + } + + #[test] + fn the_serde_tags_are_frozen() { + let stack = serde_json::to_value(PanelInfo::stack(vec![px(1.)], Axis::Vertical)).unwrap(); + assert_eq!( + stack, + serde_json::json!({"stack": {"sizes": [1.0], "axis": 1}}) + ); + + let tabs = serde_json::to_value(PanelInfo::tabs(2)).unwrap(); + assert_eq!(tabs, serde_json::json!({"tabs": {"active_index": 2}})); + + let placement = serde_json::to_value(DockPlacement::Bottom).unwrap(); + assert_eq!(placement, serde_json::json!("bottom")); + } + + #[test] + fn optional_docks_are_omitted_not_nulled() { + let state = DockAreaState::default(); + let json = serde_json::to_value(&state).unwrap(); + assert!(json.get("left_dock").is_none()); + assert!(json.get("right_dock").is_none()); + assert!(json.get("bottom_dock").is_none()); + } +} diff --git a/vendor/gpui-base/src/dock/state_convert.rs b/vendor/gpui-base/src/dock/state_convert.rs new file mode 100644 index 00000000..883283d6 --- /dev/null +++ b/vendor/gpui-base/src/dock/state_convert.rs @@ -0,0 +1,826 @@ +use gpui::{Axis, Pixels}; + +use super::layout::{NodeKind, PaneNode, PaneRef, PaneTree, PanelId, RootKind, TilePanel}; +use super::state::{PanelInfo, PanelState, TileMeta}; + +/// The names written to persisted layouts. These are contract, not type names: +/// they must keep their values even if the Rust types are renamed. +pub(crate) const STACK_PANEL_NAME: &str = "StackPanel"; +pub(crate) const TAB_PANEL_NAME: &str = "TabPanel"; +pub(crate) const TILES_PANEL_NAME: &str = "Tiles"; + +/// How the layout tree reads properties of panels it only knows by id. +/// +/// Keeping this behind a trait is what lets the whole layout algebra be tested +/// without an `App`. +pub trait PanelSource { + fn panel_name(&self, id: PanelId) -> &'static str; + fn is_visible(&self, id: PanelId) -> bool; + fn dump(&self, id: PanelId) -> PanelState; +} + +impl PaneTree { + pub fn to_state(&self, source: &dyn PanelSource) -> PanelState { + node_to_state(self.root(), source) + } +} + +fn node_to_state(node: &PaneNode, source: &dyn PanelSource) -> PanelState { + match node.kind() { + PaneRef::Split { + axis, + children, + sizes, + } => PanelState { + panel_name: STACK_PANEL_NAME.to_string(), + children: children + .iter() + .map(|child| node_to_state(child, source)) + .collect(), + // `None` is a representation this rewrite introduces, not + // something the old writer ever produced: the schema's `sizes` + // field is `Vec`, with no slot for "unconstrained". `0.0` + // is the sentinel this writer chooses for that case, and the + // corresponding reader maps a `0.0` it loads back to `None`. + // + // That makes a `None` slot safe to persist only transiently: a + // caller building a tree meant to be written out must resolve + // every slot to concrete pixels first. An older build reading a + // persisted `0.0` back has no notion of the sentinel and + // constructs a real, zero-pixel-wide panel from it. + info: PanelInfo::stack( + sizes.iter().map(|size| size.unwrap_or_default()).collect(), + axis, + ), + }, + PaneRef::Tabs { panels, active_ix } => PanelState { + panel_name: TAB_PANEL_NAME.to_string(), + children: panels.iter().map(|panel| source.dump(*panel)).collect(), + // Unconditional, unlike the old writer which assigned this inside + // its loop and left an empty group looking like a bare panel. + info: PanelInfo::tabs(active_ix), + }, + PaneRef::Tiles { panels } => PanelState { + panel_name: TILES_PANEL_NAME.to_string(), + children: panels + .iter() + .map(|tile| source.dump(tile.panel())) + .collect(), + info: PanelInfo::tiles( + panels + .iter() + .map(|tile| TileMeta { + bounds: tile.bounds(), + z_index: tile.z_index(), + }) + .collect(), + ), + }, + } +} + +/// Turns a persisted leaf into a live panel id. +/// +/// The production implementation (at the `gpui-component` layer, above this +/// crate) consults `PanelRegistry` and falls back to an invalid-panel +/// placeholder that retains the original `PanelState`, so a panel type this +/// build does not know about survives a load/save round trip instead of +/// being erased. +pub trait PanelBuilder { + fn build(&mut self, state: &PanelState, info: &PanelInfo) -> PanelId; +} + +impl PaneTree { + /// Read a persisted layout. + /// + /// Compatibility rules, all previously implicit in `PanelState::to_item`: + /// + /// - a `Tabs` whose children are themselves `Tabs` is flattened; + /// - a bare `Panel` leaf appearing where a container belongs is wrapped in + /// a `Tabs`; + /// - a node named `TabPanel` carrying `PanelInfo::Panel` is read as an + /// empty tab group, recovering data written by the old dump defect (an + /// empty `TabPanel` never entered the loop that set its `info` to + /// `Tabs`, so it kept `PanelState`'s default `Panel(Value::Null)`). The + /// old reader had no such rule: it looked "TabPanel" up in the panel + /// registry, found nothing, and rendered an `InvalidPanel` placeholder + /// where an empty tab group belonged. This rule is a genuine fix, not a + /// preserved behavior; + /// - a `Tiles` child without a matching meta keeps the default placement. + /// The old writer's counterpart (`DockItem::tiles`) hard-asserted + /// `items.len() == metas.len()` and panicked the whole load on a short + /// `metas` list, so this rule is a new safety net, not a preserved + /// graceful-degradation path. + pub fn from_state( + state: &PanelState, + root_kind: RootKind, + builder: &mut dyn PanelBuilder, + ) -> Self { + let mut tree = PaneTree::new(root_kind); + let root = build_node(&mut tree, state, builder); + + let root = match (root_kind, &root) { + (RootKind::Split, node) if !matches!(node.kind_ref(), NodeKind::Split { .. }) => { + let id = tree.allocate_node_id(); + PaneNode::new( + id, + NodeKind::Split { + axis: Axis::Horizontal, + children: vec![node.clone()], + sizes: vec![None], + }, + ) + } + _ => root, + }; + + tree.replace_root(root); + tree.normalize(); + tree + } +} + +fn build_node(tree: &mut PaneTree, state: &PanelState, builder: &mut dyn PanelBuilder) -> PaneNode { + let id = tree.allocate_node_id(); + + match &state.info { + PanelInfo::Stack { sizes, axis } => { + let axis = if *axis == 0 { + Axis::Horizontal + } else { + Axis::Vertical + }; + let children: Vec = state + .children + .iter() + .map(|child| build_node(tree, child, builder)) + .collect(); + let sizes = (0..children.len()) + .map(|ix| sizes.get(ix).copied().filter(|size| *size > Pixels::ZERO)) + .collect(); + PaneNode::new( + id, + NodeKind::Split { + axis, + children, + sizes, + }, + ) + } + PanelInfo::Tabs { active_index } => { + let panels = collect_tab_panels(&state.children, builder); + PaneNode::new( + id, + NodeKind::Tabs { + panels, + active_ix: *active_index, + }, + ) + } + PanelInfo::Tiles { metas } => { + let mut panels = Vec::new(); + for (ix, child) in state.children.iter().enumerate() { + // Keyed by the *child* index, not by the output index: a + // child that expands to several tiles must not shift the + // metas of the children after it. + let meta = metas.get(ix).copied().unwrap_or_default(); + for panel in tile_panels(child, builder) { + panels.push(TilePanel::new(panel, meta.bounds).with_z_index(meta.z_index)); + } + } + PaneNode::new(id, NodeKind::Tiles { panels }) + } + PanelInfo::Panel(_) => { + // A container name carrying a leaf info means the writer that + // produced this file had the empty-group defect. + let panels = if state.panel_name == TAB_PANEL_NAME { + Vec::new() + } else { + vec![builder.build(state, &state.info)] + }; + PaneNode::new( + id, + NodeKind::Tabs { + panels, + active_ix: 0, + }, + ) + } + } +} + +/// The panels one persisted `Tiles` child stands for. +/// +/// Every tile the old dock ever wrote is `TabPanel`-shaped: `DockItem::tiles` +/// wraps each child in a `TabPanel`, `DockItem::add_panel`'s tiles arm wraps +/// every UI-added panel in a fresh one, and `PanelState::to_item` converts a +/// plain-panel child into a `TabPanel` on the next save. In the tree model a +/// tile *is* a panel, so the group has to be unwrapped — building the +/// `"TabPanel"` leaf directly would miss the registry, fall to a placeholder, +/// and never build the user's real panels at all, restoring a saved tiles +/// canvas as blank tiles. +/// +/// A group holding several panels expands to one tile per panel, sharing the +/// group's meta. That is not merely defensive: the UI cannot produce it +/// (tiles groups are locked, so they can never gain a tab), but +/// `DockItem::tiles(vec![DockItem::tabs(vec![a, b])], ..)` can, and a tiles +/// canvas has no way to show a second tab. +fn tile_panels(child: &PanelState, builder: &mut dyn PanelBuilder) -> Vec { + match &child.info { + PanelInfo::Tabs { .. } => { + let panels = collect_tab_panels(&child.children, builder); + if panels.len() > 1 { + tracing::warn!( + panels = panels.len(), + "a tiles child held a tab group with more than one panel; \ + expanding it to one tile per panel, all sharing the group's placement" + ); + } + panels + } + // The legacy empty-group form: a `TabPanel` name carrying leaf info. + // It stands for no panel, so it contributes no tile. + PanelInfo::Panel(_) if child.panel_name == TAB_PANEL_NAME => Vec::new(), + _ => vec![builder.build(child, &child.info)], + } +} + +/// Flatten one level of tab nesting, which the old writer could produce. +fn collect_tab_panels(children: &[PanelState], builder: &mut dyn PanelBuilder) -> Vec { + children + .iter() + .flat_map(|child| match &child.info { + PanelInfo::Tabs { .. } => collect_tab_panels(&child.children, builder), + PanelInfo::Panel(_) if child.panel_name == TAB_PANEL_NAME => Vec::new(), + _ => vec![builder.build(child, &child.info)], + }) + .collect() +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, px, size}; + + use super::super::layout::{RootKind, TilePanel}; + use super::super::state::DockAreaState; + use super::*; + + /// A `PanelSource` backed by a fixed map, so conversion is testable + /// without an `App`. + struct FakePanels(Vec<(PanelId, &'static str)>); + + impl PanelSource for FakePanels { + fn panel_name(&self, id: PanelId) -> &'static str { + self.0 + .iter() + .find(|(p, _)| *p == id) + .map(|(_, n)| *n) + .unwrap_or("Unknown") + } + fn is_visible(&self, _: PanelId) -> bool { + true + } + fn dump(&self, id: PanelId) -> PanelState { + PanelState { + panel_name: self.panel_name(id).to_string(), + children: Vec::new(), + info: PanelInfo::panel(serde_json::Value::Null), + } + } + } + + #[test] + fn a_split_serializes_as_a_stack_panel() { + let mut tree = PaneTree::new(RootKind::Split); + tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(1)]); + tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(2)]); + tree.normalize(); + + let source = FakePanels(vec![ + (PanelId::from_u64(1), "Alpha"), + (PanelId::from_u64(2), "Beta"), + ]); + let state = tree.to_state(&source); + + assert_eq!(state.panel_name, "StackPanel"); + let PanelInfo::Stack { sizes, .. } = &state.info else { + panic!("expected Stack info"); + }; + // Both slots were pushed with no explicit size (`push_tabs_for_test` + // always pushes `None`), so both serialize as the zero sentinel. + assert_eq!(sizes, &vec![px(0.), px(0.)]); + assert_eq!(state.children[0].panel_name, "TabPanel"); + assert_eq!(state.children[0].children[0].panel_name, "Alpha"); + } + + #[test] + fn an_unresolved_slot_size_serializes_as_the_zero_sentinel() { + let mut tree = PaneTree::new(RootKind::Split); + let root = tree.root().id(); + tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(1)], Some(px(120.))); + tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(2)], None); + tree.normalize(); + + let source = FakePanels(vec![ + (PanelId::from_u64(1), "Alpha"), + (PanelId::from_u64(2), "Beta"), + ]); + let state = tree.to_state(&source); + + let PanelInfo::Stack { sizes, .. } = state.info else { + panic!("expected Stack info"); + }; + // The `Some` slot keeps its concrete value; only the genuinely + // unresolved (`None`) slot is written as the `0.0` sentinel. + assert_eq!(sizes, vec![px(120.), px(0.)]); + } + + #[test] + fn an_empty_tab_group_serializes_as_tabs_not_as_a_panel() { + let mut tree = PaneTree::new(RootKind::Any); + tree.set_root_tabs_for_test(vec![], 0); + + let state = tree.to_state(&FakePanels(vec![])); + + assert_eq!(state.panel_name, "TabPanel"); + assert!( + matches!(state.info, PanelInfo::Tabs { active_index: 0 }), + "the old writer emitted PanelInfo::Panel here, which failed to restore" + ); + } + + #[test] + fn an_empty_center_still_serializes_as_a_stack_panel() { + let tree = PaneTree::new(RootKind::Split); + let state = tree.to_state(&FakePanels(vec![])); + assert_eq!(state.panel_name, "StackPanel"); + assert!(matches!(state.info, PanelInfo::Stack { .. })); + } + + #[test] + fn tiles_serialize_with_their_metas_in_order() { + let mut tree = PaneTree::new(RootKind::Any); + let bounds = Bounds { + origin: point(px(5.), px(6.)), + size: size(px(7.), px(8.)), + }; + tree.set_root_tiles_for_test(vec![ + TilePanel::new(PanelId::from_u64(1), bounds).with_z_index(2), + ]); + + let state = tree.to_state(&FakePanels(vec![(PanelId::from_u64(1), "Alpha")])); + + assert_eq!(state.panel_name, "Tiles"); + let PanelInfo::Tiles { metas } = state.info else { + panic!() + }; + assert_eq!(metas[0].bounds, bounds); + assert_eq!(metas[0].z_index, 2); + } + + /// Assigns each leaf `PanelState` an id in encounter order, so the reader + /// can be tested without a registry or an `App`. + #[derive(Default)] + struct RecordingBuilder { + built: Vec, + } + + impl PanelBuilder for RecordingBuilder { + fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId { + self.built.push(state.panel_name.clone()); + PanelId::from_u64(self.built.len() as u64) + } + } + + fn tabs_state(children: Vec, active_index: usize) -> PanelState { + PanelState { + panel_name: TAB_PANEL_NAME.to_string(), + children, + info: PanelInfo::tabs(active_index), + } + } + + fn panel_state(name: &str) -> PanelState { + PanelState { + panel_name: name.to_string(), + children: Vec::new(), + info: PanelInfo::panel(serde_json::Value::Null), + } + } + + #[test] + fn nested_tab_groups_are_flattened() { + let state = tabs_state( + vec![ + tabs_state(vec![panel_state("Alpha")], 0), + tabs_state(vec![panel_state("Beta")], 0), + ], + 1, + ); + + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder); + + assert_eq!(builder.built, vec!["Alpha", "Beta"]); + let PaneRef::Tabs { panels, active_ix } = tree.root().kind() else { + panic!() + }; + assert_eq!(panels.len(), 2); + assert_eq!(active_ix, 1); + } + + #[test] + fn a_bare_panel_leaf_is_wrapped_in_a_tab_group() { + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&panel_state("Alpha"), RootKind::Any, &mut builder); + + assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.len() == 1)); + } + + #[test] + fn a_tab_panel_carrying_panel_info_is_read_as_an_empty_group() { + // What the old `TabPanel::dump` wrote for an empty tab group. + let state = PanelState { + panel_name: TAB_PANEL_NAME.to_string(), + children: Vec::new(), + info: PanelInfo::panel(serde_json::Value::Null), + }; + + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder); + + assert!( + builder.built.is_empty(), + "no panel is built for the phantom leaf" + ); + assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.is_empty())); + } + + #[test] + fn a_split_root_is_forced_even_when_the_state_is_a_tab_group() { + let state = tabs_state(vec![panel_state("Alpha")], 0); + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&state, RootKind::Split, &mut builder); + + assert!(matches!(tree.root().kind(), PaneRef::Split { .. })); + } + + #[test] + fn tile_metas_are_paired_with_children_by_index() { + let bounds = Bounds { + origin: point(px(1.), px(2.)), + size: size(px(3.), px(4.)), + }; + let state = PanelState { + panel_name: TILES_PANEL_NAME.to_string(), + children: vec![panel_state("Alpha")], + info: PanelInfo::tiles(vec![TileMeta { bounds, z_index: 5 }]), + }; + + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder); + + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!() + }; + assert_eq!(panels[0].bounds(), bounds); + assert_eq!(panels[0].z_index(), 5); + } + + #[test] + fn a_tile_child_missing_its_meta_falls_back_to_the_default_placement() { + let state = PanelState { + panel_name: TILES_PANEL_NAME.to_string(), + children: vec![panel_state("Alpha"), panel_state("Beta")], + info: PanelInfo::tiles(vec![TileMeta::default()]), + }; + + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder); + + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!() + }; + assert_eq!(panels.len(), 2, "a short metas list must not drop panels"); + } + + /// Round-trips leaves by remembering the exact `PanelState` each id came + /// from, which is what the production invalid-panel path must also do. + /// + /// `PanelSource::panel_name` returns `&'static str`, which a JSON-sourced + /// `String` can only satisfy by leaking. Rather than leak on every call + /// to `panel_name` (as many times as the layout is dumped), each leak + /// happens once, in `build`, when the id is minted; `panel_name` then + /// just indexes into the already-leaked slice. Still a leak, but a + /// bounded one, and test-only. + #[derive(Default)] + struct PreservingPanels { + states: Vec, + names: Vec<&'static str>, + } + + impl PanelBuilder for PreservingPanels { + fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId { + self.states.push(state.clone()); + self.names + .push(Box::leak(state.panel_name.clone().into_boxed_str())); + PanelId::from_u64(self.states.len() as u64) + } + } + + impl PanelSource for PreservingPanels { + fn panel_name(&self, id: PanelId) -> &'static str { + self.names[id.as_u64() as usize - 1] + } + fn is_visible(&self, _: PanelId) -> bool { + true + } + fn dump(&self, id: PanelId) -> PanelState { + self.states[id.as_u64() as usize - 1].clone() + } + } + + fn canonicalize(json: &str) -> PanelState { + let state: DockAreaState = serde_json::from_str(json).unwrap(); + let mut panels = PreservingPanels::default(); + let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels); + tree.to_state(&panels) + } + + #[test] + fn canonicalization_reaches_a_fixpoint_in_one_pass() { + for json in [ + include_str!("fixtures/layout.json"), + include_str!("fixtures/tiles.json"), + include_str!("fixtures/nested_splits.json"), + include_str!("fixtures/legacy_empty_tab_group.json"), + include_str!("fixtures/unregistered_panel.json"), + include_str!("fixtures/zero_size_sentinel.json"), + include_str!("fixtures/tiles_tab_panel_children.json"), + ] { + let once = canonicalize(json); + let twice = { + let wrapped = DockAreaState { + center: once.clone(), + ..Default::default() + }; + canonicalize(&serde_json::to_string(&wrapped).unwrap()) + }; + assert_eq!(once, twice, "r(r(x)) != r(x)"); + } + } + + #[test] + fn an_unregistered_panel_keeps_its_payload_through_a_round_trip() { + let state = canonicalize(include_str!("fixtures/unregistered_panel.json")); + let leaf = &state.children[0].children[0]; + + assert_eq!(leaf.panel_name, "PanelFromTheFuture"); + assert_eq!( + leaf.info, + PanelInfo::panel(serde_json::json!({"keep": "me"})) + ); + } + + #[test] + fn the_legacy_empty_tab_group_is_rewritten_into_the_tabs_form() { + let state = canonicalize(include_str!("fixtures/legacy_empty_tab_group.json")); + + // The empty group collapses, leaving the mandatory split root. + assert_eq!(state.panel_name, "StackPanel"); + assert!(state.children.is_empty()); + } + + #[test] + fn nested_same_axis_splits_are_flattened_and_single_child_splits_collapse() { + let state = canonicalize(include_str!("fixtures/nested_splits.json")); + + assert_eq!(state.panel_name, "StackPanel"); + assert_eq!( + state.children.len(), + 3, + "the horizontal inner split splices in and the vertical single-child split collapses" + ); + assert!( + state + .children + .iter() + .all(|child| child.panel_name == "TabPanel") + ); + // Order matters as much as count: a splice that reversed the spliced + // children, or that put the collapsed single-child split ahead of + // them, would still satisfy the two assertions above. + assert_eq!(state.children[0].children[0].panel_name, "Alpha"); + assert_eq!(state.children[1].children[0].panel_name, "Beta"); + assert_eq!(state.children[2].children[0].panel_name, "Gamma"); + + // The fixture's inner slot (50.0 + 150.0 = 200.0) does not equal its + // outer slot (400.0), so the scale factor is a genuine 2x, not 1x: + // `distribute_slot`'s `slot / total` and a wrongly inverted + // `total / slot` would disagree here. The vertical single-child + // split's own inner size (300.0) is discarded; its outer slot + // (300.0) is what the surviving child inherits. + let PanelInfo::Stack { sizes, .. } = &state.info else { + panic!("expected Stack info"); + }; + assert_eq!( + sizes, + &vec![px(100.), px(300.), px(300.)], + "the spliced-in sizes are scaled by outer/inner (400/200 = 2x), and the \ + collapsed split hands its own outer slot (300.0) to its surviving child" + ); + } + + /// The shape every persisted tiles canvas actually has: each tile child + /// is a `TabPanel` wrapping the real panel. Reading the `"TabPanel"` leaf + /// literally would build a placeholder and drop the user's panel. + #[test] + fn a_tiles_child_that_is_a_tab_group_is_unwrapped_to_its_panels() { + let json = include_str!("fixtures/tiles_tab_panel_children.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + let mut panels = PreservingPanels::default(); + let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels); + + assert_eq!( + panels.names, + vec!["Alpha", "Beta", "Gamma"], + "the real panels are built; no `TabPanel` leaf is handed to the builder" + ); + + let dumped = tree.to_state(&panels); + let tiles = &dumped.children[0]; + assert_eq!(tiles.panel_name, "Tiles"); + assert_eq!( + tiles + .children + .iter() + .map(|child| child.panel_name.as_str()) + .collect::>(), + vec!["Alpha", "Beta", "Gamma"], + ); + + let PanelInfo::Tiles { metas } = &tiles.info else { + panic!("expected Tiles info"); + }; + // The two-panel child expands to two tiles sharing its own meta, and + // the *next* child still gets the meta at its own child index — an + // implementation keyed on the output index would hand Gamma the first + // meta and lose the second entirely. + assert_eq!(metas.len(), 3); + assert_eq!( + metas[0].bounds.origin.x, + px(10.), + "Alpha keeps child 0's meta" + ); + assert_eq!( + metas[1].bounds.origin.x, + px(10.), + "Beta shares child 0's meta" + ); + assert_eq!(metas[1].z_index, 0); + assert_eq!( + metas[2].bounds.origin.x, + px(400.), + "Gamma gets child 1's meta" + ); + assert_eq!(metas[2].z_index, 3); + } + + #[test] + fn an_empty_tab_group_on_a_tiles_canvas_contributes_no_tile() { + let state = PanelState { + panel_name: TILES_PANEL_NAME.to_string(), + children: vec![ + // The legacy empty-group form. + PanelState { + panel_name: TAB_PANEL_NAME.to_string(), + children: Vec::new(), + info: PanelInfo::panel(serde_json::Value::Null), + }, + tabs_state(vec![panel_state("Alpha")], 0), + ], + info: PanelInfo::tiles(vec![TileMeta::default(), TileMeta::default()]), + }; + + let mut builder = RecordingBuilder::default(); + let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder); + + assert_eq!(builder.built, vec!["Alpha"]); + let PaneRef::Tiles { panels } = tree.root().kind() else { + panic!() + }; + assert_eq!(panels.len(), 1); + } + + #[test] + fn tile_bounds_and_z_order_survive_a_round_trip() { + let state = canonicalize(include_str!("fixtures/tiles.json")); + let tiles = &state.children[0]; + + assert_eq!(tiles.panel_name, "Tiles"); + // `metas` and `children` are parallel arrays, matched up by index. A + // bug that swapped `children`'s order while leaving `metas` in place + // would misattribute bounds to the wrong panel without either array + // changing length, so check both arrays' contents *and* that they + // still line up by identity, not just that each array is correct in + // isolation. + assert_eq!(tiles.children[0].panel_name, "Alpha"); + assert_eq!(tiles.children[1].panel_name, "Beta"); + + let PanelInfo::Tiles { metas } = &tiles.info else { + panic!() + }; + assert_eq!(metas.len(), 2); + assert_eq!(metas[0].z_index, 0, "Alpha's meta"); + assert_eq!(metas[0].bounds.origin.x, px(10.), "Alpha's meta"); + assert_eq!(metas[1].z_index, 1, "Beta's meta"); + assert_eq!(metas[1].bounds.origin.x, px(220.), "Beta's meta"); + } + + #[test] + fn a_tree_survives_a_round_trip_exactly() { + let json = include_str!("fixtures/nested_splits.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + let mut panels = PreservingPanels::default(); + let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels); + + let dumped = tree.to_state(&panels); + let mut rebuilt_panels = PreservingPanels::default(); + let rebuilt = PaneTree::from_state(&dumped, RootKind::Split, &mut rebuilt_panels); + + assert_eq!( + tree.to_state(&panels), + rebuilt.to_state(&rebuilt_panels), + "load(dump(t)) must describe the same layout as t" + ); + } + + /// Pins the one value in the schema that means something different now + /// than it ever did before: a literal `0.0` slot size sits next to a + /// genuine, non-collapsing `200.0` sibling, so the fixture cannot pass by + /// accident of single-child-split collapse swallowing the slot. The + /// reader must map the `0.0` back to `None` (unconstrained) while + /// leaving the `200.0` sibling as `Some`, and the writer must round-trip + /// `None` back to the literal `0.0` sentinel. + #[test] + fn a_zero_size_slot_round_trips_through_the_none_sentinel() { + let json = include_str!("fixtures/zero_size_sentinel.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + let mut panels = PreservingPanels::default(); + let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels); + + let PaneRef::Split { sizes, .. } = tree.root().kind() else { + panic!("expected the root to stay a split"); + }; + assert_eq!( + sizes, + &[None, Some(px(200.))], + "the 0.0 sentinel reads back as None, not as a real zero-width panel" + ); + + let dumped = tree.to_state(&panels); + let PanelInfo::Stack { sizes, .. } = &dumped.info else { + panic!("expected Stack info"); + }; + assert_eq!( + sizes, + &vec![px(0.), px(200.)], + "the unconstrained slot writes back out as the 0.0 sentinel" + ); + } + + /// Pins that a bare `TabPanel` root — the shape every dock in the + /// shipped `fixtures/layout.json` (`left_dock`, `right_dock`, + /// `bottom_dock`) actually stores — survives a round trip under + /// `RootKind::Any` as a `Tabs` node, with its one panel intact. + /// + /// It does *not* pin that the forced-wrap arm in `from_state` is gated + /// on `RootKind::Split` and correctly skipped here. It can't: `normalize` + /// runs `collapse_root` on every pass, which un-wraps a single-child + /// `Split` root for any `root_kind != RootKind::Split` before this test + /// (or anything else) can observe the tree. So if the guard were deleted + /// and the wrap fired unconditionally, `collapse_root` would strip the + /// synthetic split right back off within the same `normalize()` call, + /// and this test would still pass — a correctly-guarded arm and a + /// missing guard produce byte-identical output here. The half of the + /// guard that *is* observable — the wrap firing and surviving — is + /// pinned by `a_split_root_is_forced_even_when_the_state_is_a_tab_group`, + /// where `RootKind::Split` makes `collapse_root` return early instead of + /// undoing it. + #[test] + fn a_bare_tab_panel_root_is_not_wrapped_under_root_kind_any() { + let json = include_str!("fixtures/bare_tab_panel_root.json"); + let state: PanelState = serde_json::from_str(json).unwrap(); + let mut panels = PreservingPanels::default(); + let tree = PaneTree::from_state(&state, RootKind::Any, &mut panels); + + assert!( + matches!(tree.root().kind(), PaneRef::Tabs { .. }), + "a bare TabPanel root must stay a Tabs node under RootKind::Any, \ + not get wrapped in a synthetic split" + ); + + let dumped = tree.to_state(&panels); + assert_eq!(dumped.panel_name, "TabPanel"); + } +} diff --git a/vendor/gpui-base/src/dock/tab_group.rs b/vendor/gpui-base/src/dock/tab_group.rs new file mode 100644 index 00000000..d4ba128a --- /dev/null +++ b/vendor/gpui-base/src/dock/tab_group.rs @@ -0,0 +1,1783 @@ +//! A tab group's behavior, with no appearance of its own. + +use std::{rc::Rc, sync::Arc}; + +use gpui::{ + AnyElement, AnyView, App, Bounds, Context, Div, DragMoveEvent, Empty, EventEmitter, + FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, + Render, Stateful, WeakEntity, Window, div, prelude::FluentBuilder as _, +}; + +use crate::Placement; + +use super::{ + active::ActiveTracker, + drag::{ + AnyDrag, DragPanel, DropIndicator, DropPlaceholderBounds, DropTarget, ITEM_DRAG_SESSION_ID, + split_placement_at, + }, + layout::{InsertTarget, NodeId, PanelId}, + panel::PanelView, +}; + +/// Behavior a tab group cannot carry out on its own. +/// +/// Every variant here ends in an edit to the layout tree, and the tree belongs +/// to the container that owns this group. Reporting the intent instead of +/// reaching for the container keeps the whole detach-then-reinsert dance — +/// and the reentrancy it used to provoke — out of the group entirely. +#[non_exhaustive] +pub enum TabGroupEvent { + /// A panel was dropped on this group. `target` says where in the tree it + /// lands; the container applies it as a single `PaneTree::move_panel`. + Drop { + panel: PanelId, + source: NodeId, + target: InsertTarget, + }, + /// A host-owned drag landed on this group. + DragDrop { item: AnyDrag, target: DropTarget }, + /// The user asked to close `panel`. + ClosePanel { panel: PanelId }, + /// The displayed tab changed, so the tree's stored `active_ix` is stale. + ActiveChanged { ix: usize }, + /// This group asked to fill the whole dock. The container installs the + /// *group* as its zoomed view, chrome and all — the tab bar is where the + /// control that zooms back out lives. + ZoomIn, + /// This group gave the dock back. + ZoomOut, +} + +/// Everything the container knows about a group's place in the dock. +/// +/// Pushed as one value rather than one setter per fact. These are read +/// together, and a container that updates one while leaving another stale +/// describes a dock that cannot exist — a group on a tiles canvas that still +/// reports itself droppable, or a group beside siblings that still reports +/// itself alone. Choosing a constructor forces the container kind to be +/// stated; anything a constructor does not grant stays off, so a container +/// that forgets something gets a group that does less rather than more. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TabGroupConstraints { + alone: bool, + dock_locked: bool, + collapsed: bool, + closable: bool, +} + +impl TabGroupConstraints { + /// A group with nowhere to go: locked, alone, unclosable. What a group + /// starts as, before any container has placed it. + pub fn sealed() -> Self { + Self { + alone: true, + dock_locked: true, + collapsed: false, + closable: false, + } + } + + /// A group in a split layout. `alone` when nothing sits beside it, which + /// is what stops its last visible panel being dragged out and leaving the + /// dock empty. + pub fn in_split(alone: bool) -> Self { + Self { + alone, + dock_locked: false, + collapsed: false, + closable: true, + } + } + + /// Whether the dock as a whole forbids rearranging. + pub fn dock_locked(mut self, dock_locked: bool) -> Self { + self.dock_locked = dock_locked; + self + } + + /// Whether the group is folded away to a strip of tabs with no content. + pub fn collapsed(mut self, collapsed: bool) -> Self { + self.collapsed = collapsed; + self + } + + /// Whether the container allows this group's panels to be closed at all. + /// A dock's last group sets this `false` so the dock cannot be emptied. + pub fn closable(mut self, closable: bool) -> Self { + self.closable = closable; + self + } + + /// Whether nothing sits beside this group in its tree. + pub fn is_alone(&self) -> bool { + self.alone + } + + /// Whether the group's place in the dock is fixed. + /// + /// The dock-wide lock is the whole of it. A tab group only ever sits + /// inside a split — a tiles canvas holds panels directly and never a tab + /// group — so there is no second way for a container to pin one down, and + /// no separate `is_dock_locked` reader that would answer identically. + pub fn is_locked(&self) -> bool { + self.dock_locked + } + + pub fn is_collapsed(&self) -> bool { + self.collapsed + } + + pub fn can_close(&self) -> bool { + self.closable + } +} + +/// A tab group's behavior, with no appearance of its own. +/// +/// It owns the panel list mirrored from the layout tree, the displayed index, +/// the focus handle, drag and drop hit state, and the zoom flag. Everything +/// visible is produced by the [`TabGroupRenderer`] the host installs. +/// +/// The group holds no handle on its container. What the container knows — the +/// facts in [`TabGroupConstraints`] — is pushed in, and what the group needs +/// done is emitted as a [`TabGroupEvent`]. That keeps a group constructible, +/// and testable, on its own. +pub struct TabGroup { + node: NodeId, + /// Handed to the callbacks in [`TabGroupContext`], which are built from a + /// plain `&App` and so cannot ask for it. + this: WeakEntity, + panels: Vec>, + active_ix: usize, + zoomed: bool, + constraints: TabGroupConstraints, + focus_handle: FocusHandle, + active: ActiveTracker, + drop_indicator: Option, + renderer: Rc, +} + +impl TabGroup { + /// Only a container builds groups: a group is the entity mirror of one + /// `Tabs` node, and it is created when that node first appears in the + /// tree. `DockArea` is the only caller outside tests. + pub(crate) fn new(node: NodeId, _window: &mut Window, cx: &mut Context) -> Self { + Self { + node, + this: cx.weak_entity(), + panels: Vec::new(), + active_ix: 0, + zoomed: false, + // A group that no container has placed yet can do nothing. + constraints: TabGroupConstraints::sealed(), + focus_handle: cx.focus_handle(), + active: ActiveTracker::default(), + drop_indicator: None, + renderer: Rc::new(BareTabGroup), + } + } + + pub fn with_renderer(mut self, renderer: Rc) -> Self { + self.renderer = renderer; + self + } + + /// The `Tabs` node this group mirrors. + pub fn node(&self) -> NodeId { + self.node + } + + pub fn panels(&self) -> &[Arc] { + &self.panels + } + + pub fn active_ix(&self) -> usize { + self.active_ix + } + + /// The panel currently on screen, which is the displayed tab unless it has + /// gone invisible, in which case rendering falls back to the first visible + /// panel. + pub fn active_panel(&self, cx: &App) -> Option> { + match self.panels.get(self.active_ix) { + Some(panel) if panel.visible(cx) => Some(panel.clone()), + Some(_) => self.visible_panels(cx).next(), + None => None, + } + } + + pub fn is_zoomed(&self) -> bool { + self.zoomed + } + + pub fn is_collapsed(&self) -> bool { + self.constraints.is_collapsed() + } + + /// Whether closing this group's displayed panel is allowed at all. + /// + /// Mirrors the old `TabPanel::closable`: the container must permit it, the + /// group must have somewhere to go, and the displayed panel must itself be + /// closable. + pub fn can_close(&self, cx: &App) -> bool { + self.constraints.can_close() + && self.draggable(cx) + && self + .active_panel(cx) + .is_some_and(|panel| panel.closable(cx)) + } + + /// Display `ix`, if it names a tab that is not already displayed. + pub fn select_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if ix >= self.panels.len() || ix == self.active_ix { + return; + } + + self.active_ix = ix; + self.focus_active_panel(window, cx); + self.schedule_active_sync(window, cx); + cx.emit(TabGroupEvent::ActiveChanged { ix }); + cx.notify(); + } + + /// Ask the container to close `panel`. Nothing happens for a panel that is + /// not in this group, or when either the group or the panel refuses. + pub fn close_panel(&mut self, panel: PanelId, cx: &mut Context) { + if !self.constraints.can_close() { + return; + } + // A dock's last group has nowhere to go and must stay. + if !self.draggable(cx) { + return; + } + + let closable = self + .panels + .iter() + .any(|candidate| candidate.panel_id(cx) == panel && candidate.closable(cx)); + if !closable { + return; + } + + cx.emit(TabGroupEvent::ClosePanel { panel }); + cx.notify(); + } + + pub fn toggle_zoom(&mut self, window: &mut Window, cx: &mut Context) { + self.set_zoomed(!self.zoomed, window, cx); + } + + /// A snapshot of everything a skin needs to draw this group. + pub fn context(&self, cx: &App) -> TabGroupContext { + let group = self.this.clone(); + + TabGroupContext { + node: self.node, + panels: self.panels.clone(), + active_panel: self.active_panel(cx), + active_ix: self.active_ix, + zoomed: self.zoomed, + collapsed: self.constraints.is_collapsed(), + can_close: self.can_close(cx), + locked: self.is_locked(), + draggable: self.draggable(cx), + droppable: self.droppable(), + // A stale indicator would otherwise outlive a drag that was + // cancelled while hovering this group. + drop_indicator: cx + .has_active_drag() + .then_some(self.drop_indicator) + .flatten(), + on_select_tab: { + let group = group.clone(); + Rc::new(move |ix, window, cx| { + _ = group.update(cx, |group, cx| group.select_tab(ix, window, cx)); + }) + }, + on_close: { + let group = group.clone(); + Rc::new(move |panel, _, cx| { + _ = group.update(cx, |group, cx| group.close_panel(panel, cx)); + }) + }, + on_toggle_zoom: { + let group = group.clone(); + Rc::new(move |window, cx| { + _ = group.update(cx, |group, cx| group.toggle_zoom(window, cx)); + }) + }, + on_drop_panel: { + let group = group.clone(); + Rc::new(move |drag, ix, activate, _, cx| { + _ = group.update(cx, |group, cx| group.on_drop(&drag, ix, activate, cx)); + }) + }, + on_drop_item: Rc::new(move |item, placement, _, cx| { + _ = group.update(cx, |group, cx| group.emit_drag_drop(&item, placement, cx)); + }), + } + } +} + +/// What the container pushes into a group, and reads back out of it. +/// +/// `DockArea` is the only caller outside tests. +impl TabGroup { + /// Mirror one `Tabs` node's membership and displayed index into this + /// group. `on_added_to` fires for arrivals; departures are silent here + /// because only the container can tell a move from a removal. + pub(crate) fn sync_from_tree( + &mut self, + panels: Vec>, + active_ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let group = cx.weak_entity(); + let existing: Vec = self.panels.iter().map(|panel| panel.panel_id(cx)).collect(); + for panel in panels.iter() { + if !existing.contains(&panel.panel_id(cx)) { + panel.on_added_to(group.clone(), window, cx); + } + } + + self.panels = panels; + self.active_ix = active_ix.min(self.panels.len().saturating_sub(1)); + self.schedule_active_sync(window, cx); + cx.notify(); + } + + /// Replace everything the container knows about this group's place in the + /// dock, in one call. + /// + /// One value rather than a setter per fact, because these are read + /// together and a container that updates one while leaving another stale + /// describes a dock that cannot exist — a group on a tiles canvas that + /// still reports itself droppable, or a group beside siblings that still + /// reports itself alone. + pub(crate) fn set_constraints( + &mut self, + constraints: TabGroupConstraints, + window: &mut Window, + cx: &mut Context, + ) { + if self.constraints == constraints { + return; + } + + // Collapsing takes the displayed panel off screen, which the + // active-state contract counts as no panel being displayed. + let collapse_changed = self.constraints.is_collapsed() != constraints.is_collapsed(); + self.constraints = constraints; + if collapse_changed { + self.schedule_active_sync(window, cx); + } + cx.notify(); + } + + /// Zoom this group in or out, in full: the flag flips, the displayed + /// panel is told, and the container is asked to install or clear the + /// zoomed view. + /// + /// Zoom is the group's own state rather than the container's, but the + /// container drives it too when it installs or clears a zoomed view — and + /// it goes through this same method, so a group cannot end up flagged + /// zoomed while the container shows something else. + /// + /// Zooming *in* is refused, leaving the flag alone, when there is no + /// displayed panel or that panel is not zoomable — the early return the + /// old `TabPanel::on_action_toggle_zoom` made on `zoomable(cx).is_none()`. + /// Zooming *out* is never refused: a group that became unzoomable while + /// zoomed still has to be able to give the dock back. + pub(crate) fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context) { + if self.zoomed == zoomed { + return; + } + let panel = self.active_panel(cx); + if zoomed && !panel.as_ref().is_some_and(|panel| panel.zoomable(cx)) { + return; + } + + self.zoomed = zoomed; + cx.emit(if zoomed { + TabGroupEvent::ZoomIn + } else { + TabGroupEvent::ZoomOut + }); + + // Delivered outside this update so a `set_zoomed` handler may call + // back into the group. The old `TabPanel` sent this to itself, where + // `Panel::set_zoomed` defaulted to a no-op, so no panel ever heard it. + if let Some(panel) = panel { + cx.spawn_in(window, async move |_, cx| { + _ = cx.update(|window, cx| panel.set_zoomed(zoomed, window, cx)); + }) + .detach(); + } + cx.notify(); + } + + /// What this group last told `panel` about being active, for handing to + /// [`Self::seed_active`] on the group it is moving to. + pub(crate) fn last_notified_active(&self, panel: PanelId) -> Option { + self.active.last_notified(panel) + } + + /// Record what an arriving panel already believes, so a move between + /// groups does not read as a fresh activation. + pub(crate) fn seed_active(&mut self, panel: PanelId, active: bool) { + self.active.seed(panel, active); + } +} + +impl TabGroup { + /// Every visible panel, in tab order. + fn visible_panels<'a>(&'a self, cx: &'a App) -> impl Iterator> + 'a { + self.panels + .iter() + .filter(|panel| panel.visible(cx)) + .cloned() + } + + /// A locked group cannot be rearranged. Zooming locks it too: a zoomed + /// group is the only thing on screen, so there is nowhere to drop. + fn is_locked(&self) -> bool { + self.constraints.is_locked() || self.zoomed + } + + /// True when this group holds the last visible panel that anything could + /// be rearranged around. Only visible panels count, so a hidden panel does + /// not keep the last visible one draggable and leave the dock empty. + fn is_last_panel(&self, cx: &App) -> bool { + self.constraints.is_alone() && self.visible_panels(cx).count() <= 1 + } + + fn draggable(&self, cx: &App) -> bool { + !self.is_locked() && !self.is_last_panel(cx) + } + + fn droppable(&self) -> bool { + !self.is_locked() + } + + fn focus_active_panel(&self, window: &mut Window, cx: &mut Context) { + if let Some(panel) = self.active_panel(cx) { + panel.focus_handle(cx).focus(window, cx); + } + } + + /// Queue one reconcile per frame that notifies panels of their frame-end + /// net active state. A spawned task, not `defer`, so it runs after every + /// same-frame mutation including a deferred collapse. + fn schedule_active_sync(&mut self, window: &mut Window, cx: &mut Context) { + if !self.active.schedule_sync() { + return; + } + + cx.spawn_in(window, async move |group, cx| { + _ = cx.update(|window, cx| { + let Ok(changes) = group.update(cx, |group, cx| group.reconcile_active(cx)) else { + return; + }; + // Dispatched outside the group's update so a `set_active` + // handler may call back into it without panicking. + for (panel, active) in changes { + panel.set_active(active, window, cx); + } + }); + }) + .detach(); + } + + /// The deliveries this frame owes, deactivations first. The displayed slot + /// is `active_ix` even when the panel there is invisible: rendering falls + /// back to another panel, but the active-state contract does not. + fn reconcile_active(&mut self, cx: &App) -> Vec<(Arc, bool)> { + self.active.sync_finished(); + + let ids: Vec = self.panels.iter().map(|panel| panel.panel_id(cx)).collect(); + let displayed = match self.constraints.is_collapsed() { + true => None, + false => ids.get(self.active_ix).copied(), + }; + + self.active + .reconcile(&ids, displayed) + .into_iter() + .filter_map(|(id, active)| { + ids.iter() + .position(|candidate| *candidate == id) + .map(|ix| (self.panels[ix].clone(), active)) + }) + .collect() + } + + /// Where a dragged panel would land, tracked while it moves over the + /// group's content. + fn on_panel_drag_move( + &mut self, + drag: &DragMoveEvent, + _: &mut Window, + cx: &mut Context, + ) { + let bounds = drag.bounds; + if !bounds.contains(&drag.event.position) { + self.clear_drop_indicator(cx); + return; + } + + let placement = split_placement_at(bounds, drag.event.position); + let dragged = drag.drag(cx); + // The placeholder flies in from wherever the preview currently is. + let source = DropPlaceholderBounds::new( + drag.event.position - dragged.drag_offset() - bounds.origin, + dragged.preview_size(), + ); + + self.sync_drop_placeholder(bounds, placement, dragged.drag_session_id(), source, cx); + } + + /// Same as [`Self::on_panel_drag_move`], for a host-owned drag item. + /// + /// A drag item has no dragged tab to fly the placeholder in from, so it + /// starts at its resting position and only animates between placements. + fn on_item_drag_move( + &mut self, + drag: &DragMoveEvent, + _: &mut Window, + cx: &mut Context, + ) { + let bounds = drag.bounds; + if !bounds.contains(&drag.event.position) { + self.clear_drop_indicator(cx); + return; + } + + let placement = split_placement_at(bounds, drag.event.position); + let source = DropPlaceholderBounds::for_placement(bounds, placement); + + self.sync_drop_placeholder(bounds, placement, ITEM_DRAG_SESSION_ID, source, cx); + } + + fn sync_drop_placeholder( + &mut self, + bounds: Bounds, + placement: Option, + drag_session_id: u64, + source: DropPlaceholderBounds, + cx: &mut Context, + ) { + let to = DropPlaceholderBounds::for_placement(bounds, placement); + + let restart = self.drop_indicator.is_none_or(|indicator| { + indicator.drag_session_id() != drag_session_id || indicator.placement() != placement + }); + + let (from, epoch) = match (restart, self.drop_indicator) { + (false, Some(indicator)) => (indicator.from(), indicator.epoch()), + (_, Some(indicator)) => { + let from = match indicator.drag_session_id() == drag_session_id { + true => indicator.to(), + false => source, + }; + (from, indicator.epoch().wrapping_add(1)) + } + (_, None) => (source, 0), + }; + + self.drop_indicator = Some(DropIndicator::new( + bounds, + placement, + from, + to, + drag_session_id, + epoch, + )); + cx.notify(); + } + + fn clear_drop_indicator(&mut self, cx: &mut Context) { + if self.drop_indicator.take().is_some() { + cx.notify(); + } + } + + /// Report a host-owned drag landing on this group. `placement` is `None` + /// to merge into the tab group instead of splitting. + fn emit_drag_drop( + &mut self, + item: &AnyDrag, + placement: Option, + cx: &mut Context, + ) { + self.drop_indicator = None; + cx.emit(TabGroupEvent::DragDrop { + item: item.clone(), + target: DropTarget::Group { + node: self.node, + placement, + }, + }); + cx.notify(); + } + + /// Resolve where a dropped panel lands and report it as one move. + /// + /// `ix` names a tab slot, which the tab bar supplies and the content area + /// does not; a slot always merges, so it overrides any split the hovering + /// drag had resolved. `activate` decides whether the arriving panel + /// becomes the displayed tab. + fn on_drop( + &mut self, + drag: &DragPanel, + ix: Option, + activate: bool, + cx: &mut Context, + ) { + let indicator = self.drop_indicator.take(); + let placement = match ix { + Some(_) => None, + None => indicator.and_then(|indicator| indicator.placement()), + }; + + // Dropping a panel back onto its own group is a move only when it + // splits out of a group holding more than itself, or when it lands on + // a specific tab slot. + if drag.source() == self.node + && ix.is_none() + && (placement.is_none() || self.panels.len() == 1) + { + cx.notify(); + return; + } + + let target = match placement { + Some(placement) => InsertTarget::Split { + node: self.node, + placement, + size: None, + }, + None => InsertTarget::Tabs { + node: self.node, + ix, + activate, + }, + }; + + cx.emit(TabGroupEvent::Drop { + panel: drag.panel(), + source: drag.source(), + target, + }); + cx.notify(); + } +} + +impl EventEmitter for TabGroup {} + +impl Focusable for TabGroup { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match self.active_panel(cx) { + Some(panel) => panel.focus_handle(cx), + None => self.focus_handle.clone(), + } + } +} + +impl Render for TabGroup { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let context = self.context(cx); + let renderer = self.renderer.clone(); + let focus_handle = self.focus_handle(cx); + let droppable = context.droppable; + let indicator = context.drop_indicator; + + renderer + .frame(&context, window, cx) + .track_focus(&focus_handle) + .tab_group() + .child(renderer.render_tab_bar(&context, window, cx)) + .child( + renderer + .content_frame(&context, window, cx) + // Both drag kinds hang off `droppable` alone. The old + // `TabPanel` nested a second guard inside the same + // droppable test for the host-item handlers, asking + // whether it sat on a tiles canvas; it never did anything, + // because such a group was already locked and `droppable` + // was therefore false. + .when(droppable, |this| { + this.on_drag_move(cx.listener(Self::on_panel_drag_move)) + .on_drop(cx.listener(|this, drag: &DragPanel, _, cx| { + this.on_drop(drag, None, true, cx) + })) + .on_drag_move(cx.listener(Self::on_item_drag_move)) + .on_drop(cx.listener(|this, item: &AnyDrag, _, cx| { + let placement = this + .drop_indicator + .and_then(|indicator| indicator.placement()); + this.emit_drag_drop(item, placement, cx); + })) + }) + .map(|this| match context.active_panel.as_ref() { + Some(panel) => this.child(renderer.render_active_panel( + panel.view(), + &context, + window, + cx, + )), + None => this.children(renderer.render_empty(&context, window, cx)), + }) + .when_some(indicator, |this, indicator| { + this.children(renderer.render_drop_indicator(indicator, window, cx)) + }), + ) + } +} + +type SelectTabHandler = Rc; +type ClosePanelHandler = Rc; +type ToggleZoomHandler = Rc; +type DropPanelHandler = Rc, bool, &mut Window, &mut App)>; +type DropItemHandler = Rc, &mut Window, &mut App)>; + +/// What a skin needs to draw a tab group, and the callbacks it invokes rather +/// than reimplementing behavior. +#[derive(Clone)] +pub struct TabGroupContext { + node: NodeId, + panels: Vec>, + active_panel: Option>, + active_ix: usize, + zoomed: bool, + collapsed: bool, + locked: bool, + draggable: bool, + droppable: bool, + can_close: bool, + drop_indicator: Option, + on_select_tab: SelectTabHandler, + on_close: ClosePanelHandler, + on_toggle_zoom: ToggleZoomHandler, + on_drop_panel: DropPanelHandler, + on_drop_item: DropItemHandler, +} + +impl TabGroupContext { + /// The `Tabs` node this group mirrors, for a skin that needs to name the + /// group in a drag payload or a drop target. + pub fn node(&self) -> NodeId { + self.node + } + + /// Every panel in the group, in tab order — visible or not. A skin filters + /// with [`PanelView::visible`] when it draws. + pub fn panels(&self) -> &[Arc] { + &self.panels + } + + pub fn active_ix(&self) -> usize { + self.active_ix + } + + /// The panel on screen, which is the displayed tab unless that panel has + /// gone invisible. + pub fn active_panel(&self) -> Option<&Arc> { + self.active_panel.as_ref() + } + + pub fn drop_indicator(&self) -> Option { + self.drop_indicator + } + + pub fn is_zoomed(&self) -> bool { + self.zoomed + } + + pub fn is_collapsed(&self) -> bool { + self.collapsed + } + + /// Whether closing the displayed panel is allowed at all, so a skin knows + /// whether to offer a Close control. + pub fn can_close(&self) -> bool { + self.can_close + } + + pub fn is_locked(&self) -> bool { + self.locked + } + + pub fn is_draggable(&self) -> bool { + self.draggable + } + + pub fn is_droppable(&self) -> bool { + self.droppable + } + + pub fn select_tab(&self, ix: usize, window: &mut Window, cx: &mut App) { + (self.on_select_tab)(ix, window, cx); + } + + pub fn close(&self, panel: PanelId, window: &mut Window, cx: &mut App) { + (self.on_close)(panel, window, cx); + } + + pub fn toggle_zoom(&self, window: &mut Window, cx: &mut App) { + (self.on_toggle_zoom)(window, cx); + } + + /// The drag payload for the tab at `ix`, for a skin wiring `on_drag` onto + /// its tabs. `None` when `ix` names no panel. + pub fn drag_panel(&self, ix: usize, cx: &App) -> Option { + self.panels + .get(ix) + .map(|panel| DragPanel::new(panel.panel_id(cx), self.node)) + } + + /// A panel dropped on the tab bar. `ix` names the slot it lands in, or + /// `None` to append; `activate` decides whether it becomes displayed. + pub fn drop_panel( + &self, + drag: DragPanel, + ix: Option, + activate: bool, + window: &mut Window, + cx: &mut App, + ) { + (self.on_drop_panel)(drag, ix, activate, window, cx); + } + + /// A host-owned drag dropped on the tab bar. `placement` is `None` to + /// merge into this group rather than split beside it. + pub fn drop_item( + &self, + item: AnyDrag, + placement: Option, + window: &mut Window, + cx: &mut App, + ) { + (self.on_drop_item)(item, placement, window, cx); + } +} + +/// Appearance for a tab group. Base draws none of it. +/// +/// The two frame hooks return the element itself rather than wrapping one, +/// because base attaches focus, keyboard grouping, and drop handling to the +/// very elements the skin styles: a wrapper would put the hit area and the +/// painted area on different elements. +#[allow(unused_variables)] +pub trait TabGroupRenderer: 'static { + /// The group's outer frame, which base tracks focus on. + /// + /// Identified rather than plain, so a skin can add a role, a tooltip, or + /// scroll tracking; `Stateful

` does everything base needs from it. + fn frame(&self, group: &TabGroupContext, window: &mut Window, cx: &mut App) -> Stateful
{ + div().id("tab-group") + } + + /// The region below the tab bar, which base installs drop handling on. + fn content_frame( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> Stateful
{ + div().id("tab-group-content") + } + + fn render_tab_bar( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement; + + /// How the displayed panel's view is placed in the content region. The + /// skin receives the view rather than an element so it can decide how the + /// view is cached and stretched. + fn render_active_panel( + &self, + panel: AnyView, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + panel.into_any_element() + } + + fn render_drop_indicator( + &self, + indicator: DropIndicator, + window: &mut Window, + cx: &mut App, + ) -> Option { + None + } + + fn render_empty( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> Option { + None + } +} + +/// The renderer a group starts with: the displayed panel and nothing else. +pub(crate) struct BareTabGroup; + +impl TabGroupRenderer for BareTabGroup { + fn render_tab_bar(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> AnyElement { + Empty.into_any_element() + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use gpui::{ + AppContext as _, Entity, Modifiers, MouseButton, StatefulInteractiveElement as _, + Styled as _, TestAppContext, VisualTestContext, point, px, size, + }; + + use super::*; + use crate::dock::test_support::{ + PanelSignal, build_group, drain, drain_active, log_of, panel_id, + }; + + /// The node `build_group` gives its group. + fn group_node() -> NodeId { + NodeId::from_u64(1) + } + + fn elsewhere() -> NodeId { + NodeId::from_u64(7) + } + + fn content_bounds() -> Bounds { + Bounds { + origin: point(px(0.), px(0.)), + size: size(px(400.), px(300.)), + } + } + + /// Collect the group's outgoing intents as readable strings, so an + /// assertion reads as the sentence the event means. + fn record_events( + group: &Entity, + cx: &mut VisualTestContext, + ) -> Rc>> { + let events: Rc>> = Rc::default(); + let sink = events.clone(); + cx.update(|_, cx| { + cx.subscribe(group, move |_, event: &TabGroupEvent, _| { + sink.borrow_mut().push(describe(event)); + }) + .detach(); + }); + events + } + + fn describe(event: &TabGroupEvent) -> String { + match event { + TabGroupEvent::Drop { + panel, + source, + target, + } => match target { + InsertTarget::Tabs { node, ix, activate } => format!( + "drop panel {} from {} into tabs {} at {:?} activate={}", + panel.as_u64(), + source.as_u64(), + node.as_u64(), + ix, + activate + ), + InsertTarget::Split { + node, placement, .. + } => format!( + "drop panel {} from {} split {} {}", + panel.as_u64(), + source.as_u64(), + node.as_u64(), + placement + ), + InsertTarget::Tile { .. } => "drop tile".into(), + }, + TabGroupEvent::DragDrop { target, .. } => match target { + DropTarget::Group { node, placement } => { + format!("item onto {} at {:?}", node.as_u64(), placement) + } + DropTarget::Canvas => "item onto canvas".into(), + }, + TabGroupEvent::ClosePanel { panel } => format!("close {}", panel.as_u64()), + TabGroupEvent::ActiveChanged { ix } => format!("active {ix}"), + TabGroupEvent::ZoomIn => "zoom in".into(), + TabGroupEvent::ZoomOut => "zoom out".into(), + } + } + + #[gpui::test] + fn a_group_announces_its_first_panel_active(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a"], cx); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), vec![("a", true)]); + let _ = (group, panels); + } + + #[gpui::test] + fn selecting_another_tab_deactivates_then_activates(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a", "b"], cx); + cx.run_until_parked(); + drain_active(&log); + + cx.update(|window, cx| { + group.update(cx, |group, cx| group.select_tab(1, window, cx)); + }); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), vec![("a", false), ("b", true)]); + } + + #[gpui::test] + fn the_context_snapshots_the_group_state(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a", "b"], cx); + + let seen = cx.update(|_, cx| { + group.update(cx, |group, cx| { + let context = group.context(cx); + ( + context.panels().len(), + context.active_ix(), + context.is_zoomed(), + ) + }) + }); + + assert_eq!(seen, (2, 0, false)); + } + + /// A panel hidden while it holds the displayed slot is still the panel + /// told it is active; only what gets drawn falls back to a visible one. + #[gpui::test] + fn a_hidden_displayed_panel_is_still_the_active_one(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a", "b"], cx); + cx.update(|_, cx| { + panels[0].update(cx, |panel, cx| panel.set_visible(false, cx)); + }); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), vec![("a", true)]); + assert_eq!( + cx.update(|_, cx| group.read(cx).active_panel(cx).unwrap().panel_name(cx)), + "b", + "rendering falls back to the first visible panel" + ); + } + + /// `on_removed` is a departing panel's deactivation signal, so the group + /// must not also announce `false` to it. + #[gpui::test] + fn a_panel_dropped_from_the_group_is_never_told_it_went_inactive(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a", "b"], cx); + cx.run_until_parked(); + drain(&log); + + cx.update(|window, cx| { + let remaining: Vec> = vec![Arc::new(panels[1].clone())]; + group.update(cx, |group, cx| { + group.sync_from_tree(remaining, 0, window, cx) + }); + }); + cx.run_until_parked(); + + assert_eq!(drain_active(&log), vec![("b", true)]); + } + + #[gpui::test] + fn selecting_a_tab_that_is_not_there_changes_nothing(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a", "b"], cx); + cx.run_until_parked(); + drain_active(&log); + let events = record_events(&group, cx); + + cx.update(|window, cx| { + group.update(cx, |group, cx| group.select_tab(5, window, cx)); + }); + cx.run_until_parked(); + + assert_eq!(cx.update(|_, cx| group.read(cx).active_ix()), 0); + assert!(events.borrow().is_empty()); + assert!(drain_active(&log).is_empty()); + } + + #[gpui::test] + fn a_locked_group_can_be_neither_dragged_nor_dropped_into(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a", "b"], cx); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(false), window, cx) + }) + }); + let unlocked = cx.update(|_, cx| { + let context = group.read(cx).context(cx); + (context.is_draggable(), context.is_droppable()) + }); + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints( + TabGroupConstraints::in_split(false).dock_locked(true), + window, + cx, + ) + }) + }); + let locked = cx.update(|_, cx| { + let context = group.read(cx).context(cx); + (context.is_draggable(), context.is_droppable()) + }); + + assert_eq!(unlocked, (true, true)); + assert_eq!(locked, (false, false)); + } + + /// Zooming is a lock of its own: a zoomed group fills the dock, so there + /// is nothing beside it to drop against. + #[gpui::test] + fn a_zoomed_group_takes_no_drops(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a", "b"], cx); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(false), window, cx); + group.set_zoomed(true, window, cx); + }) + }); + + assert!(!cx.update(|_, cx| group.read(cx).context(cx).is_droppable())); + } + + /// Dragging the last visible panel out of the only group would leave the + /// dock empty and undroppable, so it is refused. + #[gpui::test] + fn the_only_groups_last_visible_panel_cannot_be_dragged_out(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(true), window, cx) + }) + }); + let alone = cx.update(|_, cx| group.read(cx).context(cx).is_draggable()); + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(false), window, cx) + }) + }); + let beside_others = cx.update(|_, cx| group.read(cx).context(cx).is_draggable()); + + assert!(!alone); + assert!(beside_others); + } + + #[gpui::test] + fn a_panel_from_another_group_lands_as_one_move(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + let events = record_events(&group, cx); + + let drag = DragPanel::new(PanelId::from_u64(99), elsewhere()); + cx.update(|_, cx| { + group.update(cx, |group, cx| group.on_drop(&drag, None, true, cx)); + }); + cx.run_until_parked(); + + assert_eq!( + *events.borrow(), + vec!["drop panel 99 from 7 into tabs 1 at None activate=true"] + ); + } + + #[gpui::test] + fn dropping_a_panel_back_onto_its_own_group_is_ignored(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a", "b"], cx); + let events = record_events(&group, cx); + + let panel = panel_id(&panels[0], cx); + let drag = DragPanel::new(panel, group_node()); + cx.update(|_, cx| { + group.update(cx, |group, cx| group.on_drop(&drag, None, true, cx)); + }); + cx.run_until_parked(); + + assert!(events.borrow().is_empty()); + } + + #[gpui::test] + fn a_hovering_split_turns_a_drop_into_a_split(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + let events = record_events(&group, cx); + + let drag = DragPanel::new(PanelId::from_u64(99), elsewhere()); + cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.sync_drop_placeholder( + content_bounds(), + Some(Placement::Right), + drag.drag_session_id(), + DropPlaceholderBounds::for_placement(content_bounds(), None), + cx, + ); + group.on_drop(&drag, None, true, cx); + }); + }); + cx.run_until_parked(); + + assert_eq!(*events.borrow(), vec!["drop panel 99 from 7 split 1 Right"]); + } + + /// The tab bar reports the slot a drop landed on, and a slot always means + /// "into these tabs" however the content area had resolved the cursor. + #[gpui::test] + fn a_tab_slot_overrides_the_hovering_split(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + let events = record_events(&group, cx); + + let drag = DragPanel::new(PanelId::from_u64(99), elsewhere()); + cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.sync_drop_placeholder( + content_bounds(), + Some(Placement::Right), + drag.drag_session_id(), + DropPlaceholderBounds::for_placement(content_bounds(), None), + cx, + ); + group.on_drop(&drag, Some(0), true, cx); + }); + }); + cx.run_until_parked(); + + assert_eq!( + *events.borrow(), + vec!["drop panel 99 from 7 into tabs 1 at Some(0) activate=true"] + ); + } + + /// A lone panel splitting out of its own group would empty that group and + /// refill it, which is no move at all. + #[gpui::test] + fn a_lone_panel_cannot_split_out_of_its_own_group(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a"], cx); + let events = record_events(&group, cx); + + let drag = DragPanel::new(panel_id(&panels[0], cx), group_node()); + cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.sync_drop_placeholder( + content_bounds(), + Some(Placement::Right), + drag.drag_session_id(), + DropPlaceholderBounds::for_placement(content_bounds(), None), + cx, + ); + group.on_drop(&drag, None, true, cx); + }); + }); + cx.run_until_parked(); + + assert!(events.borrow().is_empty()); + } + + #[gpui::test] + fn the_placeholder_replays_only_when_the_target_moves(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + let bounds = content_bounds(); + let source = DropPlaceholderBounds::new(point(px(10.), px(10.)), size(px(96.), px(30.))); + + let sync = |placement: Option, cx: &mut VisualTestContext| { + cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.sync_drop_placeholder(bounds, placement, 42, source, cx); + group.drop_indicator.unwrap() + }) + }) + }; + + let first = sync(Some(Placement::Left), cx); + let again = sync(Some(Placement::Left), cx); + let moved = sync(Some(Placement::Right), cx); + + assert_eq!(first.epoch(), 0); + assert_eq!(first.from(), source, "the first run flies in from the drag"); + assert_eq!(again.epoch(), 0, "an unchanged target keeps animating"); + assert_eq!(again.from(), source); + assert_eq!(moved.epoch(), 1, "a moved target replays from where it was"); + assert_eq!(moved.from(), first.to()); + assert_eq!( + moved.to(), + DropPlaceholderBounds::for_placement(bounds, Some(Placement::Right)) + ); + } + + /// The indicator is hit state, not a latch: a drag cancelled while + /// hovering must not leave the placeholder painted. + #[gpui::test] + fn the_indicator_is_withheld_when_no_drag_is_in_flight(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + + let (stored, published) = cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.sync_drop_placeholder( + content_bounds(), + Some(Placement::Top), + 42, + DropPlaceholderBounds::for_placement(content_bounds(), None), + cx, + ); + ( + group.drop_indicator.is_some(), + group.context(cx).drop_indicator().is_some(), + ) + }) + }); + + assert!(stored); + assert!(!published); + } + + #[gpui::test] + fn a_host_drag_reports_the_group_and_the_edge_it_resolved(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + let events = record_events(&group, cx); + + cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.emit_drag_drop(&AnyDrag::new(7u32), Some(Placement::Bottom), cx) + }); + }); + cx.run_until_parked(); + + assert_eq!(*events.borrow(), vec!["item onto 1 at Some(Bottom)"]); + } + + #[gpui::test] + fn closing_asks_the_container_rather_than_editing_the_group(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a", "b"], cx); + let events = record_events(&group, cx); + let panel = panel_id(&panels[1], cx); + + cx.update(|_, cx| { + group.update(cx, |group, cx| { + group.close_panel(panel, cx); + // A panel that is not a member is not this group's to close. + group.close_panel(PanelId::from_u64(4242), cx); + }); + }); + cx.run_until_parked(); + + assert_eq!(*events.borrow(), vec![format!("close {}", panel.as_u64())]); + assert_eq!( + cx.update(|_, cx| group.read(cx).panels().len()), + 2, + "the group still holds both panels until the container edits the tree" + ); + } + + #[gpui::test] + fn zooming_toggles_and_tells_the_displayed_panel(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + let events = record_events(&group, cx); + + cx.update(|window, cx| group.update(cx, |group, cx| group.toggle_zoom(window, cx))); + cx.run_until_parked(); + cx.update(|window, cx| group.update(cx, |group, cx| group.toggle_zoom(window, cx))); + cx.run_until_parked(); + + assert_eq!(*events.borrow(), vec!["zoom in", "zoom out"]); + assert!(!cx.update(|_, cx| group.read(cx).is_zoomed())); + assert_eq!( + drain(&log) + .into_iter() + .filter(|(_, signal)| matches!(signal, PanelSignal::Zoomed(_))) + .collect::>(), + vec![ + ("a", PanelSignal::Zoomed(true)), + ("a", PanelSignal::Zoomed(false)) + ], + "the old TabPanel sent this to itself, where the default no-op swallowed it" + ); + } + + /// A real drag over a locked group must resolve nothing: base installs no + /// drop handling on one, so the placement is never computed and the layout + /// tree is never asked to move anything. + #[gpui::test] + fn a_drag_over_a_locked_group_resolves_nothing(cx: &mut TestAppContext) { + let (group, _calls, cx) = build_skinned_group(&["a", "b"], cx); + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints( + TabGroupConstraints::in_split(false).dock_locked(true), + window, + cx, + ) + }) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + drag_from_the_tab_into_the_content(cx); + + assert!( + cx.update(|_, cx| group.read(cx).drop_indicator.is_none()), + "a locked group installs no drag-move listener, so nothing resolved" + ); + } + + /// `Dock::new` marks a dock's last group unclosable so the dock cannot be + /// emptied out from under itself. + #[gpui::test] + fn a_group_the_container_sealed_shut_refuses_to_close(cx: &mut TestAppContext) { + let log = log_of(); + let (group, panels, cx) = build_group(&log, &["a", "b"], cx); + let events = record_events(&group, cx); + let panel = panel_id(&panels[0], cx); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints( + TabGroupConstraints::in_split(false).closable(false), + window, + cx, + ); + group.close_panel(panel, cx); + }) + }); + cx.run_until_parked(); + + assert!(!cx.update(|_, cx| group.read(cx).context(cx).can_close())); + assert!(events.borrow().is_empty()); + } + + /// The last visible panel of the only group has nowhere to go, so it is + /// not closable either — closing it would empty the region out from under + /// itself. Placing a sibling beside the group makes the same panel + /// closable again, which is what pins the reason to `alone` rather than to + /// something else the sealed default also forbids. + #[gpui::test] + fn the_only_groups_last_panel_is_not_closable(cx: &mut TestAppContext) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a"], cx); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(true), window, cx) + }) + }); + let alone = cx.update(|_, cx| group.read(cx).context(cx).can_close()); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(false), window, cx) + }) + }); + let beside_a_sibling = cx.update(|_, cx| group.read(cx).context(cx).can_close()); + + assert!(!alone); + assert!(beside_a_sibling); + } + + /// Collapsing takes the displayed panel off screen, and the active-state + /// contract counts that as no panel being displayed. + #[gpui::test] + fn collapsing_deactivates_the_displayed_panel_and_expanding_restores_it( + cx: &mut TestAppContext, + ) { + let log = log_of(); + let (group, _panels, cx) = build_group(&log, &["a", "b"], cx); + cx.run_until_parked(); + assert_eq!(drain_active(&log), vec![("a", true)]); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints( + TabGroupConstraints::in_split(false).collapsed(true), + window, + cx, + ) + }) + }); + cx.run_until_parked(); + assert_eq!(drain_active(&log), vec![("a", false)]); + + cx.update(|window, cx| { + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(false), window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(drain_active(&log), vec![("a", true)]); + } + + // ---- the renderer seam ---- + + /// Where `RecordingRenderer` puts its content frame. Deliberately not the + /// window's own origin or size, so a listener on the outer frame would + /// report different bounds. + fn skin_content() -> Bounds { + Bounds { + origin: point(px(100.), px(50.)), + size: size(px(400.), px(300.)), + } + } + + /// A skin that records which hooks base calls, and lays its two frames out + /// at known coordinates so a test can tell them apart by hit geometry. + struct RecordingRenderer { + calls: Rc>>, + } + + impl RecordingRenderer { + fn saw(&self, call: &'static str) { + self.calls.borrow_mut().push(call); + } + } + + impl TabGroupRenderer for RecordingRenderer { + fn frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ + self.saw("frame"); + div().id("skin-frame").relative().size_full() + } + + fn content_frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ + self.saw("content_frame"); + div() + .id("skin-content") + .absolute() + .left(skin_content().origin.x) + .top(skin_content().origin.y) + .w(skin_content().size.width) + .h(skin_content().size.height) + } + + fn render_tab_bar( + &self, + group: &TabGroupContext, + _: &mut Window, + cx: &mut App, + ) -> AnyElement { + self.saw("tab_bar"); + div() + .id("skin-tab") + .absolute() + .left(px(0.)) + .top(px(0.)) + .w(px(80.)) + .h(px(24.)) + .when_some(group.drag_panel(0, cx), |this, drag| { + this.on_drag(drag, |drag, offset, _, cx| { + drag.set_drag_offset(offset); + cx.new(|_| drag.clone()) + }) + }) + .into_any_element() + } + + fn render_active_panel( + &self, + panel: AnyView, + _: &TabGroupContext, + _: &mut Window, + _: &mut App, + ) -> AnyElement { + self.saw("active_panel"); + panel.into_any_element() + } + + fn render_empty( + &self, + _: &TabGroupContext, + _: &mut Window, + _: &mut App, + ) -> Option { + self.saw("empty"); + None + } + + fn render_drop_indicator( + &self, + _: DropIndicator, + _: &mut Window, + _: &mut App, + ) -> Option { + self.saw("drop_indicator"); + None + } + } + + fn build_skinned_group<'a>( + names: &[&'static str], + cx: &'a mut TestAppContext, + ) -> ( + Entity, + Rc>>, + &'a mut VisualTestContext, + ) { + let calls: Rc>> = Rc::default(); + let renderer = Rc::new(RecordingRenderer { + calls: calls.clone(), + }); + let (group, cx) = cx.add_window_view(|window, cx| { + TabGroup::new(NodeId::from_u64(1), window, cx).with_renderer(renderer) + }); + + let names = names.to_vec(); + let log = log_of(); + cx.update(|window, cx| { + let views: Vec> = names + .iter() + .map(|name| { + Arc::new(crate::dock::test_support::TestPanel::logging( + name, &log, cx, + )) as _ + }) + .collect(); + group.update(cx, |group, cx| { + group.set_constraints(TabGroupConstraints::in_split(false), window, cx); + group.sync_from_tree(views, 0, window, cx); + }); + }); + cx.run_until_parked(); + calls.borrow_mut().clear(); + + (group, calls, cx) + } + + /// Press on the skin's tab, start the drag, and move into the right-hand + /// third of `skin_content()`. + fn drag_from_the_tab_into_the_content(cx: &mut VisualTestContext) { + cx.simulate_mouse_down( + point(px(20.), px(10.)), + MouseButton::Left, + Modifiers::none(), + ); + cx.simulate_mouse_move( + point(px(30.), px(14.)), + MouseButton::Left, + Modifiers::none(), + ); + cx.simulate_mouse_move( + point(px(450.), px(200.)), + MouseButton::Left, + Modifiers::none(), + ); + } + + /// The composition contract every later renderer copies: which hooks base + /// calls, and in what order. + #[gpui::test] + fn the_renderer_composes_frame_then_tab_bar_then_content(cx: &mut TestAppContext) { + let (_group, calls, cx) = build_skinned_group(&["a"], cx); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + *calls.borrow(), + vec!["frame", "tab_bar", "content_frame", "active_panel"], + "the tab bar is a sibling of the content, not a child of it" + ); + } + + /// With nothing to display base asks for the empty element instead of the + /// active panel, and never for both. + #[gpui::test] + fn an_empty_group_asks_the_renderer_for_its_empty_state(cx: &mut TestAppContext) { + let (_group, calls, cx) = build_skinned_group(&[], cx); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + *calls.borrow(), + vec!["frame", "tab_bar", "content_frame", "empty"] + ); + } + + /// The drop listeners must sit on the content frame, not the outer frame: + /// a drag over the group resolves against the content frame's own bounds, + /// which the skin — not base — decides. + #[gpui::test] + fn the_content_frame_is_what_a_drag_is_measured_against(cx: &mut TestAppContext) { + let (group, calls, cx) = build_skinned_group(&["a", "b"], cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + drag_from_the_tab_into_the_content(cx); + + let indicator = cx + .update(|_, cx| group.read(cx).drop_indicator) + .expect("the content frame's drag-move listener ran"); + + assert_eq!( + indicator.bounds(), + skin_content(), + "measured against the content frame, not the full-size outer frame" + ); + assert_eq!(indicator.placement(), Some(Placement::Right)); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!( + calls.borrow().contains(&"drop_indicator"), + "a published indicator is handed to the renderer to draw" + ); + } + + /// A drop landing on the content frame reports a move the container can + /// apply, and clears the hover state behind it. + #[gpui::test] + fn dropping_on_the_content_frame_reports_the_move(cx: &mut TestAppContext) { + let (group, _calls, cx) = build_skinned_group(&["a", "b"], cx); + let events = record_events(&group, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + drag_from_the_tab_into_the_content(cx); + cx.simulate_mouse_up( + point(px(450.), px(200.)), + MouseButton::Left, + Modifiers::none(), + ); + cx.run_until_parked(); + + assert_eq!(events.borrow().len(), 1, "got {:?}", events.borrow()); + assert!( + events.borrow()[0].ends_with("split 1 Right"), + "got {:?}", + events.borrow() + ); + assert!(cx.update(|_, cx| group.read(cx).drop_indicator.is_none())); + } +} diff --git a/vendor/gpui-base/src/dock/test_support.rs b/vendor/gpui-base/src/dock/test_support.rs new file mode 100644 index 00000000..799f43e1 --- /dev/null +++ b/vendor/gpui-base/src/dock/test_support.rs @@ -0,0 +1,196 @@ +//! Panel doubles shared by the dock's tests. +//! +//! This lives beside the production modules rather than inside one module's +//! `mod tests` so `tab_group`, `dock_area`, and the cutover tests can share a +//! single panel double and a single ordered delivery log. Ported from the +//! `TabPanel` tests in `crates/ui/src/dock/tab_panel.rs`. + +use std::sync::{Arc, Mutex}; + +use gpui::{ + App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, Render, TestAppContext, VisualTestContext, WeakEntity, Window, +}; + +use super::layout::{NodeId, PanelId}; +use super::panel::{Panel, PanelEvent, PanelView}; +use super::tab_group::{TabGroup, TabGroupConstraints}; + +/// One thing a panel was told, in delivery order. +/// +/// `set_active` and the membership callbacks share one log because their +/// relative order is itself part of the contract under test: a panel joining a +/// group must see `Added` before it is told it is active. +/// +/// `Removed` is also where a deliberate divergence from `crates/ui` shows up. +/// The old `TabPanel::detach_panel` calls `on_removed` on every detach, +/// including the detach half of a drag between groups, so a moved panel is +/// told it was removed and then added again. In the tree world a move never +/// leaves the tree — `PaneTree::move_panel` reports no `removed_panels`, and +/// `EditResult::removed_panels` documents that a moved panel is absent from it +/// precisely so its entity survives. So under the new contract a moved panel +/// must never see `Removed`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PanelSignal { + Active(bool), + Zoomed(bool), + Added, + Removed, +} + +/// Shared, cross-panel ordered log of everything the panels were told. +pub(crate) type Log = Arc>>; + +pub(crate) fn log_of() -> Log { + Log::default() +} + +/// Take everything logged so far, leaving the log empty. +pub(crate) fn drain(log: &Log) -> Vec<(&'static str, PanelSignal)> { + std::mem::take(&mut *log.lock().unwrap()) +} + +/// [`drain`], keeping only the `set_active` edges. Most tests care about the +/// active-state contract and would otherwise have to filter the membership +/// callbacks out by hand at every assertion. +pub(crate) fn drain_active(log: &Log) -> Vec<(&'static str, bool)> { + drain(log) + .into_iter() + .filter_map(|(name, signal)| match signal { + PanelSignal::Active(active) => Some((name, active)), + _ => None, + }) + .collect() +} + +pub(crate) struct TestPanel { + name: &'static str, + focus_handle: FocusHandle, + log: Log, + visible: bool, + zoomable: bool, + pub(crate) group: Option>, +} + +impl TestPanel { + /// A panel whose deliveries go nowhere. For tests that only need a panel + /// to exist. + pub(crate) fn new(name: &'static str, cx: &mut App) -> Entity { + Self::logging(name, &log_of(), cx) + } + + pub(crate) fn logging(name: &'static str, log: &Log, cx: &mut App) -> Entity { + let log = log.clone(); + cx.new(|cx| Self { + name, + focus_handle: cx.focus_handle(), + log, + visible: true, + zoomable: true, + group: None, + }) + } + + pub(crate) fn set_visible(&mut self, visible: bool, cx: &mut Context) { + self.visible = visible; + cx.notify(); + } + + /// A panel that refuses to zoom, which its group must honour. + pub(crate) fn set_zoomable(&mut self, zoomable: bool, cx: &mut Context) { + self.zoomable = zoomable; + cx.notify(); + } + + fn record(&self, signal: PanelSignal) { + self.log.lock().unwrap().push((self.name, signal)); + } +} + +impl Panel for TestPanel { + /// The panel's own name, so a dumped layout is legible in assertions. + fn panel_name(&self) -> &'static str { + self.name + } + + fn visible(&self, _: &App) -> bool { + self.visible + } + + fn zoomable(&self, _: &App) -> bool { + self.zoomable + } + + fn set_active(&mut self, active: bool, _: &mut Window, _: &mut Context) { + self.record(PanelSignal::Active(active)); + } + + fn set_zoomed(&mut self, zoomed: bool, _: &mut Window, _: &mut Context) { + self.record(PanelSignal::Zoomed(zoomed)); + } + + fn on_added_to(&mut self, group: WeakEntity, _: &mut Window, _: &mut Context) { + self.group = Some(group); + self.record(PanelSignal::Added); + } + + fn on_removed(&mut self, _: &mut Window, _: &mut Context) { + self.group = None; + self.record(PanelSignal::Removed); + } +} + +impl EventEmitter for TestPanel {} + +impl Focusable for TestPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for TestPanel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +pub(crate) fn panel_id(panel: &Entity, cx: &TestAppContext) -> PanelId { + cx.read(|cx| Arc::new(panel.clone()).panel_id(cx)) +} + +/// Open a window holding one tab group over freshly built panels named +/// `names`, with the first tab displayed. +pub(crate) fn build_group<'a>( + log: &Log, + names: &[&'static str], + cx: &'a mut TestAppContext, +) -> ( + Entity, + Vec>, + &'a mut VisualTestContext, +) { + let (group, cx) = + cx.add_window_view(|window, cx| TabGroup::new(NodeId::from_u64(1), window, cx)); + + let names = names.to_vec(); + let log = log.clone(); + let panels = cx.update(|window, cx| { + let panels: Vec<_> = names + .iter() + .map(|name| TestPanel::logging(name, &log, cx)) + .collect(); + let views: Vec> = panels + .iter() + .map(|panel| Arc::new(panel.clone()) as Arc) + .collect(); + group.update(cx, |group, cx| { + // A freshly built group is sealed; place it beside siblings so + // tests that do not care about constraints get a working group. + group.set_constraints(TabGroupConstraints::in_split(false), window, cx); + group.sync_from_tree(views, 0, window, cx); + }); + panels + }); + + (group, panels, cx) +} diff --git a/vendor/gpui-base/src/dock/tiles_geometry.rs b/vendor/gpui-base/src/dock/tiles_geometry.rs new file mode 100644 index 00000000..5a051dca --- /dev/null +++ b/vendor/gpui-base/src/dock/tiles_geometry.rs @@ -0,0 +1,559 @@ +//! Pure arithmetic for the tiles canvas: magnetic snapping, boundary +//! constraints, resize math, and grid rounding, plus the undo/redo history +//! record for a tile change. +//! +//! This module decides *where* a tile lands. It draws nothing: the tile +//! frame, the drag-bar chrome, and the resize-handle visuals are appearance +//! and live in `crates/ui`. + +use gpui::{Bounds, EntityId, Pixels, Point, Size, px, size}; + +use crate::history::HistoryItem; + +/// A tile smaller than this on either axis cannot be usefully manipulated. +/// This is behavior, not presentation: it bounds what resize/drag arithmetic +/// will produce. +pub const MINIMUM_SIZE: Size = size(px(100.), px(100.)); + +/// Height of the tile's drag bar. This is hit-target geometry the skin must +/// agree with when it paints the drag bar, not a visual constant, so it +/// lives here rather than in `crates/ui`. +pub const DRAG_BAR_HEIGHT: Pixels = px(30.); + +/// Size of the resize-handle hit target at a tile's corner/edge. Same +/// reasoning as [`DRAG_BAR_HEIGHT`]. +pub const HANDLE_SIZE: Pixels = px(5.0); + +/// A recorded change to one tile's bounds or z-order, for undo/redo. +/// +/// Exactly one of the bounds pair or the order pair is populated per change, +/// mirroring the two ways a tile can be edited (move/resize vs. reorder). +#[derive(Clone, PartialEq, Debug)] +pub struct TileChange { + tile_id: EntityId, + old_bounds: Option>, + new_bounds: Option>, + version: usize, +} + +impl TileChange { + /// A change record for a tile whose bounds moved or resized. + pub fn bounds_change( + tile_id: EntityId, + old_bounds: Bounds, + new_bounds: Bounds, + ) -> Self { + Self { + tile_id, + old_bounds: Some(old_bounds), + new_bounds: Some(new_bounds), + version: 0, + } + } + + pub fn tile_id(&self) -> EntityId { + self.tile_id + } + + pub fn old_bounds(&self) -> Option> { + self.old_bounds + } + + pub fn new_bounds(&self) -> Option> { + self.new_bounds + } +} + +impl HistoryItem for TileChange { + fn version(&self) -> usize { + self.version + } + + fn set_version(&mut self, version: usize) { + self.version = version; + } +} + +/// Which edge (or corner) of a tile a resize drag is manipulating. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ResizeSide { + Left, + Right, + Top, + Bottom, + BottomRight, +} + +/// In-flight state for a resize drag: which side is moving, and the pointer +/// position and tile bounds recorded at the last processed move event. +#[derive(Clone, Copy, Debug)] +pub struct ResizeDrag { + side: ResizeSide, + last_position: Point, + last_bounds: Bounds, +} + +impl ResizeDrag { + pub fn new( + side: ResizeSide, + last_position: Point, + last_bounds: Bounds, + ) -> Self { + Self { + side, + last_position, + last_bounds, + } + } + + pub fn side(&self) -> ResizeSide { + self.side + } + + pub fn last_bounds(&self) -> Bounds { + self.last_bounds + } + + pub fn with_last_position(mut self, last_position: Point) -> Self { + self.last_position = last_position; + self + } + + pub fn with_last_bounds(mut self, last_bounds: Bounds) -> Self { + self.last_bounds = last_bounds; + self + } +} + +/// Snap `edge` to the nearest value in `candidates` whose distance is strictly +/// below `threshold`. Returns `None` when nothing is close enough. +pub fn snap_edge(edge: Pixels, candidates: &[Pixels], threshold: Pixels) -> Option { + let mut best: Option = None; + let mut best_dist = threshold; + for &candidate in candidates { + let dist = (edge - candidate).abs(); + if dist < best_dist { + best_dist = dist; + best = Some(candidate); + } + } + best +} + +/// Compute the final bounds for a resize, applying magnetic edge snapping to +/// neighboring panels and falling back to grid rounding when no neighbor edge +/// is within `grid_size`. +/// +/// Which edges move is inferred from the provided `Option`s, mirroring the +/// original `Tiles::resize`: +/// - `new_x` set => left edge moves (right edge pinned) +/// - `new_width` set, `new_x` not => right edge moves (left edge pinned) +/// - `new_y` set => top edge moves (bottom edge pinned) +/// - `new_height` set, `new_y` not => bottom edge moves (top edge pinned) +pub fn compute_resized_bounds( + previous: Bounds, + new_x: Option, + new_y: Option, + new_width: Option, + new_height: Option, + other_bounds: &[Bounds], + grid_size: Pixels, +) -> Bounds { + // Candidate snap edges from neighbouring panels. + let mut x_edges = Vec::with_capacity(other_bounds.len() * 2); + let mut y_edges = Vec::with_capacity(other_bounds.len() * 2); + for bounds in other_bounds { + x_edges.push(bounds.left()); + x_edges.push(bounds.right()); + y_edges.push(bounds.top()); + y_edges.push(bounds.bottom()); + } + + let prev_right = previous.origin.x + previous.size.width; + let prev_bottom = previous.origin.y + previous.size.height; + + // --- X axis --- + let (final_x, final_width) = if let Some(x) = new_x { + // Left edge moving; right edge pinned. Canvas-left (0) is also a target. + let raw_left = x.max(px(0.)); + let mut candidates = x_edges.clone(); + candidates.push(px(0.)); + let snapped_left = snap_edge(raw_left, &candidates, grid_size) + .unwrap_or_else(|| round_to_grid(raw_left, grid_size)); + let width = (prev_right - snapped_left).max(MINIMUM_SIZE.width); + (snapped_left, width) + } else if let Some(width) = new_width { + // Right edge moving; left edge pinned. + let raw_right = previous.origin.x + width; + let snapped_right = snap_edge(raw_right, &x_edges, grid_size) + .unwrap_or_else(|| round_to_grid(raw_right, grid_size)); + let width = (snapped_right - previous.origin.x).max(MINIMUM_SIZE.width); + (previous.origin.x, width) + } else { + (previous.origin.x, previous.size.width) + }; + + // --- Y axis --- + let (final_y, final_height) = if let Some(y) = new_y { + // Top edge moving; bottom edge pinned. Canvas-top (0) is also a target. + let raw_top = y.max(px(0.)); + let mut candidates = y_edges.clone(); + candidates.push(px(0.)); + let snapped_top = snap_edge(raw_top, &candidates, grid_size) + .unwrap_or_else(|| round_to_grid(raw_top, grid_size)); + let height = (prev_bottom - snapped_top).max(MINIMUM_SIZE.height); + (snapped_top, height) + } else if let Some(height) = new_height { + // Bottom edge moving; top edge pinned. + let raw_bottom = previous.origin.y + height; + let snapped_bottom = snap_edge(raw_bottom, &y_edges, grid_size) + .unwrap_or_else(|| round_to_grid(raw_bottom, grid_size)); + let height = (snapped_bottom - previous.origin.y).max(MINIMUM_SIZE.height); + (previous.origin.y, height) + } else { + (previous.origin.y, previous.size.height) + }; + + Bounds { + origin: Point { + x: final_x, + y: final_y, + }, + size: Size { + width: final_width, + height: final_height, + }, + } +} + +/// Round `value` to the nearest multiple of `grid_size`. +/// +/// This is the original `round_to_nearest_ten_with` (already grid-size +/// parameterized, not `cx`-dependent), renamed to match the split described +/// below and exposed directly rather than duplicated under two names. +/// +/// The original `round_to_nearest_ten` and `round_point_to_nearest_ten` read +/// the grid size off the theme via `cx`; base cannot see a theme, so the +/// skin reads the grid size and passes it in here instead. +pub fn round_to_grid(value: Pixels, grid_size: Pixels) -> Pixels { + (value / grid_size).round() * grid_size +} + +/// Calculate the magnetic snap position for a tile being dragged. +/// +/// `moving` is the tile's candidate bounds (already translated by the drag +/// delta, before snapping). `others` are the bounds of every other tile in +/// the same canvas. The returned point keeps `moving.origin`'s coordinate on +/// any axis that did not snap, so the result can be assigned directly as the +/// tile's new origin. +pub fn magnetic_snap( + moving: Bounds, + others: &[Bounds], + threshold: Pixels, +) -> Point { + // Only check nearby panels + let search_bounds = Bounds { + origin: Point { + x: moving.left() - threshold, + y: moving.top() - threshold, + }, + size: Size { + width: moving.size.width + threshold * 2.0, + height: moving.size.height + threshold * 2.0, + }, + }; + + let mut snap_x: Option = None; + let mut snap_y: Option = None; + let mut min_x_dist = threshold; + let mut min_y_dist = threshold; + + // Pre-calculate dragging bounds edges to avoid repeated method calls + let drag_left = moving.left(); + let drag_right = moving.right(); + let drag_top = moving.top(); + let drag_bottom = moving.bottom(); + let drag_width = moving.size.width; + let drag_height = moving.size.height; + + // Check for edge snapping first (top and left boundaries) + let edge_snap_pos = px(0.); + + // Snap to top edge + let top_dist = drag_top.abs(); + if top_dist < threshold { + snap_y = Some(edge_snap_pos); + min_y_dist = top_dist; + } + + // Snap to left edge + let left_dist = drag_left.abs(); + if left_dist < threshold { + snap_x = Some(edge_snap_pos); + min_x_dist = left_dist; + } + + // If both edges are snapped, skip the neighbor search entirely. + if snap_x.is_none() || snap_y.is_none() { + for other in others { + if snap_x.is_some() && snap_y.is_some() { + break; + } + + // Pre-calculate other bounds edges + let other_left = other.left(); + let other_right = other.right(); + let other_top = other.top(); + let other_bottom = other.bottom(); + + // Skip panels that are far away + if other_right < search_bounds.left() + || other_left > search_bounds.right() + || other_bottom < search_bounds.top() + || other_top > search_bounds.bottom() + { + continue; + } + + // Horizontal snapping (X axis) - find closest snap point + if snap_x.is_none() { + let candidates = [ + ((drag_left - other_left).abs(), other_left), + ((drag_left - other_right).abs(), other_right), + ((drag_right - other_left).abs(), other_left - drag_width), + ((drag_right - other_right).abs(), other_right - drag_width), + ]; + + for (dist, snap_pos) in candidates { + if dist < min_x_dist { + min_x_dist = dist; + snap_x = Some(snap_pos); + } + } + } + + // Vertical snapping (Y axis) - find closest snap point + if snap_y.is_none() { + let candidates = [ + ((drag_top - other_top).abs(), other_top), + ((drag_top - other_bottom).abs(), other_bottom), + ((drag_bottom - other_top).abs(), other_top - drag_height), + ( + (drag_bottom - other_bottom).abs(), + other_bottom - drag_height, + ), + ]; + + for (dist, snap_pos) in candidates { + if dist < min_y_dist { + min_y_dist = dist; + snap_y = Some(snap_pos); + } + } + } + } + } + + Point { + x: snap_x.unwrap_or(moving.origin.x), + y: snap_y.unwrap_or(moving.origin.y), + } +} + +/// Clamp a dragged tile's origin to the canvas boundary. +/// +/// The top is a hard boundary (a tile's top can never go negative), and at +/// most `dragging_width - 64px` of the tile may hang off the left edge, +/// keeping 64px of it visible. There is no boundary on the right or bottom: +/// the canvas scrolls. +/// +/// `dragging_width` is the width of the tile being dragged, not a canvas +/// size — the original `Tiles::apply_boundary_constraints` reads it from +/// `self.dragging_initial_bounds.size.width`, the entity's own drag-tracking +/// state, not a container/tile-list argument. +pub fn apply_boundary_constraints(origin: Point, dragging_width: Pixels) -> Point { + let mut origin = origin; + + // Top boundary + if origin.y < px(0.) { + origin.y = px(0.); + } + + // Left boundary (allow partial off-screen but keep 64px visible) + let min_left = -dragging_width + px(64.); + if origin.x < min_left { + origin.x = min_left; + } + + origin +} + +/// The scrollable extent a set of tiles occupies, measured from the canvas +/// origin. +/// +/// Reproduces the fold the old `Tiles::render` did before handing the result +/// to its scrollbar: the union runs from `min(0, left)` to `max(0, right)` on +/// each axis, so a canvas whose tiles all sit at positive coordinates reports +/// the far edge, and one with a tile dragged past the origin reports the +/// distance across both. +pub fn content_size(tiles: &[Bounds]) -> Size { + let mut left = px(0.); + let mut top = px(0.); + let mut right = px(0.); + let mut bottom = px(0.); + for bounds in tiles { + left = left.min(bounds.left()); + top = top.min(bounds.top()); + right = right.max(bounds.right()); + bottom = bottom.max(bounds.bottom()); + } + size(right - left, bottom - top) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds { + origin: Point { x: px(x), y: px(y) }, + size: Size { + width: px(w), + height: px(h), + }, + } + } + + #[test] + fn test_snap_edge_within_threshold() { + // 102 is 2px from 100 (< 8) -> snaps to 100. + assert_eq!( + snap_edge(px(102.), &[px(100.), px(300.)], px(8.)), + Some(px(100.)) + ); + } + + #[test] + fn test_snap_edge_outside_threshold() { + // 120 is 20px from nearest candidate (>= 8) -> no snap. + assert_eq!(snap_edge(px(120.), &[px(100.), px(300.)], px(8.)), None); + } + + #[test] + fn test_snap_edge_picks_nearest() { + // 303 is 3px from 300 and 5px from 308 -> picks 300. + assert_eq!( + snap_edge(px(303.), &[px(308.), px(300.)], px(8.)), + Some(px(300.)) + ); + } + + #[test] + fn test_snap_edge_empty_candidates() { + assert_eq!(snap_edge(px(50.), &[], px(8.)), None); + } + + #[test] + fn test_resize_right_edge_snaps_to_neighbor_left() { + // Panel A: x=0 w=196 (right edge 196). Neighbour B starts at x=200. + // Dragging right edge to 197 should snap right edge to 200 -> width 200. + let prev = b(0., 0., 196., 100.); + let neighbor = b(200., 0., 100., 100.); + let out = + compute_resized_bounds(prev, None, None, Some(px(197.)), None, &[neighbor], px(8.)); + assert_eq!(out.origin.x, px(0.)); + assert_eq!(out.size.width, px(200.)); + } + + #[test] + fn test_resize_bottom_edge_snaps_to_neighbor_top() { + let prev = b(0., 0., 100., 196.); + let neighbor = b(0., 200., 100., 100.); + let out = + compute_resized_bounds(prev, None, None, None, Some(px(197.)), &[neighbor], px(8.)); + assert_eq!(out.origin.y, px(0.)); + assert_eq!(out.size.height, px(200.)); + } + + #[test] + fn test_resize_left_edge_snaps_and_pins_right() { + // Panel: x=200 w=100 (right edge 300). Neighbour right edge at 100. + // Drag left edge to 103 -> snaps to 100 -> width = 300 - 100 = 200. + let prev = b(200., 0., 100., 100.); + let neighbor = b(0., 0., 100., 100.); + let out = compute_resized_bounds( + prev, + Some(px(103.)), + None, + Some(px(197.)), + None, + &[neighbor], + px(8.), + ); + assert_eq!(out.origin.x, px(100.)); + assert_eq!(out.size.width, px(200.)); + } + + #[test] + fn test_resize_corner_snaps_both_edges() { + // Right edge -> neighbour-right at 300; bottom edge -> neighbour-bottom at 250. + let prev = b(0., 0., 196., 196.); + let right_neighbor = b(100., 0., 200., 100.); // right edge = 300 + let bottom_neighbor = b(0., 100., 100., 150.); // bottom edge = 250 + let out = compute_resized_bounds( + prev, + None, + None, + Some(px(298.)), + Some(px(248.)), + &[right_neighbor, bottom_neighbor], + px(8.), + ); + assert_eq!(out.size.width, px(300.)); + assert_eq!(out.size.height, px(250.)); + } + + #[test] + fn test_resize_grid_rounds_when_no_neighbor_close() { + // No neighbours; raw right edge 153 -> grid round to 152 (nearest multiple of 8). + let prev = b(0., 0., 100., 100.); + let out = compute_resized_bounds(prev, None, None, Some(px(153.)), None, &[], px(8.)); + assert_eq!(out.size.width, px(152.)); + } + + #[test] + fn test_resize_respects_minimum_size() { + let prev = b(0., 0., 100., 100.); + let out = compute_resized_bounds(prev, None, None, Some(px(10.)), None, &[], px(8.)); + assert_eq!(out.size.width, MINIMUM_SIZE.width); + } + + #[test] + fn content_size_spans_from_the_origin_to_the_far_edge() { + assert_eq!( + content_size(&[b(20., 20., 380., 280.), b(420., 20., 380., 280.)]), + size(px(800.), px(300.)), + "the extent runs from the canvas origin, not from the first tile" + ); + assert_eq!( + content_size(&[]), + size(px(0.), px(0.)), + "an empty canvas scrolls nowhere" + ); + assert_eq!( + content_size(&[b(-40., -10., 100., 100.)]), + size(px(100.), px(100.)), + "a tile dragged past the origin still reports the distance across it" + ); + } + + #[test] + fn test_resize_no_change_returns_previous_geometry() { + let prev = b(0., 0., 100., 100.); + let out = compute_resized_bounds(prev, None, None, None, None, &[], px(8.)); + assert_eq!(out.origin.x, px(0.)); + assert_eq!(out.origin.y, px(0.)); + assert_eq!(out.size.width, px(100.)); + assert_eq!(out.size.height, px(100.)); + } +} diff --git a/vendor/gpui-base/src/dock/tiles_state.rs b/vendor/gpui-base/src/dock/tiles_state.rs new file mode 100644 index 00000000..1a06d48c --- /dev/null +++ b/vendor/gpui-base/src/dock/tiles_state.rs @@ -0,0 +1,1048 @@ +//! A tiles canvas's behavior, with no appearance of its own. + +use std::{rc::Rc, sync::Arc}; + +use gpui::{ + AnyElement, App, Bounds, Context, Div, Empty, EntityId, EventEmitter, FocusHandle, Focusable, + InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render, Size, + Stateful, Styled as _, WeakEntity, Window, div, prelude::FluentBuilder as _, px, +}; + +use crate::history::History; + +use super::{ + drag::AnyDrag, + layout::{NodeId, PanelId}, + panel::PanelView, + tiles_geometry::{ + MINIMUM_SIZE, ResizeDrag, ResizeSide, TileChange, apply_boundary_constraints, + compute_resized_bounds, content_size, magnetic_snap, + }, +}; + +/// What a tiles canvas cannot carry out on its own. +/// +/// The canvas mirrors one `Tiles` node but does not own the tree that node +/// lives in, so — exactly as with [`TabGroupEvent`](super::TabGroupEvent) — +/// every outcome is reported as an intent and applied by the container +/// through `PaneTree::set_tile_bounds` / `PaneTree::bring_to_front`. +#[non_exhaustive] +pub enum TilesEvent { + /// A tile finished moving or resizing at `bounds`. + BoundsChanged { + panel: PanelId, + bounds: Bounds, + }, + /// A tile was interacted with and should stack above its peers. + BringToFront { panel: PanelId }, + /// The user asked to close `panel`, dismissing its tile. + ClosePanel { panel: PanelId }, + /// A host-owned drag landed on the canvas. The canvas has free + /// coordinates, so the host reads the landing position itself. + DragDrop { item: AnyDrag }, + /// One tile asked to fill the whole dock. The container installs the + /// *canvas* as its zoomed view, and the canvas draws that one tile with + /// its chrome — which is where the control that zooms back out lives. + ZoomIn { panel: PanelId }, + /// The zoomed tile gave the dock back. + ZoomOut, +} + +/// One tile, mirrored from a `Tiles` node. +#[derive(Clone)] +struct Tile { + panel: Arc, + id: PanelId, + bounds: Bounds, + z_index: usize, +} + +/// An in-flight move: which tile, and where the pointer and the tile were +/// when it started. +#[derive(Clone, Copy)] +struct TileMove { + panel: PanelId, + initial_pointer: Point, + initial_bounds: Bounds, +} + +/// An in-flight resize: which tile, and the geometry module's own drag record. +#[derive(Clone, Copy)] +struct TileResize { + panel: PanelId, + initial_bounds: Bounds, + drag: ResizeDrag, +} + +/// A tiles canvas's behavior, with no appearance of its own. +/// +/// It owns the tile list mirrored from the layout tree, the in-flight move and +/// resize state, and the undo stack. Everything visible is produced by the +/// [`TilesRenderer`] the host installs. +pub struct TilesState { + node: NodeId, + /// Handed to the callbacks in [`TileContext`], which are built from a + /// plain `&App` and so cannot ask for it. + this: WeakEntity, + tiles: Vec, + focus_handle: FocusHandle, + /// The tile filling the whole dock, if one is. Driven by the container + /// through [`Self::set_zoomed`], so the canvas and the container name the + /// same tile or neither does. + zoomed: Option, + moving: Option, + resizing: Option, + history: History, + renderer: Rc, +} + +impl TilesState { + /// Only a container builds canvases: a canvas is the entity mirror of one + /// `Tiles` node, created when that node first appears in the tree. + pub(crate) fn new(node: NodeId, _window: &mut Window, cx: &mut Context) -> Self { + Self { + node, + this: cx.weak_entity(), + tiles: Vec::new(), + focus_handle: cx.focus_handle(), + zoomed: None, + moving: None, + resizing: None, + history: History::new().group_interval(std::time::Duration::from_millis(100)), + renderer: Rc::new(BareTiles), + } + } + + pub fn with_renderer(mut self, renderer: Rc) -> Self { + self.renderer = renderer; + self + } + + /// The `Tiles` node this canvas mirrors. + pub fn node(&self) -> NodeId { + self.node + } + + /// Every tile, in stacking order (lowest first). + pub fn tiles(&self, cx: &App) -> Vec { + let mut order: Vec = (0..self.tiles.len()).collect(); + order.sort_by_key(|ix| (self.tiles[*ix].z_index, *ix)); + order + .into_iter() + .map(|ix| self.tile_context(ix, cx)) + .collect() + } + + /// Mirror one `Tiles` node's membership and geometry into this canvas. + pub(crate) fn sync_from_tree( + &mut self, + tiles: Vec<(Arc, Bounds, usize)>, + cx: &mut Context, + ) { + self.tiles = tiles + .into_iter() + .map(|(panel, bounds, z_index)| Tile { + id: panel.panel_id(cx), + panel, + bounds, + z_index, + }) + .collect(); + // A tile that left the canvas must not be resurrected by an in-flight + // gesture that outlived it. + if self + .moving + .is_some_and(|drag| self.index_of(drag.panel).is_none()) + { + self.moving = None; + } + if self + .resizing + .is_some_and(|drag| self.index_of(drag.panel).is_none()) + { + self.resizing = None; + } + cx.notify(); + } + + /// The tile filling the whole dock, if one is. + pub fn zoomed_tile(&self) -> Option { + self.zoomed + } + + /// Flip one tile's zoom. + pub fn toggle_zoom(&mut self, panel: PanelId, window: &mut Window, cx: &mut Context) { + let zoomed = (self.zoomed != Some(panel)).then_some(panel); + self.set_zoomed(zoomed, window, cx); + } + + /// Zoom one tile in, or zoom out with `None`: the flag changes, the panel + /// is told, and the container is asked to install or clear the zoomed + /// view. + /// + /// The container drives this too when it clears a zoom from outside, so + /// the canvas cannot be left naming a tile the container is not showing. + /// + /// Zooming *in* is refused, leaving the flag alone, for a tile that is not + /// on this canvas or whose panel is not zoomable. Zooming *out* is never + /// refused: a tile that became unzoomable while zoomed still has to be + /// able to give the dock back. + pub(crate) fn set_zoomed( + &mut self, + zoomed: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self.zoomed == zoomed { + return; + } + // The outgoing tile hears about it as well as the incoming one, so a + // zoom moved straight from one tile to another leaves neither panel + // believing it still fills the dock. + let outgoing = self.zoomed.and_then(|panel| self.panel_view(panel)); + let incoming = zoomed.and_then(|panel| self.panel_view(panel)); + if zoomed.is_some() && !incoming.as_ref().is_some_and(|panel| panel.zoomable(cx)) { + return; + } + + self.zoomed = zoomed; + cx.emit(match zoomed { + Some(panel) => TilesEvent::ZoomIn { panel }, + None => TilesEvent::ZoomOut, + }); + + // Delivered outside this update so a `set_zoomed` handler may call + // back into the canvas. + cx.spawn_in(window, async move |_, cx| { + _ = cx.update(|window, cx| { + if let Some(panel) = outgoing { + panel.set_zoomed(false, window, cx); + } + if let Some(panel) = incoming { + panel.set_zoomed(true, window, cx); + } + }); + }) + .detach(); + cx.notify(); + } + + /// Undo the most recent group of tile changes. + pub fn undo(&mut self, cx: &mut Context) { + let Some(changes) = self.history.undo() else { + return; + }; + for change in changes { + if let (Some(panel), Some(bounds)) = + (self.panel_of(change.tile_id()), change.old_bounds()) + { + cx.emit(TilesEvent::BoundsChanged { panel, bounds }); + } + } + cx.notify(); + } + + /// Redo the most recently undone group of tile changes. + pub fn redo(&mut self, cx: &mut Context) { + let Some(changes) = self.history.redo() else { + return; + }; + for change in changes { + if let (Some(panel), Some(bounds)) = + (self.panel_of(change.tile_id()), change.new_bounds()) + { + cx.emit(TilesEvent::BoundsChanged { panel, bounds }); + } + } + cx.notify(); + } +} + +impl TilesState { + fn index_of(&self, panel: PanelId) -> Option { + self.tiles.iter().position(|tile| tile.id == panel) + } + + fn bounds_of(&self, panel: PanelId) -> Option> { + self.index_of(panel).map(|ix| self.tiles[ix].bounds) + } + + fn panel_view(&self, panel: PanelId) -> Option> { + self.index_of(panel).map(|ix| self.tiles[ix].panel.clone()) + } + + /// The panel behind a history record's `EntityId`. + fn panel_of(&self, entity: EntityId) -> Option { + self.tiles + .iter() + .find(|tile| tile.panel.view().entity_id() == entity) + .map(|tile| tile.id) + } + + /// Every other tile's bounds, which is what the snapping arithmetic + /// measures against. + fn other_bounds(&self, panel: PanelId) -> Vec> { + self.tiles + .iter() + .filter(|tile| tile.id != panel) + .map(|tile| tile.bounds) + .collect() + } + + fn grid_size(&self, cx: &App) -> Pixels { + self.renderer.grid_size(cx) + } + + fn begin_move(&mut self, panel: PanelId, pointer: Point, cx: &mut Context) { + // A zoomed tile fills the dock rather than sitting at its stored + // bounds, so there is nothing for a move to mean — the same reason a + // zoomed tab group reports itself locked. + if self.zoomed.is_some() { + return; + } + let Some(initial_bounds) = self.bounds_of(panel) else { + return; + }; + self.moving = Some(TileMove { + panel, + initial_pointer: pointer, + initial_bounds, + }); + cx.emit(TilesEvent::BringToFront { panel }); + cx.notify(); + } + + fn move_to(&mut self, pointer: Point, cx: &mut Context) { + let Some(drag) = self.moving else { + return; + }; + let delta = pointer - drag.initial_pointer; + let candidate = Bounds { + origin: apply_boundary_constraints( + drag.initial_bounds.origin + delta, + drag.initial_bounds.size.width, + ), + size: drag.initial_bounds.size, + }; + let origin = magnetic_snap( + candidate, + &self.other_bounds(drag.panel), + self.grid_size(cx), + ); + + self.apply_bounds( + drag.panel, + Bounds { + origin, + size: drag.initial_bounds.size, + }, + cx, + ); + } + + fn end_move(&mut self, cx: &mut Context) { + let Some(drag) = self.moving.take() else { + return; + }; + self.record(drag.panel, drag.initial_bounds, cx); + } + + fn begin_resize( + &mut self, + panel: PanelId, + side: ResizeSide, + pointer: Point, + cx: &mut Context, + ) { + if self.zoomed.is_some() { + return; + } + let Some(initial_bounds) = self.bounds_of(panel) else { + return; + }; + self.resizing = Some(TileResize { + panel, + initial_bounds, + drag: ResizeDrag::new(side, pointer, initial_bounds), + }); + cx.emit(TilesEvent::BringToFront { panel }); + cx.notify(); + } + + fn resize_to(&mut self, pointer: Point, cx: &mut Context) { + let Some(resize) = self.resizing else { + return; + }; + let previous = resize.drag.last_bounds(); + let (new_x, new_y, new_width, new_height) = match resize.drag.side() { + ResizeSide::Left => (Some(pointer.x), None, None, None), + ResizeSide::Right => ( + None, + None, + Some((pointer.x - previous.origin.x).max(MINIMUM_SIZE.width)), + None, + ), + ResizeSide::Top => (None, Some(pointer.y), None, None), + ResizeSide::Bottom => ( + None, + None, + None, + Some((pointer.y - previous.origin.y).max(MINIMUM_SIZE.height)), + ), + ResizeSide::BottomRight => ( + None, + None, + Some((pointer.x - previous.origin.x).max(MINIMUM_SIZE.width)), + Some((pointer.y - previous.origin.y).max(MINIMUM_SIZE.height)), + ), + }; + + let bounds = compute_resized_bounds( + previous, + new_x, + new_y, + new_width, + new_height, + &self.other_bounds(resize.panel), + self.grid_size(cx), + ); + + self.resizing = Some(TileResize { + drag: resize + .drag + .with_last_position(pointer) + .with_last_bounds(bounds), + ..resize + }); + self.apply_bounds(resize.panel, bounds, cx); + } + + fn end_resize(&mut self, cx: &mut Context) { + let Some(resize) = self.resizing.take() else { + return; + }; + self.record(resize.panel, resize.initial_bounds, cx); + } + + /// Show the new geometry immediately and report it, so the container's + /// tree and this mirror never disagree for a frame. + fn apply_bounds(&mut self, panel: PanelId, bounds: Bounds, cx: &mut Context) { + let Some(ix) = self.index_of(panel) else { + return; + }; + if self.tiles[ix].bounds == bounds { + return; + } + self.tiles[ix].bounds = bounds; + cx.emit(TilesEvent::BoundsChanged { panel, bounds }); + cx.notify(); + } + + /// Push one completed gesture onto the undo stack. + fn record(&mut self, panel: PanelId, old_bounds: Bounds, cx: &mut Context) { + let Some(ix) = self.index_of(panel) else { + return; + }; + let tile = &self.tiles[ix]; + if tile.bounds == old_bounds { + return; + } + self.history.push(TileChange::bounds_change( + tile.panel.view().entity_id(), + old_bounds, + tile.bounds, + )); + cx.notify(); + } + + /// Ask the container to close `panel`. Nothing happens for a tile that + /// is not on this canvas, or for a panel that refuses to close. + fn close_tile(&mut self, panel: PanelId, cx: &mut Context) { + let closable = self + .tiles + .iter() + .any(|tile| tile.id == panel && tile.panel.closable(cx)); + if !closable { + return; + } + cx.emit(TilesEvent::ClosePanel { panel }); + cx.notify(); + } + + fn tile_context(&self, ix: usize, cx: &App) -> TileContext { + let tile = &self.tiles[ix]; + let panel = tile.id; + let canvas = self.this.clone(); + + TileContext { + node: self.node, + panel: tile.panel.clone(), + id: panel, + bounds: tile.bounds, + z_index: tile.z_index, + moving: self.moving.is_some_and(|drag| drag.panel == panel), + resizing: self.resizing.is_some_and(|drag| drag.panel == panel), + closable: tile.panel.closable(cx), + zoomed: self.zoomed == Some(panel), + zoomable: tile.panel.zoomable(cx), + on_begin_move: { + let canvas = canvas.clone(); + Rc::new(move |pointer, _, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.begin_move(panel, pointer, cx)); + }) + }, + on_move_to: { + let canvas = canvas.clone(); + Rc::new(move |pointer, _, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.move_to(pointer, cx)); + }) + }, + on_end_move: { + let canvas = canvas.clone(); + Rc::new(move |_, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.end_move(cx)); + }) + }, + on_begin_resize: { + let canvas = canvas.clone(); + Rc::new(move |side, pointer, _, cx| { + _ = canvas.update(cx, |canvas, cx| { + canvas.begin_resize(panel, side, pointer, cx) + }); + }) + }, + on_resize_to: { + let canvas = canvas.clone(); + Rc::new(move |pointer, _, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.resize_to(pointer, cx)); + }) + }, + on_end_resize: { + let canvas = canvas.clone(); + Rc::new(move |_, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.end_resize(cx)); + }) + }, + on_bring_to_front: { + let canvas = canvas.clone(); + Rc::new(move |_, cx| { + _ = canvas.update(cx, |_, cx| { + cx.emit(TilesEvent::BringToFront { panel }); + }); + }) + }, + on_toggle_zoom: { + let canvas = canvas.clone(); + Rc::new(move |window, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.toggle_zoom(panel, window, cx)); + }) + }, + on_close: Rc::new(move |_, cx| { + _ = canvas.update(cx, |canvas, cx| canvas.close_tile(panel, cx)); + }), + } + } +} + +impl EventEmitter for TilesState {} + +impl Focusable for TilesState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for TilesState { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let renderer = self.renderer.clone(); + let focus_handle = self.focus_handle.clone(); + // A zoomed tile fills the dock on its own, so the tiles beside it are + // not drawn — just as the rest of the dock is not drawn behind it. + // A zoom naming a tile that has since left the canvas draws the + // canvas whole rather than nothing at all. + let zoomed = self.zoomed.filter(|panel| self.index_of(*panel).is_some()); + let tiles: Vec = self + .tiles(cx) + .into_iter() + .filter(|tile| zoomed.is_none_or(|panel| tile.id == panel)) + .collect(); + // Every tile, not the drawn subset: an overlay scrollbar measures the + // whole canvas. + let content = content_size( + &self + .tiles + .iter() + .map(|tile| tile.bounds) + .collect::>(), + ); + + renderer + .frame(window, cx) + .track_focus(&focus_handle) + .on_drop(cx.listener(|_, item: &AnyDrag, _, cx| { + cx.emit(TilesEvent::DragDrop { item: item.clone() }); + })) + .children( + tiles + .into_iter() + .map(|tile| { + renderer + .tile_frame(&tile, window, cx) + // The only positioning base installs anywhere in + // the dock. A tiles canvas *is* "panels at stored + // coordinates": drawing one somewhere other than + // its own bounds would not be a different skin, + // it would be a different data structure. A + // zoomed tile is the exception — it is no longer + // at its coordinates, and how it fills the dock + // is the skin's to decide. + .when(!tile.zoomed, |this| { + this.absolute() + .left(tile.bounds.origin.x) + .top(tile.bounds.origin.y) + .w(tile.bounds.size.width) + .h(tile.bounds.size.height) + }) + .child(renderer.render_drag_bar(&tile, window, cx)) + .child( + renderer + .panel_frame(&tile, window, cx) + .child(tile.panel.view()), + ) + // Nothing to resize against while zoomed, and the + // canvas refuses the gesture anyway. + .when(!tile.zoomed, |this| { + this.child(renderer.render_resize_handles(&tile, window, cx)) + }) + }) + .collect::>(), + ) + // Last, so it paints and hit-tests above every tile. A zoomed + // canvas draws one tile filling the dock, so there is no canvas + // for an overlay to sit over. + .when(zoomed.is_none(), |this| { + this.children(renderer.render_overlay(content, window, cx)) + }) + } +} + +type MovePointerHandler = Rc, &mut Window, &mut App)>; +type ResizeStartHandler = Rc, &mut Window, &mut App)>; +type GestureEndHandler = Rc; + +/// What a skin needs to draw one tile, and the callbacks it invokes rather +/// than reimplementing the snapping and resize arithmetic. +#[derive(Clone)] +pub struct TileContext { + node: NodeId, + panel: Arc, + id: PanelId, + bounds: Bounds, + z_index: usize, + moving: bool, + resizing: bool, + closable: bool, + zoomed: bool, + zoomable: bool, + on_begin_move: MovePointerHandler, + on_move_to: MovePointerHandler, + on_end_move: GestureEndHandler, + on_begin_resize: ResizeStartHandler, + on_resize_to: MovePointerHandler, + on_end_resize: GestureEndHandler, + on_bring_to_front: GestureEndHandler, + on_toggle_zoom: GestureEndHandler, + on_close: GestureEndHandler, +} + +impl TileContext { + /// The `Tiles` node this tile belongs to, for a skin that needs to name + /// the canvas in a drag payload or a drop target. + pub fn node(&self) -> NodeId { + self.node + } + + pub fn panel(&self) -> &Arc { + &self.panel + } + + pub fn panel_id(&self) -> PanelId { + self.id + } + + pub fn bounds(&self) -> Bounds { + self.bounds + } + + pub fn z_index(&self) -> usize { + self.z_index + } + + pub fn is_moving(&self) -> bool { + self.moving + } + + pub fn is_resizing(&self) -> bool { + self.resizing + } + + pub fn can_close(&self) -> bool { + self.closable + } + + /// Whether this tile is the one filling the whole dock. + /// + /// A zoomed tile is drawn without its stored bounds and takes no move or + /// resize gesture, so a skin should offer the way back out here rather + /// than the affordances of a tile that can still be dragged. + pub fn is_zoomed(&self) -> bool { + self.zoomed + } + + /// Whether this tile's panel allows zooming at all. Where the zoom + /// control appears is the skin's decision; whether there is one to offer + /// is not. + pub fn can_zoom(&self) -> bool { + self.zoomable + } + + /// Pointer positions are in window coordinates: every gesture is resolved + /// against the position the gesture started at, so the skin never has to + /// convert into canvas space. + pub fn begin_move(&self, pointer: Point, window: &mut Window, cx: &mut App) { + (self.on_begin_move)(pointer, window, cx); + } + + pub fn move_to(&self, pointer: Point, window: &mut Window, cx: &mut App) { + (self.on_move_to)(pointer, window, cx); + } + + pub fn end_move(&self, window: &mut Window, cx: &mut App) { + (self.on_end_move)(window, cx); + } + + pub fn begin_resize( + &self, + side: ResizeSide, + pointer: Point, + window: &mut Window, + cx: &mut App, + ) { + (self.on_begin_resize)(side, pointer, window, cx); + } + + pub fn resize_to(&self, pointer: Point, window: &mut Window, cx: &mut App) { + (self.on_resize_to)(pointer, window, cx); + } + + pub fn end_resize(&self, window: &mut Window, cx: &mut App) { + (self.on_end_resize)(window, cx); + } + + pub fn bring_to_front(&self, window: &mut Window, cx: &mut App) { + (self.on_bring_to_front)(window, cx); + } + + /// Flip this tile between filling the whole dock and sitting at its + /// stored bounds. + /// + /// Zooming *in* is refused when [`Self::can_zoom`] is false, so a skin + /// that offers a Zoom control should gate it on that. Zooming out is + /// never refused. + pub fn toggle_zoom(&self, window: &mut Window, cx: &mut App) { + (self.on_toggle_zoom)(window, cx); + } + + /// Dismiss this tile. Refused when [`Self::can_close`] is false, so a + /// skin that offers a Close control should gate it on that. + pub fn close(&self, window: &mut Window, cx: &mut App) { + (self.on_close)(window, cx); + } +} + +/// Appearance for a tiles canvas. Base draws none of it. +/// +/// Like [`TabGroupRenderer`](super::TabGroupRenderer), the frame hooks return +/// the element itself rather than wrapping one: base attaches focus and drop +/// handling to the canvas frame and the stored bounds to the tile frame, so a +/// wrapper would put the hit area and the painted area on different elements. +/// That is also why there is no `wrap_canvas` hook — it would be exactly the +/// wrapper the `TabGroupRenderer` review ruled out. +#[allow(unused_variables)] +pub trait TilesRenderer: 'static { + /// The canvas frame, which base tracks focus and drop handling on. + fn frame(&self, window: &mut Window, cx: &mut App) -> Stateful
{ + div().id("tiles") + } + + /// One tile's frame, which base positions at the tile's stored bounds. + fn tile_frame(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> Stateful
{ + div().id("tile") + } + + /// The strip the tile is dragged by. Its height is + /// [`DRAG_BAR_HEIGHT`](super::DRAG_BAR_HEIGHT), which base's snapping + /// arithmetic and the skin must agree on. + fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement; + + /// The tile's resize affordances. Their hit size is + /// [`HANDLE_SIZE`](super::HANDLE_SIZE). + fn render_resize_handles( + &self, + tile: &TileContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + Empty.into_any_element() + } + + /// The frame around one tile's panel view. + /// + /// A wrapper, unlike the other frame hooks, and for the same reason + /// [`DockAreaRenderer::split_frame`](super::DockAreaRenderer::split_frame) + /// is one: base attaches nothing to it, so there is no hit area to keep + /// together with the painted area. It exists because base draws the panel + /// as an ordinary child of the tile frame, and a panel that does not size + /// itself would otherwise have no size at all — the old canvas wrapped it + /// in `h_flex().overflow_hidden().size_full()`, and nothing else can put + /// that back. + fn panel_frame(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> Stateful
{ + div().id(("tile-panel", tile.panel_id().as_u64())) + } + + /// An element drawn above every tile — a scrollbar, a drop hint. + /// + /// Rendered last, so it paints and hit-tests above the tiles. That + /// ordering is the whole point: the canvas frame is the scroll container, + /// so an overlay placed with the frame's own children would sit beneath + /// every tile. Not called while a tile is zoomed, because then the canvas + /// is one tile filling the dock rather than a canvas. + /// + /// `content` is the scrollable extent of every tile measured from the + /// canvas origin, which a scrollbar needs and a hook holding only + /// `&mut App` could not work out. + fn render_overlay( + &self, + content: Size, + window: &mut Window, + cx: &mut App, + ) -> Option { + None + } + + /// The grid a tile snaps to when no neighbouring edge is close enough. + /// + /// The old canvas read this off the theme, which base cannot see; the + /// default is the ten-pixel grid the original rounded to. + fn grid_size(&self, cx: &App) -> Pixels { + px(10.) + } +} + +/// The renderer a canvas starts with: the tiles and nothing else. +pub(crate) struct BareTiles; + +impl TilesRenderer for BareTiles { + fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement { + Empty.into_any_element() + } +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc}; + + use gpui::{Bounds, Entity, TestAppContext, VisualTestContext, point, size}; + + use super::*; + use crate::ElementExt as _; + use crate::dock::{ + DockArea, DockAreaRenderer, DockLayout, TabGroupRenderer, test_support::TestPanel, + }; + + /// What each hook drew, in the order the frame prepainted it. + /// + /// Prepaint order, not call order: the overlay has to be *below the tiles + /// in the element tree*, and a renderer that computed it early and added + /// it late would still be called first. Prepaint walks the tree, so this + /// records the property that matters. + #[derive(Default)] + struct DrawOrder { + painted: Vec<&'static str>, + content: Option>, + /// The contexts the drag bar was handed, so a test can drive a tile + /// through the same seam a skin would. + tiles: Vec, + } + + struct OrderRecorder { + order: Rc>, + } + + impl TilesRenderer for OrderRecorder { + fn render_drag_bar(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> AnyElement { + self.order.borrow_mut().tiles.push(tile.clone()); + Empty.into_any_element() + } + + fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful
{ + let order = self.order.clone(); + div() + .id(("tile", tile.panel_id().as_u64())) + .on_prepaint(move |_, _, _| order.borrow_mut().painted.push("tile")) + } + + fn render_overlay( + &self, + content: Size, + _: &mut Window, + _: &mut App, + ) -> Option { + let order = self.order.clone(); + Some( + div() + .on_prepaint(move |_, _, _| { + let mut order = order.borrow_mut(); + order.painted.push("overlay"); + order.content = Some(content); + }) + .into_any_element(), + ) + } + } + + impl TabGroupRenderer for OrderRecorder { + fn render_tab_bar( + &self, + _: &super::super::TabGroupContext, + _: &mut Window, + _: &mut App, + ) -> AnyElement { + Empty.into_any_element() + } + } + + impl DockAreaRenderer for OrderRecorder { + fn tab_group_renderer(&self) -> Rc { + Rc::new(OrderRecorder { + order: self.order.clone(), + }) + } + + fn tiles_renderer(&self) -> Rc { + Rc::new(OrderRecorder { + order: self.order.clone(), + }) + } + } + + fn setup_order( + cx: &mut TestAppContext, + ) -> ( + Entity, + Rc>, + &mut VisualTestContext, + ) { + cx.update(|cx| { + let _ = crate::Theme::global_mut(cx); + }); + let order: Rc> = Rc::default(); + let renderer = Rc::new(OrderRecorder { + order: order.clone(), + }); + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("tiles-order", None, window, cx).with_renderer(renderer) + }); + (area, order, cx) + } + + /// The canvas overlay is drawn after every tile. + /// + /// This is the whole reason the hook exists rather than the skin adding a + /// scrollbar to the canvas frame: the frame is the scroll container, and + /// base appends the tiles to whatever it carries, so anything placed there + /// paints and hit-tests underneath every tile. Move + /// `children(render_overlay(..))` above `children(tiles)` in + /// `TilesState::render` and this fails. + #[gpui::test] + fn the_canvas_overlay_is_drawn_after_every_tile(cx: &mut TestAppContext) { + let (area, order, cx) = setup_order(cx); + + cx.update(|window, cx| { + let first = TestPanel::new("First", cx); + let second = TestPanel::new("Second", cx); + let layout = DockLayout::tiles() + .tile( + first, + Bounds { + origin: point(px(20.), px(20.)), + size: size(px(100.), px(80.)), + }, + ) + .tile( + second, + Bounds { + origin: point(px(140.), px(20.)), + size: size(px(100.), px(80.)), + }, + ); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + }); + cx.run_until_parked(); + order.borrow_mut().painted.clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + order.borrow().painted, + vec!["tile", "tile", "overlay"], + "the overlay must come after every tile, or it paints beneath them" + ); + + // And it is handed the whole canvas, not the tile it happens to sit + // over: 20..240 across and 20..100 down, measured from the origin. + assert_eq!( + order.borrow().content, + Some(size(px(240.), px(100.))), + "the overlay is given the canvas's scrollable extent" + ); + } + + /// A zoomed tile fills the dock, so there is no canvas to overlay. + #[gpui::test] + fn a_zoomed_canvas_draws_no_overlay(cx: &mut TestAppContext) { + let (area, order, cx) = setup_order(cx); + + let panel = cx.update(|window, cx| { + let panel = TestPanel::new("Only", cx); + let layout = DockLayout::tiles().tile( + panel.clone(), + Bounds { + origin: point(px(20.), px(20.)), + size: size(px(100.), px(80.)), + }, + ); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + panel + }); + cx.run_until_parked(); + + // Zoomed through the seam a skin uses, not a back door. + cx.update(|window, cx| window.draw(cx).clear(cx)); + let tile = order.borrow().tiles.last().cloned().expect("the tile drew"); + cx.update(|window, cx| tile.toggle_zoom(window, cx)); + cx.run_until_parked(); + assert_eq!( + cx.read(|cx| area.read(cx).zoomed_tile()), + Some(PanelId::from(panel.entity_id())), + "the tile is the one filling the dock" + ); + order.borrow_mut().painted.clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + order.borrow().painted, + vec!["tile"], + "a zoomed tile fills the dock, so no overlay is drawn over it" + ); + } +} diff --git a/vendor/gpui-base/src/element_ext.rs b/vendor/gpui-base/src/element_ext.rs new file mode 100644 index 00000000..d153bf8d --- /dev/null +++ b/vendor/gpui-base/src/element_ext.rs @@ -0,0 +1,31 @@ +use gpui::{App, Bounds, IntoElement, ParentElement, Pixels, Styled as _, Window, canvas}; + +use crate::TextSelectionScopeId; + +/// Extends a GPUI parent element with post-layout prepaint observation. +pub trait ElementExt: ParentElement + Sized { + /// Marks this element subtree as belonging to a text-selection scope. + fn text_selection_scope(self, scope: TextSelectionScopeId) -> impl IntoElement + where + Self: IntoElement, + { + crate::text_selection::text_selection_scope(scope, self) + } + + /// Invokes `callback` during prepaint with this element's resolved bounds. + fn on_prepaint(self, callback: F) -> Self + where + F: FnOnce(Bounds, &mut Window, &mut App) + 'static, + { + self.child( + canvas( + move |bounds, window, cx| callback(bounds, window, cx), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + } +} + +impl ElementExt for T {} diff --git a/vendor/gpui-base/src/event.rs b/vendor/gpui-base/src/event.rs new file mode 100644 index 00000000..39b77732 --- /dev/null +++ b/vendor/gpui-base/src/event.rs @@ -0,0 +1,60 @@ +use gpui::{ + App, ClickEvent, InteractiveElement, OngoingScroll, Pixels, Point, Stateful, + StatefulInteractiveElement, TouchPhase, Window, +}; + +/// gpui delimits scroll gestures with `std::time::Instant`, which is +/// unimplemented on wasm32: the first wheel event over an axis-locked scroll +/// area panics with "time not implemented on this platform" and takes the whole +/// application down, leaving the canvas unresponsive. Losing the axis lock in +/// the browser is by far the lesser cost, so the locks below are no-ops there. +pub trait OngoingScrollExt { + /// Locks a wheel delta to the axis its gesture started on, where the + /// platform supports it. + fn lock_axis(&mut self, delta: &mut Point, touch_phase: TouchPhase); +} + +impl OngoingScrollExt for OngoingScroll { + fn lock_axis(&mut self, delta: &mut Point, touch_phase: TouchPhase) { + #[cfg(target_family = "wasm")] + let _ = (delta, touch_phase); + #[cfg(not(target_family = "wasm"))] + self.filter(delta, touch_phase); + } +} + +pub trait InteractiveElementExt: InteractiveElement { + /// Locks scrolling to the gesture's dominant axis, where the platform + /// supports it. See [`OngoingScrollExt`] for why this is a no-op on wasm32. + fn lock_scroll_axis(self) -> Self + where + Self: Sized + StatefulInteractiveElement, + { + #[cfg(target_family = "wasm")] + { + self + } + #[cfg(not(target_family = "wasm"))] + { + self.restrict_scroll_to_axis() + } + } + + /// Set the listener for a double click event. + fn on_double_click( + mut self, + listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self + where + Self: Sized, + { + self.interactivity().on_click(move |event, window, cx| { + if event.click_count() == 2 { + listener(event, window, cx); + } + }); + self + } +} + +impl InteractiveElementExt for Stateful {} diff --git a/vendor/gpui-base/src/focus_trap.rs b/vendor/gpui-base/src/focus_trap.rs new file mode 100644 index 00000000..48b3bf12 --- /dev/null +++ b/vendor/gpui-base/src/focus_trap.rs @@ -0,0 +1,228 @@ +use gpui::{ + AnyElement, App, Bounds, Element, ElementId, FocusHandle, Global, GlobalElementId, + InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, Pixels, + StatefulInteractiveElement, StyleRefinement, Styled, WeakFocusHandle, Window, +}; +use std::collections::HashMap; + +/// Initialize the focus trap manager as a global +pub fn init(cx: &mut App) { + cx.set_global(FocusTrapManager::new()); +} + +/// An extension trait to add `focus_trap` functionality to interactive elements. +pub trait FocusTrapElement: InteractiveElement + Sized { + /// Enable focus trap for this element. + /// + /// When enabled, focus will automatically cycle within this container + /// instead of escaping to parent elements. This is useful for modal dialogs, + /// sheets, and other overlay components. + /// + /// The focus trap works by: + /// 1. Registering this element as a focus trap container + /// 2. When Tab/Shift-Tab is pressed, Root intercepts the event + /// 3. If focus would leave the container, it cycles back to the beginning/end + /// + /// # Example + /// + /// ```ignore + /// v_flex() + /// .child(Button::new("btn1").label("Button 1")) + /// .child(Button::new("btn2").label("Button 2")) + /// .child(Button::new("btn3").label("Button 3")) + /// .focus_trap("trap1", &self.container_focus_handle) + /// // Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1 + /// // Focus will not escape to elements outside this container + /// ``` + /// + /// See also: + fn focus_trap( + self, + id: impl Into, + focus_handle: &FocusHandle, + ) -> FocusTrapContainer + where + Self: ParentElement + Styled + Element + 'static, + { + FocusTrapContainer::new(id, focus_handle.clone(), self) + } +} +impl FocusTrapElement for T {} + +/// Global state to manage all focus trap containers +pub(crate) struct FocusTrapManager { + /// Map from container element ID to its focus trap info + traps: HashMap, +} + +impl Global for FocusTrapManager {} + +impl FocusTrapManager { + /// Create a new focus trap manager + fn new() -> Self { + Self { + traps: HashMap::new(), + } + } + + pub(crate) fn global(cx: &App) -> &Self { + cx.global::() + } + + fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } + + /// Register a focus trap container + fn register_trap(id: &GlobalElementId, container_handle: WeakFocusHandle, cx: &mut App) { + let this = Self::global_mut(cx); + this.traps.insert(id.clone(), container_handle); + this.cleanup(); + } + + /// Find which focus trap contains the currently focused element + fn find_active_trap(window: &Window, cx: &App) -> Option { + for (_id, container_handle) in Self::global(cx).traps.iter() { + let Some(container) = container_handle.upgrade() else { + continue; + }; + + if container.contains_focused(window, cx) { + return Some(container.clone()); + } + } + None + } + + /// Cleanup any traps with dropped handles + fn cleanup(&mut self) { + self.traps.retain(|_, handle| handle.upgrade().is_some()); + } +} + +impl Default for FocusTrapManager { + fn default() -> Self { + Self::new() + } +} + +/// A wrapper element that implements focus trap behavior. +/// +/// This element wraps another element and registers it as a focus trap container. +/// Focus will automatically cycle within the container when Tab/Shift-Tab is pressed. +pub struct FocusTrapContainer { + id: ElementId, + focus_handle: FocusHandle, + base: E, +} + +impl FocusTrapContainer { + pub(crate) fn new(id: impl Into, focus_handle: FocusHandle, child: E) -> Self { + Self { + id: id.into(), + base: child.track_focus(&focus_handle), + focus_handle, + } + } +} + +impl IntoElement + for FocusTrapContainer +{ + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} +impl ParentElement + for FocusTrapContainer +{ + fn extend(&mut self, elements: impl IntoIterator) { + self.base.extend(elements); + } +} +impl InteractiveElement + for FocusTrapContainer +{ + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} +impl StatefulInteractiveElement + for FocusTrapContainer +{ +} +impl Styled for FocusTrapContainer { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl Element + for FocusTrapContainer +{ + type RequestLayoutState = E::RequestLayoutState; + type PrepaintState = E::PrepaintState; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + // Register this focus trap with the manager + FocusTrapManager::register_trap(global_id.unwrap(), self.focus_handle.downgrade(), cx); + + self.base.request_layout(global_id, None, window, cx) + } + + fn prepaint( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.base + .prepaint(global_id, inspector_id, bounds, request_layout, window, cx) + } + + fn paint( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.base.paint( + global_id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ) + } +} + +/// Returns the focus handle for the active focus trap, if any. +#[doc(hidden)] +pub fn active_focus_trap(window: &Window, cx: &App) -> Option { + FocusTrapManager::find_active_trap(window, cx) +} diff --git a/vendor/gpui-base/src/geometry.rs b/vendor/gpui-base/src/geometry.rs new file mode 100644 index 00000000..8145a045 --- /dev/null +++ b/vendor/gpui-base/src/geometry.rs @@ -0,0 +1,151 @@ +// crates/ui/src/geometry.rs +use std::fmt::{self, Debug, Display, Formatter}; + +use gpui::{AbsoluteLength, Axis, Length, Pixels}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A enum for defining the placement of the element. +/// +/// See also: [`Side`] if you need to define the left, right side. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum Placement { + #[serde(rename = "top")] + Top, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Display for Placement { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Placement::Top => write!(f, "Top"), + Placement::Bottom => write!(f, "Bottom"), + Placement::Left => write!(f, "Left"), + Placement::Right => write!(f, "Right"), + } + } +} + +impl Placement { + #[inline] + pub fn is_horizontal(&self) -> bool { + match self { + Placement::Left | Placement::Right => true, + _ => false, + } + } + + #[inline] + pub fn is_vertical(&self) -> bool { + match self { + Placement::Top | Placement::Bottom => true, + _ => false, + } + } + + #[inline] + pub fn axis(&self) -> Axis { + match self { + Placement::Top | Placement::Bottom => Axis::Vertical, + Placement::Left | Placement::Right => Axis::Horizontal, + } + } +} + +// The local Anchor enum has been removed. Use gpui::Anchor instead. + +/// A enum for defining the side of the element. +/// +/// See also: [`Placement`] if you need to define the 4 edges. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Side { + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Side { + /// Returns true if the side is left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + /// Returns true if the side is right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// A trait to extend the [`Axis`] enum with utility methods. +pub trait AxisExt { + fn is_horizontal(self) -> bool; + fn is_vertical(self) -> bool; +} + +impl AxisExt for Axis { + #[inline] + fn is_horizontal(self) -> bool { + self == Axis::Horizontal + } + + #[inline] + fn is_vertical(self) -> bool { + self == Axis::Vertical + } +} + +/// A trait to extend the [`Length`] enum with utility methods. +pub trait LengthExt { + /// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`. + /// + /// If the [`Length`] is [`Length::Auto`], it returns `None`. + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option; +} + +impl LengthExt for Length { + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option { + match self { + Length::Auto => None, + Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)), + } + } +} + +/// A struct for defining the edges of an element. +/// +/// A extend version of [`gpui::Edges`] to serialize/deserialize. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] +#[repr(C)] +pub struct Edges { + /// The size of the top edge. + pub top: T, + /// The size of the right edge. + pub right: T, + /// The size of the bottom edge. + pub bottom: T, + /// The size of the left edge. + pub left: T, +} + +impl Edges +where + T: Clone + Debug + Default + PartialEq, +{ + /// Creates a new `Edges` instance with all edges set to the same value. + pub fn all(value: T) -> Self { + Self { + top: value.clone(), + right: value.clone(), + bottom: value.clone(), + left: value, + } + } +} diff --git a/vendor/gpui-base/src/global_state.rs b/vendor/gpui-base/src/global_state.rs new file mode 100644 index 00000000..e321eb2e --- /dev/null +++ b/vendor/gpui-base/src/global_state.rs @@ -0,0 +1,155 @@ +use std::rc::{Rc, Weak}; + +use gpui::{App, Global, OwnedMenu}; + +/// Holds the deferred interaction context open for as long as it is alive. +/// +/// Handed out by [`GlobalState::register_deferred_popover`]; drop it to close +/// the context again. The registry only ever weighs the token's liveness, so +/// there is nothing inside to read. +pub struct DeferredPopover(#[allow(dead_code)] Rc<()>); + +/// Application-wide state shared by Base behaviors. +pub struct GlobalState { + app_menus: Vec, + deferred_popovers: Vec>, + suppress_text_selection: bool, +} + +impl Global for GlobalState {} + +impl GlobalState { + fn new() -> Self { + Self { + app_menus: Vec::new(), + deferred_popovers: Vec::new(), + suppress_text_selection: false, + } + } + + /// Ensures that the Base global exists. + #[doc(hidden)] + pub fn init(cx: &mut App) { + if !cx.has_global::() { + cx.set_global(Self::new()); + } + } + + /// Suppresses window-level text selection for the current mouse down. + /// + /// Controls that own a press or drag interaction use this so the same + /// pointer event does not also start application text selection. + pub fn suppress_text_selection(cx: &mut App) { + Self::global_mut(cx).suppress_text_selection = true; + } + + /// Clears the current mouse-down text-selection suppression. + #[doc(hidden)] + pub fn reset_text_selection_suppression(cx: &mut App) { + Self::global_mut(cx).suppress_text_selection = false; + } + + /// Returns whether the current mouse down suppresses text selection. + #[doc(hidden)] + pub fn is_text_selection_suppressed(cx: &App) -> bool { + Self::global(cx).suppress_text_selection + } + + pub fn global(cx: &App) -> &Self { + cx.global::() + } + + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } + + /// Returns the application menus. + pub fn app_menus(&self) -> &[OwnedMenu] { + &self.app_menus + } + + /// Replaces the application menus. + pub fn set_app_menus(&mut self, menus: Vec) { + self.app_menus = menus; + } + + /// Returns whether any deferred popup currently owns an open interaction + /// context. + pub fn is_in_deferred_context(cx: &App) -> bool { + Self::global(cx) + .deferred_popovers + .iter() + .any(|popover| popover.strong_count() > 0) + } + + /// Registers an open deferred popup, which stays registered for as long as + /// the returned token is held. + /// + /// A token rather than an identifier, because popup state is routinely + /// dropped without ever being closed: state that stops being rendered — a + /// popover scrolled out of a virtual list, a panel closed while its menu is + /// open — is collected at the end of the frame, with no chance to + /// deregister. A registration that outlived its popup would leave the + /// application believing a popup is open forever, and everything that + /// steps aside for open popups (the native context menu of a text input, + /// say) would stay disabled for the rest of the session. + pub fn register_deferred_popover(cx: &mut App) -> DeferredPopover { + let token = Rc::new(()); + let state = Self::global_mut(cx); + state + .deferred_popovers + .retain(|popover| popover.strong_count() > 0); + state.deferred_popovers.push(Rc::downgrade(&token)); + DeferredPopover(token) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gpui::test] + fn initialization_is_idempotent_and_suppression_can_be_reset(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + GlobalState::init(cx); + GlobalState::suppress_text_selection(cx); + GlobalState::init(cx); + assert!(GlobalState::is_text_selection_suppressed(cx)); + + GlobalState::reset_text_selection_suppression(cx); + assert!(!GlobalState::is_text_selection_suppressed(cx)); + + assert!(!GlobalState::is_in_deferred_context(cx)); + let popover = GlobalState::register_deferred_popover(cx); + assert!(GlobalState::is_in_deferred_context(cx)); + drop(popover); + assert!(!GlobalState::is_in_deferred_context(cx)); + }); + } + + /// Popup state is routinely dropped without being closed first, and a + /// registration that survived it would disable everything that steps aside + /// for an open popup, permanently. + #[gpui::test] + fn a_dropped_registration_closes_the_deferred_context(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + GlobalState::init(cx); + + let outer = GlobalState::register_deferred_popover(cx); + { + let _inner = GlobalState::register_deferred_popover(cx); + assert!(GlobalState::is_in_deferred_context(cx)); + } + assert!(GlobalState::is_in_deferred_context(cx)); + + drop(outer); + assert!(!GlobalState::is_in_deferred_context(cx)); + + // Registering again must not resurrect the collected ones. + let popover = GlobalState::register_deferred_popover(cx); + assert_eq!(GlobalState::global(cx).deferred_popovers.len(), 1); + drop(popover); + assert!(!GlobalState::is_in_deferred_context(cx)); + }); + } +} diff --git a/vendor/gpui-base/src/history.rs b/vendor/gpui-base/src/history.rs new file mode 100644 index 00000000..8cc9bcf4 --- /dev/null +++ b/vendor/gpui-base/src/history.rs @@ -0,0 +1,313 @@ +use instant::{Duration, Instant}; +use std::fmt::Debug; + +/// A HistoryItem represents a single change in the history. +/// It must implement Clone and PartialEq to be used in the History. +pub trait HistoryItem: Clone + PartialEq { + fn version(&self) -> usize; + fn set_version(&mut self, version: usize); +} + +/// The History is used to keep track of changes to a model and to allow undo and redo operations. +/// +/// This is now used in Input for undo/redo operations. You can also use this in +/// your own models to keep track of changes, for example to track the tab +/// history for prev/next features. +/// +/// ## Use cases +/// +/// - Undo/redo operations in Input +/// - Tracking tab history for prev/next features +#[derive(Debug)] +pub struct History { + undos: Vec, + redos: Vec, + last_changed_at: Instant, + version: usize, + ignore: bool, + max_undos: usize, + group_interval: Option, + grouping: bool, + unique: bool, +} + +impl History +where + I: HistoryItem, +{ + pub fn new() -> Self { + Self { + undos: Default::default(), + redos: Default::default(), + ignore: false, + last_changed_at: Instant::now(), + version: 0, + max_undos: 1000, + group_interval: None, + grouping: false, + unique: false, + } + } + + /// Set the maximum number of undo steps to keep, defaults to 1000. + pub fn max_undos(mut self, max_undos: usize) -> Self { + self.max_undos = max_undos; + self + } + + /// Set the history to be unique, defaults to false. + /// If set to true, the history will only keep unique changes. + pub fn unique(mut self) -> Self { + self.unique = true; + self + } + + /// Set the interval in milliseconds to group changes, defaults to None. + pub fn group_interval(mut self, group_interval: Duration) -> Self { + self.group_interval = Some(group_interval); + self + } + + /// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called. + pub fn start_grouping(&mut self) { + self.grouping = true; + } + + /// End grouping changes, this will allow the version to be incremented again. + pub fn end_grouping(&mut self) { + self.grouping = false; + } + + /// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago. + fn inc_version(&mut self) -> usize { + let t = Instant::now(); + if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval { + self.version += 1; + } + + self.last_changed_at = t; + self.version + } + + /// Get the current version number. + pub fn version(&self) -> usize { + self.version + } + + /// Returns whether history recording is currently ignored. + pub fn is_ignoring(&self) -> bool { + self.ignore + } + + /// Sets whether history recording is currently ignored. + pub fn set_ignoring(&mut self, ignoring: bool) { + self.ignore = ignoring; + } + + /// Push a new change to the history. + pub fn push(&mut self, item: I) { + let version = self.inc_version(); + + if self.undos.len() >= self.max_undos { + self.undos.remove(0); + } + + if self.unique { + self.undos.retain(|c| *c != item); + self.redos.retain(|c| *c != item); + } + + let mut item = item; + item.set_version(version); + self.undos.push(item); + } + + /// Get the undo stack. + pub fn undos(&self) -> &Vec { + &self.undos + } + + /// Get the redo stack. + pub fn redos(&self) -> &Vec { + &self.redos + } + + /// Clear the undo and redo stacks. + pub fn clear(&mut self) { + self.undos.clear(); + self.redos.clear(); + } + + /// Undo the last change and return the changes that were undone. + pub fn undo(&mut self) -> Option> { + if let Some(first_change) = self.undos.pop() { + let mut changes = vec![first_change.clone()]; + // pick the next all changes with the same version + while self + .undos + .iter() + .filter(|c| c.version() == first_change.version()) + .count() + > 0 + { + let change = self.undos.pop().unwrap(); + changes.push(change); + } + + self.redos.extend(changes.clone()); + Some(changes) + } else { + None + } + } + + /// Redo the last undone change and return the changes that were redone. + pub fn redo(&mut self) -> Option> { + if let Some(first_change) = self.redos.pop() { + let mut changes = vec![first_change.clone()]; + // pick the next all changes with the same version + while self + .redos + .iter() + .filter(|c| c.version() == first_change.version()) + .count() + > 0 + { + let change = self.redos.pop().unwrap(); + changes.push(change); + } + self.undos.extend(changes.clone()); + Some(changes) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct TabIndex { + tab_index: usize, + version: usize, + } + + impl PartialEq for TabIndex { + fn eq(&self, other: &Self) -> bool { + self.tab_index == other.tab_index + } + } + + impl From for TabIndex { + fn from(value: usize) -> Self { + TabIndex { + tab_index: value, + version: 0, + } + } + } + + impl HistoryItem for TabIndex { + fn version(&self) -> usize { + self.version + } + fn set_version(&mut self, version: usize) { + self.version = version; + } + } + + #[test] + fn test_history() { + let mut history: History = History::new().max_undos(100); + history.push(0.into()); + history.push(3.into()); + history.push(2.into()); + history.push(1.into()); + + assert_eq!(history.version(), 4); + let changes = history.undo().unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tab_index, 1); + + let changes = history.undo().unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tab_index, 2); + + history.push(5.into()); + + let changes = history.redo().unwrap(); + assert_eq!(changes[0].tab_index, 2); + + let changes = history.redo().unwrap(); + assert_eq!(changes[0].tab_index, 1); + + let changes = history.undo().unwrap(); + assert_eq!(changes[0].tab_index, 1); + + let changes = history.undo().unwrap(); + assert_eq!(changes[0].tab_index, 2); + + let changes = history.undo().unwrap(); + assert_eq!(changes[0].tab_index, 5); + + let changes = history.undo().unwrap(); + assert_eq!(changes[0].tab_index, 3); + + let changes = history.undo().unwrap(); + assert_eq!(changes[0].tab_index, 0); + + assert_eq!(history.undo().is_none(), true); + } + + #[test] + fn test_unique_history() { + let mut history: History = History::new().max_undos(100).unique(); + + // Push some items + history.push(0.into()); + history.push(1.into()); + history.push(1.into()); // Duplicate, should be ignored + history.push(2.into()); + history.push(1.into()); // Duplicate, should be remove old, and add new + + // Check the version and undo stack + assert_eq!(history.version(), 5); + assert_eq!(history.undos().len(), 3); + assert_eq!(history.undos().last().unwrap().tab_index, 1); + + // Undo the last change + let changes = history.undo().unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tab_index, 1); + + assert_eq!(history.redos().len(), 1); + // Push duplicate, should be ignored + history.push(2.into()); + + assert_eq!(history.undos().len(), 2); + assert_eq!(history.redos().len(), 1); + + // Redo the last undone change + let changes = history.redo().unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tab_index, 1); + + // Push another item + history.push(3.into()); + + // Check the version and undo stack + assert_eq!(history.version(), 7); + assert_eq!(history.undos().len(), 4); + + // Undo all changes + for _ in 0..4 { + history.undo(); + } + + // Check the undo stack is empty and redo stack has all changes + assert_eq!(history.undos().len(), 0); + assert_eq!(history.redos().len(), 4); + } +} diff --git a/vendor/gpui-base/src/hover_card.rs b/vendor/gpui-base/src/hover_card.rs new file mode 100644 index 00000000..f0448674 --- /dev/null +++ b/vendor/gpui-base/src/hover_card.rs @@ -0,0 +1,282 @@ +use std::rc::Rc; + +use gpui::{ + Anchor, AnyElement, App, Context, ElementId, InteractiveElement as _, IntoElement, + ParentElement as _, Render, RenderOnce, Stateful, StatefulInteractiveElement as _, Task, + Window, div, prelude::FluentBuilder as _, +}; +use instant::Duration; + +use crate::Popup; + +type ContentBuilder = Box< + dyn FnOnce( + &mut HoverCardState, + &mut Window, + &mut Context, + ) -> Stateful, +>; +type OpenChangeHandler = Rc; + +/// An unstyled hover-triggered popup with delayed open and close behavior. +#[derive(IntoElement)] +pub struct HoverCard { + id: ElementId, + anchor: Anchor, + trigger: Option, + content: Option, + open_delay: Duration, + close_delay: Duration, + on_open_change: Option, +} + +impl HoverCard { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + anchor: Anchor::TopCenter, + trigger: None, + content: None, + open_delay: Duration::from_secs_f64(0.6), + close_delay: Duration::from_secs_f64(0.3), + on_open_change: None, + } + } + + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + pub fn trigger(mut self, trigger: impl IntoElement) -> Self { + self.trigger = Some(trigger.into_any_element()); + self + } + + pub fn content(mut self, content: F) -> Self + where + F: FnOnce( + &mut HoverCardState, + &mut Window, + &mut Context, + ) -> Stateful + + 'static, + { + self.content = Some(Box::new(content)); + self + } + + pub fn open_delay(mut self, duration: Duration) -> Self { + self.open_delay = duration; + self + } + + pub fn close_delay(mut self, duration: Duration) -> Self { + self.close_delay = duration; + self + } + + pub fn on_open_change( + mut self, + callback: impl Fn(&bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_change = Some(Rc::new(callback)); + self + } +} + +/// State exposed to a [`HoverCard::content`] builder. +pub struct HoverCardState { + open: bool, + open_delay: Duration, + close_delay: Duration, + open_task: Option>, + close_task: Option>, + epoch: usize, + is_hovering_trigger: bool, + is_hovering_content: bool, +} + +impl HoverCardState { + fn new(open_delay: Duration, close_delay: Duration) -> Self { + Self { + open: false, + open_delay, + close_delay, + open_task: None, + close_task: None, + epoch: 0, + is_hovering_trigger: false, + is_hovering_content: false, + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + fn sync(&mut self, open_delay: Duration, close_delay: Duration) { + self.open_delay = open_delay; + self.close_delay = close_delay; + } + + fn schedule_open(&mut self, cx: &mut Context) { + self.cancel_tasks(); + let epoch = self.next_epoch(); + let delay = self.open_delay; + self.open_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(delay).await; + let _ = this.update(cx, |state, cx| { + if state.epoch == epoch { + state.set_open(true, cx); + } + }); + })); + } + + fn schedule_close(&mut self, cx: &mut Context) { + self.cancel_tasks(); + let epoch = self.next_epoch(); + let delay = self.close_delay; + self.close_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(delay).await; + let _ = this.update(cx, |state, cx| { + if state.epoch == epoch && !state.is_hovering_trigger && !state.is_hovering_content + { + state.set_open(false, cx); + } + }); + })); + } + + fn cancel_tasks(&mut self) { + self.epoch += 1; + self.open_task = None; + self.close_task = None; + } + + fn next_epoch(&mut self) -> usize { + self.epoch += 1; + self.epoch + } + + fn set_open(&mut self, open: bool, cx: &mut Context) { + if self.open != open { + self.open = open; + cx.notify(); + } + } + + fn on_trigger_hover(&mut self, hovering: bool, cx: &mut Context) { + self.is_hovering_trigger = hovering; + if hovering { + self.schedule_open(cx); + } else if !self.is_hovering_content { + self.schedule_close(cx); + } + } + + fn on_content_hover(&mut self, hovering: bool, cx: &mut Context) { + self.is_hovering_content = hovering; + if hovering { + self.cancel_tasks(); + } else if !self.is_hovering_trigger { + self.schedule_close(cx); + } + } +} + +impl Render for HoverCardState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +impl RenderOnce for HoverCard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| { + HoverCardState::new(self.open_delay, self.close_delay) + }); + let was_open = state.read(cx).is_open(); + state.update(cx, |state, _| state.sync(self.open_delay, self.close_delay)); + let open = state.read(cx).is_open(); + if was_open != open { + if let Some(callback) = &self.on_open_change { + callback(&open, window, cx); + } + } + + let trigger = self.trigger.unwrap_or_else(|| div().into_any_element()); + let popup = Popup::new( + self.id, + div().id("trigger").child(trigger).on_hover( + window.listener_for(&state, |state, hovered, _, cx| { + state.on_trigger_hover(*hovered, cx) + }), + ), + ) + .anchor(self.anchor); + + if !open { + return popup; + } + + popup.when_some(self.content, |popup, content| { + let hover = window.listener_for(&state, |state, hovered, _, cx| { + state.on_content_hover(*hovered, cx) + }); + popup.content(state.update(cx, |state, cx| content(state, window, cx).on_hover(hover))) + }) + } +} + +#[cfg(test)] +mod tests { + use gpui::{Context, InteractiveElement as _, Render, Styled as _, TestAppContext, point, px}; + + use super::*; + + struct Harness; + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let delay = Duration::from_millis(100); + HoverCard::new("hover-card") + .open_delay(delay) + .close_delay(delay) + .trigger( + div() + .debug_selector(|| "hover-card-trigger".into()) + .size(px(20.)), + ) + .content(|_, _, _| { + div() + .id("hover-card-content") + .debug_selector(|| "hover-card-content".into()) + .size(px(10.)) + }) + } + } + + #[gpui::test] + fn public_hover_card_owns_delayed_open_and_close(cx: &mut TestAppContext) { + let delay = Duration::from_millis(100); + let (_, cx) = cx.add_window_view(|_, _| Harness); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default()); + cx.executor().advance_clock(delay); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + assert!(cx.debug_bounds("hover-card-content").is_some()); + + cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default()); + cx.executor().advance_clock(delay); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("hover-card-content").is_none()); + } +} diff --git a/vendor/gpui-base/src/index_path.rs b/vendor/gpui-base/src/index_path.rs new file mode 100644 index 00000000..c82b48f9 --- /dev/null +++ b/vendor/gpui-base/src/index_path.rs @@ -0,0 +1,102 @@ +use std::fmt::{Debug, Display}; + +use gpui::ElementId; + +/// Represents an index path in a list, which consists of a section index, +/// +/// The default values for section, row, and column are all set to 0. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct IndexPath { + /// The section index. + pub section: usize, + /// The item index in the section. + pub row: usize, + /// The column index. + pub column: usize, +} + +impl From for ElementId { + fn from(path: IndexPath) -> Self { + ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into()) + } +} + +impl Display for IndexPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "IndexPath(section: {}, row: {}, column: {})", + self.section, self.row, self.column + ) + } +} + +impl IndexPath { + /// Create a new index path with the specified section and row. + /// + /// The `section` is set to 0 by default. + /// The `column` is set to 0 by default. + pub fn new(row: usize) -> Self { + IndexPath { + section: 0, + row, + ..Default::default() + } + } + + /// Set the section for the index path. + pub fn section(mut self, section: usize) -> Self { + self.section = section; + self + } + + /// Set the row for the index path. + pub fn row(mut self, row: usize) -> Self { + self.row = row; + self + } + + /// Set the column for the index path. + pub fn column(mut self, column: usize) -> Self { + self.column = column; + self + } + + /// Check if the self is equal to the given index path (Same section and row). + pub fn eq_row(&self, index: IndexPath) -> bool { + self.section == index.section && self.row == index.row + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_into_element_id() { + let index_path = IndexPath::new(2).section(1).column(3); + let element_id: ElementId = index_path.into(); + assert_eq!(element_id.to_string(), "index-path(1,2,3)"); + } + + #[test] + fn test_display() { + assert_eq!( + format!("{}", IndexPath::new(2).section(1).column(3)), + "IndexPath(section: 1, row: 2, column: 3)" + ); + } + + #[test] + fn test_index_path() { + let mut index_path = IndexPath::default(); + assert_eq!(index_path.section, 0); + assert_eq!(index_path.row, 0); + assert_eq!(index_path.column, 0); + + index_path = index_path.section(1).row(2).column(3); + assert_eq!(index_path.section, 1); + assert_eq!(index_path.row, 2); + assert_eq!(index_path.column, 3); + } +} diff --git a/vendor/gpui-base/src/input/README.md b/vendor/gpui-base/src/input/README.md new file mode 100644 index 00000000..604be7e7 --- /dev/null +++ b/vendor/gpui-base/src/input/README.md @@ -0,0 +1,20 @@ +# Input module layout + +The public Rust module remains `gpui_base::input`; the folders below organize +its implementation without adding public module-path segments. + +- `base/` contains the shared text-editing engine and foundational behavior: + state, layout, cursor, selection, movement, masking, native integration, and + painting. +- `input/` contains the single-line `Input` element and `InputState` facade. +- `textarea/` contains the multi-line `Textarea` element and `TextareaState` + facade. +- `editor/` contains the `Editor` element and `EditorState` facade together + with display mapping, highlighting, search, diagnostics, decorations, + indentation, and LSP integration. + +`mod.rs` is the external seam. Keep public re-exports there so reorganizing the +implementation does not change callers' imports. + +The root uses explicit `#[path]` declarations so this organization remains an +implementation detail and existing internal module relationships stay intact. diff --git a/vendor/gpui-component/src/input/blink_cursor.rs b/vendor/gpui-base/src/input/base/blink_cursor.rs similarity index 76% rename from vendor/gpui-component/src/input/blink_cursor.rs rename to vendor/gpui-base/src/input/base/blink_cursor.rs index 0ce7d955..d1b4cdd0 100644 --- a/vendor/gpui-component/src/input/blink_cursor.rs +++ b/vendor/gpui-base/src/input/base/blink_cursor.rs @@ -1,9 +1,13 @@ -use std::time::Duration; - -use gpui::{px, Context, Pixels, Task, Timer}; +use gpui::{Context, Pixels, Task, px}; +use instant::Duration; static INTERVAL: Duration = Duration::from_millis(500); static PAUSE_DELAY: Duration = Duration::from_millis(300); + +// On Windows, Linux, we should use integer to avoid blurry cursor. +#[cfg(not(target_os = "macos"))] +pub(super) const CURSOR_WIDTH: Pixels = px(2.); +#[cfg(target_os = "macos")] pub(super) const CURSOR_WIDTH: Pixels = px(1.5); /// To manage the Input cursor blinking. @@ -21,7 +25,7 @@ pub(crate) struct BlinkCursor { } impl BlinkCursor { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { visible: false, paused: false, @@ -31,11 +35,11 @@ impl BlinkCursor { } /// Start the blinking - pub fn start(&mut self, cx: &mut Context) { + pub(crate) fn start(&mut self, cx: &mut Context) { self.blink(self.epoch, cx); } - pub fn stop(&mut self, cx: &mut Context) { + pub(crate) fn stop(&mut self, cx: &mut Context) { self.epoch = 0; cx.notify(); } @@ -57,20 +61,20 @@ impl BlinkCursor { // Schedule the next blink let epoch = self.next_epoch(); self._task = cx.spawn(async move |this, cx| { - Timer::after(INTERVAL).await; + cx.background_executor().timer(INTERVAL).await; if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| this.blink(epoch, cx)).ok(); + this.update(cx, |this, cx| this.blink(epoch, cx)); } }); } - pub fn visible(&self) -> bool { + pub(crate) fn visible(&self) -> bool { // Keep showing the cursor if paused self.paused || self.visible } /// Pause the blinking, and delay 500ms to resume the blinking. - pub fn pause(&mut self, cx: &mut Context) { + pub(crate) fn pause(&mut self, cx: &mut Context) { self.paused = true; self.visible = true; cx.notify(); @@ -78,14 +82,13 @@ impl BlinkCursor { // delay 500ms to start the blinking let epoch = self.next_epoch(); self._task = cx.spawn(async move |this, cx| { - Timer::after(PAUSE_DELAY).await; + cx.background_executor().timer(PAUSE_DELAY).await; if let Some(this) = this.upgrade() { this.update(cx, |this, cx| { this.paused = false; this.blink(epoch, cx); - }) - .ok(); + }); } }); } diff --git a/vendor/gpui-component/src/input/change.rs b/vendor/gpui-base/src/input/base/change.rs similarity index 71% rename from vendor/gpui-component/src/input/change.rs rename to vendor/gpui-base/src/input/base/change.rs index bd771a9a..84565470 100644 --- a/vendor/gpui-component/src/input/change.rs +++ b/vendor/gpui-base/src/input/base/change.rs @@ -1,28 +1,34 @@ use std::fmt::Debug; -use crate::{history::HistoryItem, input::Selection}; +use crate::{HistoryItem, input::Selection}; #[derive(Debug, PartialEq, Clone)] -pub struct Change { +pub(super) struct Change { pub(crate) old_range: Selection, pub(crate) old_text: String, pub(crate) new_range: Selection, pub(crate) new_text: String, + pub(crate) selection_before: Selection, + pub(crate) selection_after: Selection, version: usize, } impl Change { - pub fn new( + pub(super) fn new( old_range: impl Into, old_text: &str, new_range: impl Into, new_text: &str, + selection_before: Selection, + selection_after: Selection, ) -> Self { Self { old_range: old_range.into(), old_text: old_text.to_string(), new_range: new_range.into(), new_text: new_text.to_string(), + selection_before, + selection_after, version: 0, } } diff --git a/vendor/gpui-component/src/input/cursor.rs b/vendor/gpui-base/src/input/base/cursor.rs similarity index 100% rename from vendor/gpui-component/src/input/cursor.rs rename to vendor/gpui-base/src/input/base/cursor.rs diff --git a/vendor/gpui-base/src/input/base/element.rs b/vendor/gpui-base/src/input/base/element.rs new file mode 100644 index 00000000..ed55d50c --- /dev/null +++ b/vendor/gpui-base/src/input/base/element.rs @@ -0,0 +1,3195 @@ +use crate::input::{InputExtras as _, InputModeKind}; +use gpui::Corners; +use gpui::Half; +use gpui::{ + AnyElement, App, Bounds, Edges, Element, ElementId, ElementInputHandler, Entity, + GlobalElementId, +}; +use gpui::{ + HighlightStyle, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, LayoutId, + MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Path, Pixels, Point, Position, + ShapedLine, SharedString, Size, Style, Styled as _, TextAlign, TextRun, TextStyle, + UnderlineStyle, Window, fill, point, px, relative, size, +}; +use ropey::Rope; +use smallvec::SmallVec; +use std::{ops::Range, rc::Rc}; + +use crate::{ + Scrollbar, + input::{RopeExt as _, blink_cursor::CURSOR_WIDTH, display_map::LineLayout}, +}; + +use super::{ + InputBaseState, TextDecoration, + layout::{LastLayout, WhitespaceIndicators}, + mode::LayoutMode, +}; + +fn diagnostic_highlight_style( + severity: crate::input::DiagnosticSeverity, + colors: crate::input::DiagnosticColors, +) -> HighlightStyle { + let color = match severity { + crate::input::DiagnosticSeverity::Error => colors.error, + crate::input::DiagnosticSeverity::Warning => colors.warning, + crate::input::DiagnosticSeverity::Info => colors.info, + crate::input::DiagnosticSeverity::Hint => colors.hint, + }; + HighlightStyle { + underline: Some(UnderlineStyle { + color: Some(color), + thickness: px(1.), + wavy: true, + }), + ..Default::default() + } +} + +const BOTTOM_MARGIN_ROWS: usize = 3; +pub(super) const RIGHT_MARGIN: Pixels = px(10.); +pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.); +const FOLD_ICON_WIDTH: Pixels = px(14.); +const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.); +const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000; +const FOLD_CHEVRON_RIGHT_SVG: &[u8] = br#""#; +const FOLD_CHEVRON_DOWN_SVG: &[u8] = br#""#; + +fn compose_decorations( + mut styles: Vec<(Range, HighlightStyle)>, + decorations: impl IntoIterator, HighlightStyle)>, + visible_byte_range: Range, +) -> Option, HighlightStyle)>> { + let mut visible_decorations = decorations + .into_iter() + .filter_map(|(range, style)| { + let range = + range.start.max(visible_byte_range.start)..range.end.min(visible_byte_range.end); + (!range.is_empty()).then_some((range, style)) + }) + .peekable(); + + if visible_decorations.peek().is_none() { + return (!styles.is_empty()).then_some(styles); + } + if styles.is_empty() { + styles.push((visible_byte_range.clone(), HighlightStyle::default())); + } + + // Decorations are application-authored overrides and must win over syntax + // and semantic highlighting when both set the same style property. + Some(gpui::combine_highlights(styles, visible_decorations).collect()) +} + +fn compose_decoration_collections<'a>( + mut styles: Vec<(Range, HighlightStyle)>, + collections: impl IntoIterator, + visible_byte_range: Range, +) -> Option, HighlightStyle)>> { + // Apply collections in reverse creation order so the first collection is + // composed last and retains its documented precedence. + let collections = collections.into_iter().collect::>(); + for decorations in collections.into_iter().rev() { + styles = compose_decorations( + styles, + decorations + .iter() + .map(|decoration| (decoration.range.clone(), decoration.style)), + visible_byte_range.clone(), + ) + .unwrap_or_default(); + } + + (!styles.is_empty()).then_some(styles) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct EditorScrollbarLayout { + bounds: Bounds, + scroll_size: Size, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct EditorScrollbarSnapshot { + layout: EditorScrollbarLayout, + cursor_scroll_offset: Point, + soft_wrap: bool, +} + +impl EditorScrollbarSnapshot { + fn new( + input_bounds: Bounds, + last_layout: &LastLayout, + scroll_size: Size, + cursor_scroll_offset: Point, + state: &InputBaseState, + ) -> Self { + Self { + layout: EditorScrollbarLayout::new( + input_bounds, + last_layout.line_number_width, + scroll_size, + state.editor_paddings, + ), + cursor_scroll_offset, + soft_wrap: state.soft_wrap, + } + } +} + +impl EditorScrollbarLayout { + fn new( + input_bounds: Bounds, + line_number_width: Pixels, + scroll_size: Size, + paddings: Edges, + ) -> Self { + let left = if line_number_width == px(0.) { + px(0.) + } else { + paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN + }; + + Self { + bounds: Bounds::new( + point( + input_bounds.origin.x + left, + input_bounds.origin.y - paddings.top, + ), + size( + input_bounds.size.width - left + paddings.right, + input_bounds.size.height + paddings.top + paddings.bottom, + ), + ), + scroll_size: size( + scroll_size.width - left + paddings.right + RIGHT_MARGIN, + scroll_size.height, + ), + } + } +} + +pub(super) struct EditorScrollbar { + state: Entity>, +} + +impl EditorScrollbar { + pub(super) fn new(state: Entity>) -> Self { + Self { state } + } +} + +impl IntoElement for EditorScrollbar { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorScrollbar { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + Some("editor-scrollbar".into()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.position = Position::Absolute; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let state = self.state.read(cx); + let Some(snapshot) = state.editor_scrollbar_snapshot.get() else { + return None; + }; + let scroll_handle = state.scroll_handle.clone(); + + if scroll_handle.offset() != snapshot.cursor_scroll_offset { + scroll_handle.set_offset(snapshot.cursor_scroll_offset); + } + + let mut scrollbar = if !snapshot.soft_wrap { + Scrollbar::new(&scroll_handle) + } else { + Scrollbar::vertical(&scroll_handle) + } + .viewport_bounds(snapshot.layout.bounds) + .scroll_size(snapshot.layout.scroll_size) + .into_any_element(); + + scrollbar.prepaint_as_root( + snapshot.layout.bounds.origin, + snapshot.layout.bounds.size.into(), + window, + cx, + ); + Some(scrollbar) + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(scrollbar) = prepaint.as_mut() { + scrollbar.paint(window, cx); + } + } +} + +fn clamp_auto_grow_vertical_scroll_offset( + mode: &LayoutMode, + scroll_top: Pixels, + scroll_height: Pixels, + input_height: Pixels, +) -> Pixels { + if mode.is_auto_grow() { + scroll_top.clamp((input_height - scroll_height).min(px(0.)), px(0.)) + } else { + scroll_top + } +} + +fn editor_gutter_bounds( + input_bounds: Bounds, + line_number_width: Pixels, + ghost_lines_height: Pixels, + paddings: Edges, +) -> Bounds { + Bounds { + origin: point( + input_bounds.origin.x - paddings.left, + input_bounds.origin.y - paddings.top, + ), + size: size( + line_number_width + paddings.left, + input_bounds.size.height + ghost_lines_height + paddings.top + paddings.bottom, + ), + } +} + +use super::MASK_CHAR; + +/// Convert a byte offset in the original text to a byte offset in the masked display string. +/// +/// The masked string consists of `MASK_CHAR` repeated once per character in the original text. +/// Since `MASK_CHAR` may be multi-byte in UTF-8, the byte offset in the masked string is +/// `char_index * MASK_CHAR.len_utf8()`. +fn masked_display_offset(text: &Rope, original_offset: usize) -> usize { + text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8() +} + +/// Move the IME marked range (tracked against the original text) into the display text +/// coordinate space, so that a run boundary can't land inside a multi-byte `MASK_CHAR` +/// and panic text shaping on a non-char-boundary slice. +fn ime_marked_display_range( + text: &Rope, + marked_range: Option>, + masked: bool, +) -> Option> { + let marked = marked_range?; + if masked { + Some(masked_display_offset(text, marked.start)..masked_display_offset(text, marked.end)) + } else { + Some(marked) + } +} + +/// Minimum pixel padding the cursor is kept clear of the viewport's +/// top/bottom edges before auto-scroll engages. Backs +/// [`InputBaseState::cursor_surrounding_lines`]. +/// +/// Auto-grow uses one line. Otherwise `None` falls back to the historical +/// heuristic ([`BOTTOM_MARGIN_ROWS`] lines, or one line on small +/// viewports); `Some(n)` uses `n` lines. The result is saturated against +/// half the viewport so an oversized override can't invert the +/// top/bottom thresholds into a scroll feedback loop. +pub(super) fn cursor_surrounding_padding( + is_auto_grow: bool, + override_lines: Option, + visible_lines: usize, + line_height: Pixels, +) -> Pixels { + if is_auto_grow { + return line_height; + } + let raw = match override_lines { + Some(lines) => lines as f32 * line_height, + None => { + if visible_lines < BOTTOM_MARGIN_ROWS * 8 { + line_height + } else { + BOTTOM_MARGIN_ROWS * line_height + } + } + }; + // Saturate against half the viewport so top + bottom margins can coexist. + let viewport_half = (visible_lines as f32 * line_height).half(); + raw.min(viewport_half) +} + +/// Pixel height of the empty area below the last line in the editor's +/// scrollable region. Backs [`InputBaseState::scroll_beyond_last_line`]. +/// +/// `0` outside code-editor mode. Inside it, `None` is half the viewport +/// (floored at [`BOTTOM_MARGIN_ROWS`] line-heights); `Some(n)` is exactly +/// `n` line-heights. +fn empty_bottom_height( + is_code_editor: bool, + override_rows: Option, + viewport_height: Pixels, + line_height: Pixels, +) -> Pixels { + if !is_code_editor { + return px(0.); + } + match override_rows { + Some(rows) => rows as f32 * line_height, + None => viewport_height.half().max(BOTTOM_MARGIN_ROWS * line_height), + } +} + +/// Layout information for fold icons. +struct FoldIconLayout { + /// Hitbox for the line number area (used for hover detection) + line_number_hitbox: Hitbox, + /// List of (display_row, is_folded, icon_element) pairs for each fold candidate + icons: Vec<(usize, bool, gpui::AnyElement)>, +} + +pub(super) struct TextElement { + pub(crate) state: Entity>, + placeholder: SharedString, +} + +impl TextElement { + pub(super) fn new(state: Entity>) -> Self { + Self { + state, + placeholder: SharedString::default(), + } + } + + /// Set the placeholder text of the input field. + pub(super) fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = placeholder.into(); + self + } + + fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { + window.on_mouse_event({ + let state = self.state.clone(); + + move |event: &MouseMoveEvent, _, window, cx| { + if event.pressed_button == Some(MouseButton::Left) { + state.update(cx, |state, cx| { + state.on_drag_move(event, window, cx); + }); + } + } + }); + + window.on_mouse_event({ + let state = self.state.clone(); + move |_: &MouseUpEvent, phase, _, cx| { + if !phase.bubble() { + return; + } + + // Stop auto-scroll when mouse up, and also stop selecting. + state.update(cx, |state, _| { + state.auto_scroll.stop(); + state.selecting = false; + }); + } + }); + } + + /// Returns the: + /// + /// - cursor bounds + /// - scroll offset + /// - current row index (No only the visible lines, but all lines) + /// + /// This method also will update for track scroll to cursor. + fn layout_cursor( + &self, + last_layout: &LastLayout, + bounds: &mut Bounds, + scroll_size: Size, + _: &mut Window, + cx: &mut App, + ) -> (Option>, Point, Option) { + let state = self.state.read(cx); + + let line_height = last_layout.line_height; + let visible_range = &last_layout.visible_range; + let lines = &last_layout.lines; + let line_number_width = last_layout.line_number_width; + + let mut selected_range = state.selected_range; + + if let Some(ime_marked_range) = &state.ime_marked_range { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + } + let is_selected_all = selected_range.len() == state.text.len(); + + let mut cursor = state.cursor(); + // Buffer rows from the raw (pre-mask) offsets, used to locate the cursor line. + let cursor_row = state.text.offset_to_point(cursor).row; + let sel_start_row = state.text.offset_to_point(selected_range.start).row; + let sel_end_row = state.text.offset_to_point(selected_range.end).row; + if state.masked { + selected_range.start = masked_display_offset(&state.text, selected_range.start); + selected_range.end = masked_display_offset(&state.text, selected_range.end); + cursor = masked_display_offset(&state.text, cursor); + } + + let mut scroll_offset = state.scroll_handle.offset(); + + // Padding kept between the cursor and the viewport's top/bottom + // edges, used by the auto-scroll-into-view computation below. + let top_bottom_margin = cursor_surrounding_padding( + state.mode.is_auto_grow(), + state.cursor_surrounding_lines, + visible_range.len(), + line_height, + ); + + // Resolve a cursor or selection endpoint to a content-space position. + let visible_buffer_lines = &last_layout.visible_buffer_lines; + let caret_for = |row: usize, offset: usize, affinity: bool| -> Point { + // y of the top of buffer line `row` in content space. + let top = line_height * state.display_map.buffer_line_to_display_row(row); + let line_origin = point(px(0.), top); + + if let Some(vi) = visible_buffer_lines.iter().position(|&bl| bl == row) { + let line = &lines[vi]; + let line_start = last_layout.visible_line_byte_offsets[vi]; + let local = offset.saturating_sub(line_start); + if let Some(pos) = line.position_for_index(local, last_layout, affinity) { + return line_origin + pos; + } + } + line_origin + }; + + let current_row = Some(cursor_row); + let cursor_pos = caret_for(cursor_row, cursor, state.cursor_line_end_affinity); + let cursor_start = caret_for(sel_start_row, selected_range.start, false); + let cursor_end = caret_for(sel_end_row, selected_range.end, false); + + let cursor_bounds = { + let selection_changed = state.last_selected_range != Some(selected_range); + let auto_scrolling = state.auto_scroll.is_active(); + if selection_changed && !is_selected_all { + // For Right alignment use 0 margin: cursor is clamped to bounds separately, + // so we never scroll the text for cursor-at-edge, avoiding a first-click jump. + let safety_margin = match last_layout.text_align { + TextAlign::Left => RIGHT_MARGIN, + TextAlign::Right => px(0.), + TextAlign::Center => CURSOR_WIDTH, + }; + + scroll_offset.x = if scroll_offset.x + cursor_pos.x + > (bounds.size.width - line_number_width - safety_margin) + { + // cursor is out of right + bounds.size.width - line_number_width - safety_margin - cursor_pos.x + } else if scroll_offset.x + cursor_pos.x < px(0.) { + // cursor is out of left + scroll_offset.x - cursor_pos.x + } else { + scroll_offset.x + }; + + // Vertical cursor-follow is suppressed while auto-scroll manages the y axis, + // to prevent fighting the background scroll task. + if !auto_scrolling { + // If we change the scroll_offset.y, GPUI will render and trigger the next run loop. + // So, here we just adjust offset by `line_height` for move smooth. + scroll_offset.y = if scroll_offset.y + cursor_pos.y + > bounds.size.height - top_bottom_margin + { + // cursor is out of bottom + scroll_offset.y - line_height + } else if scroll_offset.y + cursor_pos.y < top_bottom_margin { + // cursor is out of top + (scroll_offset.y + line_height).min(px(0.)) + } else { + scroll_offset.y + }; + } + + // For selection to move scroll + if state.selection_reversed { + if scroll_offset.x + cursor_start.x < px(0.) { + // selection start is out of left + scroll_offset.x = -cursor_start.x; + } + if !auto_scrolling && scroll_offset.y + cursor_start.y < px(0.) { + // selection start is out of top + scroll_offset.y = -cursor_start.y; + } + } else { + // TODO: Consider to remove this part, + // maybe is not necessary (But selection_reversed is needed). + if scroll_offset.x + cursor_end.x <= px(0.) { + // selection end is out of left + scroll_offset.x = -cursor_end.x; + } + if !auto_scrolling && scroll_offset.y + cursor_end.y <= px(0.) { + // selection end is out of top + scroll_offset.y = -cursor_end.y; + } + } + } + + // cursor bounds + let cursor_height = 0.85 * line_height; + + // Match the caret to the deferred scroll target (applied below) that + // the text paints at; otherwise the caret follows the cursor-scroll + // while the text uses the deferred offset, flashing it mid-field. + let cursor_scroll_x = state + .deferred_scroll_offset + .map(|offset| offset.x) + .unwrap_or(scroll_offset.x); + + // For Right alignment, clamp cursor within the right edge of bounds so it + // stays visible without having to shift the text via scroll_offset. + let cursor_x = bounds.left() + cursor_pos.x + line_number_width + cursor_scroll_x; + let cursor_x = if last_layout.text_align == TextAlign::Right { + cursor_x.min(bounds.right() - CURSOR_WIDTH) + } else { + cursor_x + }; + Some(Bounds::new( + point( + cursor_x, + bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), + ), + size(CURSOR_WIDTH, cursor_height), + )) + }; + + if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { + scroll_offset = deferred_scroll_offset; + } + scroll_offset.y = clamp_auto_grow_vertical_scroll_offset( + &state.mode, + scroll_offset.y, + scroll_size.height, + bounds.size.height, + ); + + bounds.origin = bounds.origin + scroll_offset; + + (cursor_bounds, scroll_offset, current_row) + } + + /// Layout the match range to a Path. + pub(crate) fn layout_match_range( + range: Range, + last_layout: &LastLayout, + bounds: &Bounds, + ) -> Option> { + if range.is_empty() { + return None; + } + + if range.start < last_layout.visible_range_offset.start + || range.end > last_layout.visible_range_offset.end + { + return None; + } + + let line_height = last_layout.line_height; + let visible_top = last_layout.visible_top; + let lines = &last_layout.lines; + let line_number_width = last_layout.line_number_width; + + let start_ix = range.start; + let end_ix = range.end; + + // Start from visible_top (which already accounts for all lines before visible range) + let mut offset_y = visible_top; + let mut line_corners = vec![]; + + // Iterate only over visible (non-hidden) buffer lines + for (prev_lines_offset, line) in last_layout + .visible_line_byte_offsets + .iter() + .zip(lines.iter()) + { + let prev_lines_offset = *prev_lines_offset; + let line_size = line.size(line_height); + let line_wrap_width = line_size.width; + + let line_origin = point(px(0.), offset_y); + + let line_cursor_start = line.position_for_index( + start_ix.saturating_sub(prev_lines_offset), + last_layout, + false, + ); + let line_cursor_end = line.position_for_index( + end_ix.saturating_sub(prev_lines_offset), + last_layout, + false, + ); + + if line_cursor_start.is_some() || line_cursor_end.is_some() { + let start = line_cursor_start + .unwrap_or_else(|| line.position_for_index(0, last_layout, false).unwrap()); + + let end = line_cursor_end.unwrap_or_else(|| { + line.position_for_index(line.len(), last_layout, false) + .unwrap() + }); + + // Split the selection into multiple items + let wrapped_lines = + (end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize; + + let mut end_x = end.x; + if wrapped_lines > 0 { + end_x = line_wrap_width; + } + + // Ensure at least 6px width for the selection for empty lines. + end_x = end_x.max(start.x + px(6.)); + + line_corners.push(Corners { + top_left: line_origin + point(start.x, start.y), + top_right: line_origin + point(end_x, start.y), + bottom_left: line_origin + point(start.x, start.y + line_height), + bottom_right: line_origin + point(end_x, start.y + line_height), + }); + + // wrapped lines + for i in 1..=wrapped_lines { + let indent = line.wrap_indent; + let start = point(indent, start.y + i as f32 * line_height); + let mut end = point(end.x, end.y + i as f32 * line_height); + if i < wrapped_lines { + end.x = line_size.width; + } + + line_corners.push(Corners { + top_left: line_origin + point(start.x, start.y), + top_right: line_origin + point(end.x, start.y), + bottom_left: line_origin + point(start.x, start.y + line_height), + bottom_right: line_origin + point(end.x, start.y + line_height), + }); + } + } + + if line_cursor_start.is_some() && line_cursor_end.is_some() { + break; + } + + offset_y += line_size.height; + } + + let mut points = vec![]; + if line_corners.is_empty() { + return None; + } + + // Fix corners to make sure the left to right direction + for corners in &mut line_corners { + if corners.top_left.x > corners.top_right.x { + std::mem::swap(&mut corners.top_left, &mut corners.top_right); + std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right); + } + } + + for corners in &line_corners { + points.push(corners.top_right); + points.push(corners.bottom_right); + points.push(corners.bottom_left); + } + + let mut rev_line_corners = line_corners.iter().rev().peekable(); + while let Some(corners) = rev_line_corners.next() { + points.push(corners.top_left); + if let Some(next) = rev_line_corners.peek() { + if next.top_left.x != corners.top_left.x { + points.push(point(next.top_left.x, corners.top_left.y)); + } + } + } + + // print_points_as_svg_path(&line_corners, &points); + + let path_origin = bounds.origin + point(line_number_width, px(0.)); + let first_p = *points.get(0).unwrap(); + let mut builder = gpui::PathBuilder::fill(); + builder.move_to(path_origin + first_p); + for p in points.iter().skip(1) { + builder.line_to(path_origin + *p); + } + + builder.build().ok() + } + + fn layout_search_matches( + &self, + last_layout: &LastLayout, + bounds: &Bounds, + cx: &mut App, + ) -> Vec<(Path, bool)> { + let state = self.state.read(cx); + if !state.search_session.open { + return vec![]; + } + let ranges = state.search_session.matcher.matched_ranges(); + let current_match_ix = state.search_session.matcher.current_match_index(); + + let mut paths = Vec::with_capacity(ranges.as_ref().len()); + for (index, range) in ranges.as_ref().iter().enumerate() { + if let Some(path) = Self::layout_match_range(range.clone(), last_layout, bounds) { + paths.push((path, current_match_ix == index)); + } + } + + paths + } + + fn layout_hover_highlight( + &self, + last_layout: &LastLayout, + bounds: &Bounds, + cx: &mut App, + ) -> Option> { + let state = self.state.read(cx); + let Some(symbol_range) = state.extras.hover_symbol_range() else { + return None; + }; + + Self::layout_match_range(symbol_range, last_layout, bounds) + } + + fn layout_document_colors( + &self, + document_colors: &[(Range, Hsla)], + last_layout: &LastLayout, + bounds: &Bounds, + _cx: &mut App, + ) -> Vec<(Path, Hsla)> { + let mut paths = vec![]; + for (range, color) in document_colors.iter() { + if let Some(path) = Self::layout_match_range(range.clone(), last_layout, bounds) { + paths.push((path, *color)); + } + } + + paths + } + + fn layout_selections( + &self, + last_layout: &LastLayout, + bounds: &mut Bounds, + window: &mut Window, + cx: &mut App, + ) -> Option> { + let state = self.state.read(cx); + if !state.focus_handle.is_focused(window) { + return None; + } + + let mut selected_range = state.selected_range; + if let Some(ime_marked_range) = &state.ime_marked_range { + if !ime_marked_range.is_empty() { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + } + } + if selected_range.is_empty() { + return None; + } + + if state.masked { + selected_range.start = masked_display_offset(&state.text, selected_range.start); + selected_range.end = masked_display_offset(&state.text, selected_range.end); + } + + let (start_ix, end_ix) = if selected_range.start < selected_range.end { + (selected_range.start, selected_range.end) + } else { + (selected_range.end, selected_range.start) + }; + + let range = start_ix.max(last_layout.visible_range_offset.start) + ..end_ix.min(last_layout.visible_range_offset.end); + + Self::layout_match_range(range, &last_layout, bounds) + } + + /// Calculate the visible range of lines in the viewport. + /// + /// Returns + /// + /// - visible_range: The visible range is based on unwrapped lines (Zero based). + /// - visible_buffer_lines: Indices of non-hidden buffer lines within the visible range. + /// - visible_top: The top position of the first visible line in the scroll viewport. + fn calculate_visible_range( + &self, + state: &InputBaseState, + line_height: Pixels, + input_height: Pixels, + ) -> (Range, Vec, Pixels) { + // Add extra rows to avoid showing empty space when scroll to bottom. + let extra_rows = 1; + if state.is_single_line() { + return (0..1, vec![0], px(0.)); + } + + let total_lines = state.display_map.wrap_row_count(); + let display_count = state.display_map.display_row_count(); + let buffer_line_count = state.display_map.buffer_line_count(); + if display_count == 0 || buffer_line_count == 0 { + return (0..0, Vec::new(), px(0.)); + } + + let mut scroll_top = if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { + deferred_scroll_offset.y + } else { + state.scroll_handle.offset().y + }; + scroll_top = clamp_auto_grow_vertical_scroll_offset( + &state.mode, + scroll_top, + line_height * total_lines, + input_height, + ); + + // Display rows are uniformly `line_height` tall, so the visible window maps + // directly to a display-row range. + let viewport_top = (-scroll_top).max(px(0.)); + let viewport_bottom = viewport_top + input_height; + let line_height_f = f32::from(line_height); + let first_display = + ((f32::from(viewport_top) / line_height_f).floor() as usize).min(display_count - 1); + let last_display = + ((f32::from(viewport_bottom) / line_height_f).ceil() as usize).min(display_count - 1); + + let start_line = state.display_map.display_row_to_buffer_line(first_display); + let end_line = state.display_map.display_row_to_buffer_line(last_display); + + // y of the top of the first visible buffer line (in content space). + let visible_top = match state + .display_map + .buffer_line_to_display_row_range(start_line) + { + Some(range) => line_height * range.start, + None => line_height * first_display, + }; + + let visible_range = start_line..(end_line + 1 + extra_rows).min(buffer_line_count); + + // Collect non-hidden buffer lines within the visible range + let mut visible_buffer_lines = Vec::with_capacity(visible_range.len()); + for ix in visible_range.clone() { + if state.display_map.visible_wrap_row_count_for_buffer_line(ix) > 0 { + visible_buffer_lines.push(ix); + } + } + + (visible_range, visible_buffer_lines, visible_top) + } + + /// Return (line_number_width, line_number_len) + fn layout_line_numbers( + state: &InputBaseState, + text: &Rope, + font_size: Pixels, + style: &TextStyle, + window: &mut Window, + ) -> (Pixels, usize) { + let total_lines = text.lines_len(); + // One extra column beyond the widest line number, so right-aligned + // numbers keep a gap from the left edge. + let line_number_len = total_lines.max(1).ilog10() as usize + 2; + + let mut line_number_width = if state.mode.line_number() { + let empty_line_number = window.text_system().shape_line( + "+".repeat(line_number_len).into(), + font_size, + &[TextRun { + len: line_number_len, + font: style.font(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + empty_line_number.width + LINE_NUMBER_RIGHT_MARGIN + } else if state.is_code_editor() { + LINE_NUMBER_RIGHT_MARGIN + } else { + px(0.) + }; + + if state.mode.is_folding() { + // Add extra space for fold icons + line_number_width += FOLD_ICON_HITBOX_WIDTH + } + + (line_number_width, line_number_len) + } + + /// Layout shaped lines for whitespace indicators (space and tab). + /// + /// Returns `WhitespaceIndicators` with shaped lines for space and tab characters. + fn layout_whitespace_indicators( + state: &InputBaseState, + text_size: Pixels, + style: &TextStyle, + window: &mut Window, + _cx: &App, + ) -> Option { + if !state.show_whitespaces { + return None; + } + + let invisible_color = state + .editor_style + .editor_invisible + .unwrap_or(state.editor_style.muted_foreground); + + let space_font_size = text_size.half(); + let tab_font_size = text_size; + + let space_text = SharedString::new_static("•"); + let space = window.text_system().shape_line( + space_text.clone(), + space_font_size, + &[TextRun { + len: space_text.len(), + font: style.font(), + color: invisible_color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + let tab_text = SharedString::new_static("→"); + let tab = window.text_system().shape_line( + tab_text.clone(), + tab_font_size, + &[TextRun { + len: tab_text.len(), + font: style.font(), + color: invisible_color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + Some(WhitespaceIndicators { space, tab }) + } + + /// Compute inline completion ghost lines for rendering. + /// + /// Returns (first_line, ghost_lines) where: + /// - first_line: Shaped text for the first line (goes after cursor on same line) + /// - ghost_lines: Shaped lines for subsequent lines (shift content down) + fn layout_inline_completion( + state: &InputBaseState, + visible_range: &Range, + font_size: Pixels, + window: &mut Window, + _cx: &App, + ) -> (Option, Vec) { + // Must be focused to show inline completion + if !state.focus_handle.is_focused(window) { + return (None, vec![]); + } + + let Some(completion_item) = state.extras.inline_completion_item() else { + return (None, vec![]); + }; + + // Get cursor row from cursor position + let cursor_row = state.cursor_position().line as usize; + + // Only show if cursor row is visible + if cursor_row < visible_range.start || cursor_row >= visible_range.end { + return (None, vec![]); + } + + let completion_text = &completion_item.insert_text; + let completion_color = state.editor_style.muted_foreground.opacity(0.5); + + let text_style = window.text_style(); + let font = text_style.font(); + + let lines: Vec<&str> = completion_text.split('\n').collect(); + if lines.is_empty() { + return (None, vec![]); + } + + // Shape first line (goes after cursor) + let first_text: SharedString = lines[0].to_string().into(); + let first_line = if !first_text.is_empty() { + let first_run = TextRun { + len: first_text.len(), + font: font.clone(), + color: completion_color, + background_color: None, + underline: None, + strikethrough: None, + }; + Some( + window + .text_system() + .shape_line(first_text, font_size, &[first_run], None), + ) + } else { + None + }; + + // Shape ghost lines (lines 2+ that shift content down) + let ghost_lines: Vec = lines[1..] + .iter() + .map(|line_text| { + let text: SharedString = line_text.to_string().into(); + let len = text.len().max(1); // Ensure at least 1 for empty lines + let run = TextRun { + len, + font: font.clone(), + color: completion_color, + background_color: None, + underline: None, + strikethrough: None, + }; + // Use space for empty lines so they take up height + let shaped_text = if text.is_empty() { " ".into() } else { text }; + window + .text_system() + .shape_line(shaped_text, font_size, &[run], None) + }) + .collect(); + + (first_line, ghost_lines) + } + + /// Return (line_number_width, line_number_len) + /// Layout fold icon hitboxes during prepaint phase. + /// + /// This creates hitboxes for the fold icon area, positioned to the right of line numbers. + /// Icons are created and prepainted here to avoid panics. + fn layout_fold_icons( + &self, + origin_x: Pixels, + bounds: &Bounds, + last_layout: &LastLayout, + window: &mut Window, + cx: &mut App, + ) -> FoldIconLayout { + // First pass: collect fold information from state + struct FoldInfo { + buffer_line: usize, + is_folded: bool, + display_row: usize, + offset_y: Pixels, + } + + let line_number_hitbox = window.insert_hitbox( + Bounds::new( + point(origin_x, bounds.origin.y + last_layout.visible_top), + size(last_layout.line_number_width, bounds.size.height), + ), + HitboxBehavior::Normal, + ); + + let mut icon_layout = FoldIconLayout { + line_number_hitbox, + icons: vec![], + }; + + let fold_infos: Vec = { + let state = self.state.read(cx); + if !state.mode.is_folding() { + return icon_layout; + } + + let mut infos = Vec::with_capacity(last_layout.visible_buffer_lines.len()); + let mut offset_y = last_layout.visible_top; + + for (line, &buffer_line) in last_layout + .lines + .iter() + .zip(last_layout.visible_buffer_lines.iter()) + { + if state.display_map.is_fold_candidate(buffer_line) { + let is_folded = state.display_map.is_folded_at(buffer_line); + infos.push(FoldInfo { + buffer_line, + is_folded, + display_row: buffer_line, + offset_y, + }); + } + + offset_y += line.wrapped_lines.len() * last_layout.line_height; + } + + infos + }; // state is dropped here + + // Second pass: create and prepaint icons + let line_height = last_layout.line_height; + let line_number_width = + last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - FOLD_ICON_HITBOX_WIDTH; + let icon_relative_pos = point( + (FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(), + (line_height - FOLD_ICON_WIDTH).half(), + ); + + for (ix, info) in fold_infos.iter().enumerate() { + // Position fold icon to the right of line numbers. + // Use origin_x (unscrolled) so icons stay fixed in the gutter during horizontal scroll. + let fold_icon_bounds = Bounds::new( + point( + origin_x + icon_relative_pos.x + line_number_width, + bounds.origin.y + icon_relative_pos.y + info.offset_y, + ), + size(FOLD_ICON_HITBOX_WIDTH, line_height), + ); + + // Create and prepaint icon + let child = self + .state + .read(cx) + .editor_style + .fold_icon_renderer + .as_ref() + .map(|render| render(ix, info.is_folded)) + .unwrap_or_else(|| { + let (path, svg) = if info.is_folded { + ("input-fold-chevron-right", FOLD_CHEVRON_RIGHT_SVG) + } else { + ("input-fold-chevron-down", FOLD_CHEVRON_DOWN_SVG) + }; + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let color = window.text_style().color; + let _ = window.paint_svg( + bounds, + path.into(), + Some(svg), + gpui::TransformationMatrix::default(), + color, + cx, + ); + }, + ) + .size(FOLD_ICON_WIDTH) + .into_any_element() + }); + let mut icon = gpui::div() + .id(("fold", ix)) + .size(FOLD_ICON_WIDTH) + .child(child) + .on_mouse_down(MouseButton::Left, { + let state = self.state.clone(); + let buffer_line = info.buffer_line; + move |_, _: &mut Window, cx: &mut App| { + cx.stop_propagation(); + + state.update(cx, |state, cx| { + state.display_map.toggle_fold(buffer_line); + cx.notify(); + }); + } + }) + .into_any_element(); + + icon.prepaint_as_root( + fold_icon_bounds.origin, + fold_icon_bounds.size.into(), + window, + cx, + ); + + icon_layout + .icons + .push((info.display_row, info.is_folded, icon)); + } + + icon_layout + } + + /// Paint fold icons using prepaint hitboxes. + /// + /// This handles: + /// - Rendering fold icons (chevron-right for folded, chevron-down for expanded) + /// - Mouse click handling to toggle fold state + /// - Cursor style changes on hover + /// - Only show icon on hover or for current line + fn paint_fold_icons( + &mut self, + fold_icon_layout: &mut FoldIconLayout, + current_row: Option, + window: &mut Window, + cx: &mut App, + ) { + let is_hovered = fold_icon_layout.line_number_hitbox.is_hovered(window); + for (display_row, is_folded, icon) in fold_icon_layout.icons.iter_mut() { + let is_current_line = current_row == Some(*display_row); + + if !is_hovered && !is_current_line && !*is_folded { + continue; + } + + icon.paint(window, cx); + } + } + + #[allow(clippy::too_many_arguments)] + fn layout_lines( + state: &InputBaseState, + display_text: &Rope, + last_layout: &LastLayout, + font_size: Pixels, + runs: &[TextRun], + bg_segments: &[(Range, Hsla)], + whitespace_indicators: Option, + window: &mut Window, + ) -> Vec { + let is_single_line = state.is_single_line(); + + if is_single_line { + let text: SharedString = display_text.to_string().into(); + let aligned_runs = align_runs_to_char_boundaries(&text, runs); + let line_runs = aligned_runs.as_deref().unwrap_or(runs); + let shaped_line = window + .text_system() + .shape_line(text, font_size, line_runs, None); + + let line_layout = LineLayout::new() + .lines(smallvec::smallvec![shaped_line]) + .with_whitespaces(whitespace_indicators); + return vec![line_layout]; + } + + // Empty to use placeholder, the placeholder is not in the wrapper map. + if state.text.len() == 0 { + let placeholder_text = display_text.to_string(); + let mut placeholder_lines = SmallVec::new(); + + for (line, line_runs) in placeholder_line_runs(&placeholder_text, runs) { + let shaped_line = window.text_system().shape_line( + line.to_string().into(), + font_size, + &line_runs, + None, + ); + placeholder_lines.push(shaped_line); + } + + // Keep placeholder lines in a single layout to stay parallel with visible_* metadata. + let line_layout = LineLayout::new() + .lines(placeholder_lines) + .with_whitespaces(whitespace_indicators); + return vec![line_layout]; + } + + let mut lines = Vec::with_capacity(last_layout.visible_buffer_lines.len()); + // run_offset tracks position in the runs vec coordinate space (only visible line bytes). + // This is separate from the visible_text offset because runs from highlight_lines + // only cover visible (non-folded) lines. + let mut run_offset = 0; + + for (vi, &buffer_line) in last_layout.visible_buffer_lines.iter().enumerate() { + let line_text: String = display_text.slice_line(buffer_line).into(); + let line_item = state + .display_map + .line(buffer_line) + .expect("line should exists in wrapper"); + + debug_assert_eq!(line_item.len(), line_text.len()); + + let mut wrapped_lines: SmallVec<[ShapedLine; 1]> = SmallVec::with_capacity(1); + + for range in &line_item.wrapped_lines { + let line_runs = runs_for_range(runs, run_offset, &range); + let line_runs = if bg_segments.is_empty() { + line_runs + } else { + split_runs_by_bg_segments( + last_layout.visible_line_byte_offsets[vi] + (range.start), + &line_runs, + bg_segments, + ) + }; + + let sub_line: SharedString = line_text[range.clone()].to_string().into(); + let line_runs = + align_runs_to_char_boundaries(&sub_line, &line_runs).unwrap_or(line_runs); + let shaped_line = window + .text_system() + .shape_line(sub_line, font_size, &line_runs, None); + + wrapped_lines.push(shaped_line); + } + + // Use the first visual line's indentation width for continuation lines. + let wrap_indent = if line_item.indent > 0 && wrapped_lines.len() > 1 { + let indent_byte_len = line_text + .char_indices() + .nth(line_item.indent as usize) + .map(|(ix, _)| ix) + .unwrap_or(line_text.len()); + wrapped_lines[0].x_for_index(indent_byte_len) + } else { + px(0.) + }; + + let line_layout = LineLayout::new() + .lines(wrapped_lines) + .wrap_indent(wrap_indent) + .with_whitespaces(whitespace_indicators.clone()); + lines.push(line_layout); + + // +1 for the `\n` + run_offset += line_text.len() + 1; + } + + lines + } + + /// First usize is the offset of skipped. + fn highlight_lines( + &mut self, + visible_buffer_lines: &[usize], + _visible_top: Pixels, + visible_byte_range: Range, + cx: &mut App, + ) -> Option, HighlightStyle)>> { + let state = self.state.read(cx); + let text = &state.text; + let is_multi_line = state.is_multi_line(); + + let (mut highlighter, diagnostics) = match &state.mode { + LayoutMode::CodeEditor { + highlighter, + diagnostics, + .. + } => (highlighter.borrow_mut(), diagnostics), + _ => { + return (!state.masked) + .then(|| { + compose_decoration_collections( + Vec::new(), + state.extras.decoration_layers().into_iter(), + visible_byte_range, + ) + }) + .flatten(); + } + }; + let Some(highlighter) = highlighter.as_mut() else { + return (!state.masked) + .then(|| { + compose_decoration_collections( + Vec::new(), + state.extras.decoration_layers().into_iter(), + visible_byte_range, + ) + }) + .flatten(); + }; + + let mut styles = Vec::with_capacity(visible_buffer_lines.len()); + // Byte ranges of the flushed line groups; the composed styles are + // clipped to these at the end so the resulting runs cover exactly the + // visible (non-folded) lines' bytes. + let mut group_ranges: Vec> = Vec::new(); + + // Helper to flush a contiguous range of lines. These ranges are disjoint, + // so appending avoids repeatedly cloning and recombining prior styles. + let mut flush_range = + |start_line: usize, end_line: usize, skip: bool, styles: &mut Vec<_>| { + let byte_start = text.line_start_offset(start_line); + let byte_end = if is_multi_line { + // +1 for `\n` + text.line_start_offset(end_line + 1) + } else { + text.line_end_offset(end_line) + }; + let range_styles = if skip { + vec![(byte_start..byte_end, HighlightStyle::default())] + } else { + highlighter.styles( + &(byte_start..byte_end), + state.editor_style.highlight_styles.as_ref(), + ) + }; + + group_ranges.push(byte_start..byte_end); + styles.extend(range_styles); + }; + + // Group contiguous visible lines into ranges and call styles() once per range + let mut visible_iter = visible_buffer_lines.iter().peekable(); + let mut range_start: Option = None; + + while let Some(&line) = visible_iter.next() { + // Check if this line is too long for highlighting + let line_len = text.slice_line(line).len(); + if line_len > MAX_HIGHLIGHT_LINE_LENGTH { + // Flush any accumulated range first + if let Some(start) = range_start.take() { + flush_range(start, line - 1, false, &mut styles); + } + + flush_range(line, line, true, &mut styles); + continue; + } + + range_start.get_or_insert(line); + + // Check if next line is contiguous, if so keep accumulating + if visible_iter + .peek() + .map(|&&next| next == line + 1) + .unwrap_or(false) + { + continue; + } + + // Flush the contiguous range + let start_line = range_start.take().unwrap(); + flush_range(start_line, line, false, &mut styles); + } + + let diagnostic_styles: Vec<_> = diagnostics + .range(visible_byte_range.clone()) + .map(|entry| { + ( + entry.range.clone(), + diagnostic_highlight_style(entry.severity, state.editor_style.diagnostics), + ) + }) + .collect(); + + // Range semantic tokens, resolved from the LSP provider's cached + // result through the active highlight theme so it shares the same + // colour vocabulary as the tree-sitter path. Empty Vec when no + // provider is set, so `combine_highlights` short-circuits. + let custom_styles = state.extras.semantic_token_styles( + text, + &visible_byte_range, + state.editor_style.highlight_styles.as_ref(), + ); + + // hover definition style + if let Some(hover_style) = M::hover_definition_style(self.state.read(cx), cx) { + styles.push(hover_style); + } + + // Compose tree-sitter, semantic, application, then diagnostic styles. + styles = gpui::combine_highlights(custom_styles, styles).collect(); + if !state.masked { + styles = compose_decoration_collections( + styles, + state.extras.decoration_layers().into_iter(), + visible_byte_range.clone(), + ) + .unwrap_or_default(); + } + styles = gpui::combine_highlights(diagnostic_styles, styles).collect(); + + // Some sources reach outside the flushed groups — a diagnostic + // straddling the viewport edge, or any range inside a folded region — + // which would inject extra bytes into the runs coordinate space and + // shift every later run boundary (see `layout_lines`). Clip the + // composed styles back to the groups. + styles = clip_styles_to_ranges(styles, &group_ranges); + + Some(styles) + } +} + +pub(super) struct PrepaintState { + /// The lines of entire lines. + last_layout: LastLayout, + /// The lines only contains the visible lines in the viewport, based on `visible_range`. + /// + /// The child is the soft lines. + line_numbers: Option>>, + /// Size of the scrollable area by entire lines. + scroll_size: Size, + cursor_bounds: Option>, + cursor_scroll_offset: Point, + /// row index (zero based), no wrap, same line as the cursor. + current_row: Option, + selection_path: Option>, + hover_highlight_path: Option>, + search_match_paths: Vec<(Path, bool)>, + document_color_paths: Vec<(Path, Hsla)>, + hover_definition_hitbox: Option, + indent_guides_path: Option>, + bounds: Bounds, + /// Fold icon layout data + fold_icon_layout: FoldIconLayout, + // Inline completion rendering data + /// Shaped ghost lines to paint after cursor row (completion lines 2+) + ghost_lines: Vec, + /// First line of inline completion (painted after cursor on same line) + ghost_first_line: Option, + ghost_lines_height: Pixels, +} + +impl PrepaintState { + /// Returns cursor bounds adjusted for scroll offset, if available. + fn cursor_bounds_with_scroll(&self) -> Option> { + self.cursor_bounds.map(|mut bounds| { + bounds.origin.y += self.cursor_scroll_offset.y; + bounds + }) + } +} + +impl IntoElement for TextElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// A debug function to print points as SVG path. +#[allow(unused)] +fn print_points_as_svg_path( + line_corners: &Vec>, + points: &Vec>, +) { + for corners in line_corners { + println!( + "tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})", + corners.top_left.as_f32() as i32, + corners.top_left.as_f32() as i32, + corners.top_right.as_f32() as i32, + corners.top_right.as_f32() as i32, + corners.bottom_left.as_f32() as i32, + corners.bottom_left.as_f32() as i32, + corners.bottom_right.as_f32() as i32, + corners.bottom_right.as_f32() as i32, + ); + } + + if points.len() > 0 { + println!( + "M{},{}", + points[0].x.as_f32() as i32, + points[0].y.as_f32() as i32 + ); + for p in points.iter().skip(1) { + println!("L{},{}", p.x.as_f32() as i32, p.y.as_f32() as i32); + } + } +} +impl Element for TextElement { + type RequestLayoutState = (); + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let state = self.state.read(cx); + let line_height = window.line_height(); + + let mut style = Style::default(); + style.size.width = relative(1.).into(); + if state.is_multi_line() { + style.flex_grow = 1.0; + style.size.height = relative(1.).into(); + if state.mode.is_auto_grow() { + // Auto grow to let height match to rows, but not exceed max rows. + let rows = state.mode.max_rows().min(state.mode.rows()); + style.min_size.height = (rows * line_height).into(); + } else { + style.min_size.height = line_height.into(); + } + } else { + // For single-line inputs, the minimum height should be the line height + style.size.height = line_height.into(); + }; + + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let style = window.text_style(); + let font = style.font(); + let text_size = style.font_size.to_pixels(window.rem_size()); + + self.state.update(cx, |state, cx| { + state.display_map.set_font(font, text_size, cx); + state.display_map.ensure_text_prepared(&state.text, cx); + }); + + let state = self.state.read(cx); + let multi_line = state.is_multi_line(); + let text = state.text.clone(); + let is_empty = text.len() == 0; + let placeholder = self.placeholder.clone(); + + let text_style = window.text_style(); + let disabled = state.disabled; + let dim = |color: Hsla| if disabled { color.opacity(0.5) } else { color }; + let fg = dim(text_style.color); + let (display_text, text_color) = if is_empty { + ( + &Rope::from(placeholder.as_str()), + dim(state.editor_style.muted_foreground), + ) + } else if state.masked { + ( + &Rope::from(MASK_CHAR.to_string().repeat(text.chars().count())), + fg, + ) + } else { + (&text, fg) + }; + + // Calculate the width of the line numbers + let (line_number_width, line_number_len) = + Self::layout_line_numbers(&state, &text, text_size, &text_style, window); + + let mut bounds = bounds; + let wrap_width = if multi_line && state.soft_wrap { + Some(bounds.size.width - line_number_width - RIGHT_MARGIN) + } else { + None + }; + + let wrapping_indent = state.wrapping_indent; + let wrap_width_changed = state + .last_layout + .as_ref() + .map(|l| l.wrap_width != wrap_width) + .unwrap_or(true); + + let wrapping_indent_changed = state + .last_layout + .as_ref() + .map(|l| l.wrapping_indent != wrapping_indent) + .unwrap_or(true); + + if wrap_width_changed || wrapping_indent_changed { + self.state.update(cx, |state, cx| { + state.display_map.on_layout_changed(wrap_width, cx); + state.display_map.set_wrapping_indent(wrapping_indent, cx); + }); + } + + let state = self.state.read(cx); + let line_height = window.line_height(); + + let (visible_range, visible_buffer_lines, visible_top) = + self.calculate_visible_range(&state, line_height, bounds.size.height); + let visible_start_offset = state.text.line_start_offset(visible_range.start); + let visible_end_offset = state + .text + .line_end_offset(visible_range.end.saturating_sub(1)); + + let highlight_styles = self.highlight_lines( + &visible_buffer_lines, + visible_top, + visible_start_offset..visible_end_offset, + cx, + ); + + let state = self.state.read(cx); + + let visible_line_byte_offsets: Vec = visible_buffer_lines + .iter() + .map(|&bl| state.text.line_start_offset(bl)) + .collect(); + + // For password input (masked: true), convert byte offsets to masked display byte offsets so that + // layout_match_range and position_for_index work in the correct coordinate space. + let (visible_line_byte_offsets, visible_range_offset) = if state.masked { + let offsets = visible_line_byte_offsets + .iter() + .map(|&o| masked_display_offset(&text, o)) + .collect(); + let range_offset = masked_display_offset(&text, visible_start_offset) + ..masked_display_offset(&text, visible_end_offset); + (offsets, range_offset) + } else { + ( + visible_line_byte_offsets, + visible_start_offset..visible_end_offset, + ) + }; + + let mut last_layout = LastLayout { + visible_range, + visible_buffer_lines, + visible_line_byte_offsets, + visible_top, + visible_range_offset, + line_height, + wrap_width, + wrapping_indent, + line_number_width, + lines: Rc::new(vec![]), + cursor_bounds: None, + text_align: state.text_align, + content_width: bounds.size.width, + }; + + let run = TextRun { + len: display_text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + let marked_run = TextRun { + len: 0, + font: style.font(), + color: text_color, + background_color: None, + underline: Some(UnderlineStyle { + thickness: px(1.), + color: Some(text_color), + wavy: false, + }), + strikethrough: None, + }; + + let ime_marked_range = ime_marked_display_range( + &text, + state.ime_marked_range.as_ref().map(|m| m.start..m.end), + state.masked, + ); + + let runs = if let (false, Some(highlight_styles)) = (is_empty, highlight_styles) { + let mut runs = Vec::with_capacity(highlight_styles.len() + 2); + + for (range, style) in &highlight_styles { + let mut run = text_style.clone().highlight(*style).to_run(range.len()); + if disabled { + run.color = run.color.opacity(0.5); + } + + runs.extend(split_run_for_ime_underline( + run, + range.clone(), + ime_marked_range.clone(), + marked_run.underline, + )); + } + runs + } else { + split_run_for_ime_underline( + run, + 0..display_text.len(), + ime_marked_range, + marked_run.underline, + ) + .into_vec() + }; + + let document_colors = state + .extras + .document_color_swatches(&text, &last_layout.visible_range); + + // Create shaped lines for whitespace indicators before layout + let whitespace_indicators = + Self::layout_whitespace_indicators(&state, text_size, &text_style, window, cx); + + let lines = Self::layout_lines( + &state, + &display_text, + &last_layout, + text_size, + &runs, + &document_colors, + whitespace_indicators, + window, + ); + + let mut longest_line_width = wrap_width.unwrap_or(px(0.)); + // 1. Single line + // 2. Multi-line with soft wrap disabled. + if state.is_single_line() || !state.soft_wrap { + let longest_row = state.display_map.longest_row(); + let longest_line: SharedString = state.text.slice_line(longest_row).to_string().into(); + longest_line_width = window + .text_system() + .shape_line( + longest_line.clone(), + text_size, + &[TextRun { + len: longest_line.len(), + font: style.font(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + wrap_width, + ) + .width; + } + last_layout.lines = Rc::new(lines); + + let (ghost_first_line, ghost_lines) = Self::layout_inline_completion( + state, + &last_layout.visible_range, + text_size, + window, + cx, + ); + let ghost_line_count = ghost_lines.len(); + let ghost_lines_height = ghost_line_count as f32 * line_height; + + let total_wrapped_lines = state.display_map.wrap_row_count(); + let empty_bottom_height = empty_bottom_height( + state.is_code_editor(), + state.scroll_beyond_last_line, + bounds.size.height, + line_height, + ); + + // Empty bottom and ghost lines both describe extra height past the + // last content row, so take the max rather than summing — summing + // left a band of empty space the cursor could never reach. + let mut scroll_size = size( + if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { + longest_line_width + line_number_width + RIGHT_MARGIN + } else { + longest_line_width + }, + (total_wrapped_lines as f32 * line_height + + empty_bottom_height.max(ghost_lines_height)) + .max(bounds.size.height), + ); + + // TODO: should be add some gap to right, to convenient to focus on boundary position + if last_layout.text_align == TextAlign::Right || last_layout.text_align == TextAlign::Center + { + scroll_size.width = longest_line_width + line_number_width; + } + + // `position_for_index` for example + // + // #### text + // + // Hello 世界,this is GPUI component. + // The GPUI Component is a collection of UI components for + // GPUI framework, including Button, Input, Checkbox, Radio, + // Dropdown, Tab, and more... + // + // wrap_width: 444px, line_height: 20px + // + // #### lines[0] + // + // | index | pos | line | + // |-------|------------------|------| + // | 5 | (37 px, 0.0) | 0 | + // | 38 | (261.7 px, 20.0) | 0 | + // | 40 | None | - | + // + // #### lines[1] + // + // | index | position | line | + // |-------|-----------------------|------| + // | 5 | (43.578125 px, 0.0) | 0 | + // | 56 | (422.21094 px, 0.0) | 0 | + // | 57 | (11.6328125 px, 20.0) | 1 | + // | 114 | (429.85938 px, 20.0) | 1 | + // | 115 | (11.3125 px, 40.0) | 2 | + + // Calculate the scroll offset to keep the cursor in view + + // Save the unscrolled x before layout_cursor modifies bounds.origin with scroll_offset. + // Fold icons and their hitboxes must use this value so they stay fixed in the gutter + // regardless of horizontal scroll position. + let input_bounds = bounds; + let original_x = bounds.origin.x; + + let (cursor_bounds, cursor_scroll_offset, current_row) = + self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx); + last_layout.cursor_bounds = cursor_bounds; + + let search_match_paths = self.layout_search_matches(&last_layout, &mut bounds, cx); + let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx); + let hover_highlight_path = self.layout_hover_highlight(&last_layout, &mut bounds, cx); + let document_color_paths = + self.layout_document_colors(&document_colors, &last_layout, &bounds, cx); + + let state = self.state.read(cx); + let line_numbers = if state.mode.line_number() { + let mut line_numbers = Vec::with_capacity(last_layout.visible_buffer_lines.len()); + let other_line_runs = vec![TextRun { + len: line_number_len, + font: style.font(), + color: state.editor_style.muted_foreground, + background_color: None, + underline: None, + strikethrough: None, + }]; + let current_line_runs = vec![TextRun { + len: line_number_len, + font: style.font(), + color: state.editor_style.foreground, + background_color: None, + underline: None, + strikethrough: None, + }]; + + // build line numbers + for (line, &buffer_line) in last_layout + .lines + .iter() + .zip(last_layout.visible_buffer_lines.iter()) + { + let line_no: SharedString = + format!("{:>width$}", buffer_line + 1, width = line_number_len).into(); + + let runs = if current_row == Some(buffer_line) { + ¤t_line_runs + } else { + &other_line_runs + }; + + let mut sub_lines: SmallVec<[ShapedLine; 1]> = SmallVec::new(); + sub_lines.push( + window + .text_system() + .shape_line(line_no, text_size, &runs, None), + ); + for _ in 0..line.wrapped_lines.len().saturating_sub(1) { + sub_lines.push(ShapedLine::default()); + } + line_numbers.push(sub_lines); + } + Some(line_numbers) + } else { + None + }; + + let hover_definition_hitbox = M::hover_definition_hitbox(state, window, cx); + let indent_guides_path = + self.layout_indent_guides(state, &bounds, &last_layout, &text_style, window); + state + .editor_scrollbar_snapshot + .set(Some(EditorScrollbarSnapshot::new( + input_bounds, + &last_layout, + scroll_size, + cursor_scroll_offset, + state, + ))); + let fold_icon_layout = + self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx); + + PrepaintState { + bounds, + last_layout, + scroll_size, + line_numbers, + cursor_bounds, + cursor_scroll_offset, + current_row, + selection_path, + search_match_paths, + hover_highlight_path, + hover_definition_hitbox, + document_color_paths, + indent_guides_path, + fold_icon_layout, + ghost_first_line, + ghost_lines, + ghost_lines_height, + } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + input_bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let (focus_handle, show_cursor, disabled, selected_range, editor_style, editor_paddings) = { + let state = self.state.read(cx); + ( + state.focus_handle.clone(), + state.show_cursor(window, cx), + state.disabled, + state.selected_range, + state.editor_style.clone(), + state.editor_paddings, + ) + }; + let focused = focus_handle.is_focused(window); + let bounds = prepaint.bounds; + let text_align = prepaint.last_layout.text_align; + + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.state.clone()), + cx, + ); + + // Paint multi line text + let line_height = window.line_height(); + let origin = bounds.origin; + + let invisible_top_padding = prepaint.last_layout.visible_top; + let active_line_color = editor_style + .editor_active_line + .map(|color| if disabled { color.opacity(0.5) } else { color }); + let editor_background = if disabled { + editor_style.background.opacity(0.5) + } else { + editor_style.background + }; + + // Paint active line + let mut offset_y = px(0.); + if let Some(line_numbers) = prepaint.line_numbers.as_ref() { + offset_y += invisible_top_padding; + + // Each item is the normal lines. + for (lines, &buffer_line) in line_numbers + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { + let is_active = prepaint.current_row == Some(buffer_line); + let p = point(input_bounds.origin.x, origin.y + offset_y); + let height = line_height * lines.len() as f32; + // Paint the current line background + if is_active { + if let Some(bg_color) = active_line_color { + window.paint_quad(fill( + Bounds::new(p, size(bounds.size.width, height)), + bg_color, + )); + } + } + offset_y += height; + } + } + + // Paint indent guides + if let Some(path) = prepaint.indent_guides_path.take() { + window.paint_path(path, editor_style.border.opacity(0.85)); + } + + // Paint selections + if window.is_window_active() { + let secondary_selection = Hsla { + s: 0.1, + ..editor_style.selection + }; + for (path, is_active) in prepaint.search_match_paths.iter() { + window.paint_path(path.clone(), secondary_selection); + + if *is_active { + window.paint_path(path.clone(), editor_style.selection); + } + } + + if let Some(path) = prepaint.selection_path.take() { + window.paint_path(path, editor_style.selection); + } + + // Paint hover highlight + if let Some(path) = prepaint.hover_highlight_path.take() { + window.paint_path(path, secondary_selection); + } + } + + // Paint document colors + for (path, color) in prepaint.document_color_paths.iter() { + let color = if disabled { color.opacity(0.5) } else { *color }; + window.paint_path(path.clone(), color); + } + + // Paint text with inline completion ghost line support + let mut offset_y = invisible_top_padding; + let ghost_lines = &prepaint.ghost_lines; + let has_ghost_lines = !ghost_lines.is_empty(); + + // Keep scrollbar offset always be positive,Start from the left position + let scroll_offset = if text_align == TextAlign::Right { + (prepaint.scroll_size.width - prepaint.bounds.size.width).max(px(0.)) + } else if text_align == TextAlign::Center { + (prepaint.scroll_size.width - prepaint.bounds.size.width) + .half() + .max(px(0.)) + } else { + px(0.) + }; + + // Track the y-position of the cursor row for positioning the first line suffix + let mut cursor_row_y = None; + + for (line, &buffer_line) in prepaint + .last_layout + .lines + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { + let row = buffer_line; + let line_y = origin.y + offset_y; + let p = point( + origin.x + prepaint.last_layout.line_number_width + (scroll_offset), + line_y, + ); + + // Paint the actual line + _ = line.paint( + p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, + ); + offset_y += line.size(line_height).height; + + if Some(row) == prepaint.current_row { + cursor_row_y = Some(line_y); + } + + // After the cursor row, paint ghost lines (which shifts subsequent content down) + if has_ghost_lines && Some(row) == prepaint.current_row { + let ghost_x = origin.x + prepaint.last_layout.line_number_width; + + for ghost_line in ghost_lines { + let ghost_p = point(ghost_x, origin.y + offset_y); + + // Paint semi-transparent background for ghost line + let ghost_bounds = Bounds::new( + ghost_p, + size( + bounds.size.width - prepaint.last_layout.line_number_width, + line_height, + ), + ); + window.paint_quad(fill(ghost_bounds, editor_background)); + + // Paint ghost line text + _ = ghost_line.paint( + ghost_p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, + ); + offset_y += line_height; + } + } + } + + // Paint blinking cursor + if focused && show_cursor { + if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() { + window.paint_quad(fill(cursor_bounds, editor_style.caret)); + } + } + + // Paint line numbers + let mut offset_y = px(0.); + if let Some(line_numbers) = prepaint.line_numbers.as_ref() { + offset_y += invisible_top_padding; + + // The gutter is a fixed overlay above horizontally scrolling text. + // Always give it an opaque editor background when the theme does not + // provide a dedicated gutter color, and cover the complete gutter so + // text cannot show through its right-side spacing/fold-icon area. + let gutter_bg = editor_style + .editor_gutter_background + .unwrap_or(editor_background); + let gutter_bounds = editor_gutter_bounds( + input_bounds, + prepaint.last_layout.line_number_width, + prepaint.ghost_lines_height, + editor_paddings, + ); + window.paint_quad(fill(gutter_bounds, gutter_bg)); + + // Each item is the normal lines. + for (lines, &buffer_line) in line_numbers + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { + let p = point(input_bounds.origin.x, origin.y + offset_y); + let is_active = prepaint.current_row == Some(buffer_line); + + let height = line_height * lines.len() as f32; + // paint active line number background + if is_active { + if let Some(bg_color) = active_line_color { + window.paint_quad(fill( + Bounds::new( + point(gutter_bounds.origin.x, p.y), + size(gutter_bounds.size.width, height), + ), + bg_color, + )); + } + } + + for line in lines { + _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); + offset_y += line_height; + } + + // Add ghost line height after cursor row for line numbers alignment + if !prepaint.ghost_lines.is_empty() && prepaint.current_row == Some(buffer_line) { + offset_y += prepaint.ghost_lines_height; + } + } + } + + // Paint fold icons (only visible on hover or for current line) + self.paint_fold_icons( + &mut prepaint.fold_icon_layout, + prepaint.current_row, + window, + cx, + ); + + self.state.update(cx, |state, cx| { + state.last_layout = Some(prepaint.last_layout.clone()); + state.last_bounds = Some(bounds); + state.last_cursor = Some(state.cursor()); + state.set_input_bounds(input_bounds, cx); + state.last_selected_range = Some(selected_range); + state.scroll_size = prepaint.scroll_size; + state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx); + state.deferred_scroll_offset = None; + + cx.notify(); + }); + + if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() { + window.set_cursor_style(gpui::CursorStyle::PointingHand, &hitbox); + } + + // Paint inline completion first line suffix (after cursor on same line) + if focused { + if let Some(first_line) = &prepaint.ghost_first_line { + if let (Some(cursor_bounds), Some(cursor_row_y)) = + (prepaint.cursor_bounds_with_scroll(), cursor_row_y) + { + let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; + let p = point(first_line_x, cursor_row_y); + + // Paint background to cover any existing text + let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height)); + window.paint_quad(fill(bg_bounds, editor_background)); + + // Paint first line completion text + _ = first_line.paint(p, line_height, text_align, None, window, cx); + } + } + } + + self.paint_mouse_listeners(window, cx); + } +} + +/// Split placeholder text into display lines and trim runs to each line. +fn placeholder_line_runs<'a>( + display_text: &'a str, + runs: &[TextRun], +) -> Vec<(&'a str, Vec)> { + let mut result = Vec::new(); + let mut line_offset = 0; + + for line in display_text.split('\n') { + let line_runs = runs_for_range(runs, line_offset, &(0..line.len())); + debug_assert_eq!( + line_runs.iter().map(|run| run.len).sum::(), + line.len() + ); + result.push((line, line_runs)); + // Advance in the whole-placeholder coordinate space, including the separator. + line_offset += line.len() + 1; + } + + result +} + +/// Get the runs for the given range. +/// +/// The range is the byte range of the wrapped line. +pub(super) fn runs_for_range( + runs: &[TextRun], + line_offset: usize, + range: &Range, +) -> Vec { + let mut result = vec![]; + let range = (line_offset + range.start)..(line_offset + range.end); + let mut cursor = 0; + + for run in runs { + let run_start = cursor; + let run_end = cursor + run.len; + + if run_end <= range.start { + cursor = run_end; + continue; + } + + if run_start >= range.end { + break; + } + + let start = range.start.max(run_start) - run_start; + let end = range.end.min(run_end) - run_start; + let len = end - start; + + if len > 0 { + result.push(TextRun { len, ..run.clone() }); + } + + cursor = run_end; + } + + result +} + +/// Clip sorted `styles` to the sorted, disjoint `ranges`, splitting styles +/// that span several ranges and dropping coverage in the gaps between them. +fn clip_styles_to_ranges( + styles: Vec<(Range, HighlightStyle)>, + ranges: &[Range], +) -> Vec<(Range, HighlightStyle)> { + let mut result = Vec::with_capacity(styles.len()); + let mut range_ix = 0; + + for (style_range, style) in styles { + while range_ix < ranges.len() && ranges[range_ix].end <= style_range.start { + range_ix += 1; + } + + let mut ix = range_ix; + while ix < ranges.len() && ranges[ix].start < style_range.end { + let start = style_range.start.max(ranges[ix].start); + let end = style_range.end.min(ranges[ix].end); + if start < end { + result.push((start..end, style)); + } + ix += 1; + } + } + + result +} + +/// Snap run boundaries to char boundaries of `text` and cap them at its +/// length. Style ranges are byte offsets that can drift off char boundaries +/// (a stale syntax tree, a range in the wrong coordinate space) and the +/// platform text system panics when a run splits a multi-byte character. +/// +/// Returns `None` when the runs are already aligned (the common case). +pub(super) fn align_runs_to_char_boundaries(text: &str, runs: &[TextRun]) -> Option> { + let mut end = 0; + let aligned = runs.iter().all(|run| { + end += run.len; + end <= text.len() && text.is_char_boundary(end) + }); + if aligned { + return None; + } + + let mut result = Vec::with_capacity(runs.len()); + let mut cursor = 0; + let mut raw_end = 0; + for run in runs { + raw_end = (raw_end + run.len).min(text.len()); + let mut end = raw_end; + while !text.is_char_boundary(end) { + end += 1; + } + if end > cursor { + result.push(TextRun { + len: end - cursor, + ..run.clone() + }); + cursor = end; + } + } + + Some(result) +} + +fn split_run_for_ime_underline( + run: TextRun, + run_range: Range, + marked_range: Option>, + marked_underline: Option, +) -> SmallVec<[TextRun; 3]> { + if run.len == 0 { + return SmallVec::new(); + } + + let Some(marked) = marked_range else { + return [run].into_iter().collect(); + }; + + let intersection_start = run_range.start.max(marked.start); + let intersection_end = run_range.end.min(marked.end); + if intersection_start >= intersection_end { + return [run].into_iter().collect(); + } + + [ + TextRun { + len: intersection_start - run_range.start, + ..run.clone() + }, + TextRun { + len: intersection_end - intersection_start, + underline: marked_underline, + ..run.clone() + }, + TextRun { + len: run_range.end - intersection_end, + ..run + }, + ] + .into_iter() + .filter(|run| run.len > 0) + .collect() +} + +fn split_runs_by_bg_segments( + start_offset: usize, + runs: &[TextRun], + bg_segments: &[(Range, Hsla)], +) -> Vec { + let mut result = vec![]; + + let mut cursor = start_offset; + for run in runs { + let mut run_start = cursor; + let run_end = cursor + run.len; + + for (bg_range, bg_color) in bg_segments { + if run_end <= bg_range.start || run_start >= bg_range.end { + continue; + } + + // Overlap exists + if run_start < bg_range.start { + // Add the part before the background range + result.push(TextRun { + len: bg_range.start - run_start, + ..run.clone() + }); + } + + // Add the overlapping part with background color + let overlap_start = run_start.max(bg_range.start); + let overlap_end = run_end.min(bg_range.end); + let text_color = if bg_color.l >= 0.5 { + gpui::black() + } else { + gpui::white() + }; + + let run_len = overlap_end.saturating_sub(overlap_start); + if run_len > 0 { + result.push(TextRun { + len: run_len, + color: text_color, + ..run.clone() + }); + + cursor = bg_range.end; + run_start = cursor; + } + } + + if run_end > cursor { + // Add the part after the background range + result.push(TextRun { + len: run_end - cursor, + ..run.clone() + }); + } + + cursor = run_end; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_text_decorations_include_unstyled_gaps() { + let decoration = HighlightStyle { + background_color: Some(gpui::red()), + ..Default::default() + }; + let styles = compose_decorations(Vec::new(), [(2..5, decoration)], 0..10).unwrap(); + + assert_eq!( + styles + .iter() + .map(|(range, _)| range.clone()) + .collect::>(), + vec![0..2, 2..5, 5..10] + ); + assert_eq!(styles[0].1, HighlightStyle::default()); + assert_eq!(styles[1].1.background_color, Some(gpui::red())); + assert_eq!(styles[2].1, HighlightStyle::default()); + } + + #[test] + fn test_first_decoration_collection_has_precedence() { + let first = [TextDecoration::new( + 0..4, + HighlightStyle { + background_color: Some(gpui::red()), + ..Default::default() + }, + )]; + let second = [TextDecoration::new( + 0..4, + HighlightStyle { + background_color: Some(gpui::blue()), + ..Default::default() + }, + )]; + + let styles = + compose_decoration_collections(Vec::new(), [&first[..], &second[..]], 0..4).unwrap(); + + assert_eq!(styles.len(), 1); + assert_eq!(styles[0].1.background_color, Some(gpui::red())); + } + + #[test] + fn test_decorations_override_syntax_highlights() { + let syntax = vec![( + 0..8, + HighlightStyle { + color: Some(gpui::blue()), + ..Default::default() + }, + )]; + let decoration = HighlightStyle { + color: Some(gpui::red()), + font_style: Some(gpui::FontStyle::Italic), + ..Default::default() + }; + + let styles = compose_decorations(syntax, [(2..6, decoration)], 0..8).unwrap(); + + assert_eq!(styles[1].0, 2..6); + assert_eq!(styles[1].1.color, Some(gpui::red())); + assert_eq!(styles[1].1.font_style, Some(gpui::FontStyle::Italic)); + } + + #[test] + fn test_editor_scrollbar_layout_uses_current_scroll_size() { + let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); + let paddings = Edges { + top: px(2.), + right: px(3.), + bottom: px(5.), + left: px(7.), + }; + + let layout = + EditorScrollbarLayout::new(input_bounds, px(40.), size(px(1000.), px(200.)), paddings); + + assert_eq!( + layout.bounds, + Bounds::new(point(px(47.), px(18.)), size(px(266.), px(87.))) + ); + assert_eq!(layout.scroll_size, size(px(976.), px(200.))); + + let layout_without_gutter = + EditorScrollbarLayout::new(input_bounds, px(0.), size(px(500.), px(120.)), paddings); + + assert_eq!( + layout_without_gutter.bounds, + Bounds::new(point(px(10.), px(18.)), size(px(303.), px(87.))) + ); + assert_eq!(layout_without_gutter.scroll_size, size(px(513.), px(120.))); + } + + #[test] + fn test_editor_gutter_covers_the_complete_fixed_column() { + let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); + + assert_eq!( + editor_gutter_bounds( + input_bounds, + px(48.), + px(16.), + Edges { + top: px(2.), + right: px(3.), + bottom: px(5.), + left: px(7.), + }, + ), + Bounds::new(point(px(3.), px(18.)), size(px(55.), px(103.))) + ); + } + + #[test] + fn test_auto_grow_scroll_offset_is_clamped_to_current_viewport() { + let mode = LayoutMode::auto_grow(3, 8); + + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&mode, px(-260.), px(340.), px(160.)), + px(-180.) + ); + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&mode, px(-40.), px(340.), px(160.)), + px(-40.) + ); + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&mode, px(20.), px(340.), px(160.)), + px(0.) + ); + + let plain_text = LayoutMode::plain_text(); + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&plain_text, px(-260.), px(340.), px(160.)), + px(-260.) + ); + } + + #[test] + fn test_runs_for_range() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + + // use hello this-is-test + let runs = vec![ + // use + TextRun { + len: 3, + ..run.clone() + }, + // \s + TextRun { + len: 1, + ..run.clone() + }, + // hello + TextRun { + len: 5, + ..run.clone() + }, + // \s + TextRun { + len: 1, + ..run.clone() + }, + // this-is-test + TextRun { + len: 12, + ..run.clone() + }, + ]; + + #[track_caller] + fn assert_runs(actual: Vec, expected: &[usize]) { + let left = actual.iter().map(|run| run.len).collect::>(); + assert_eq!(left, expected); + } + + assert_runs(runs_for_range(&runs, 0, &(0..0)), &[]); + assert_runs(runs_for_range(&runs, 0, &(0..100)), &[3, 1, 5, 1, 12]); + + assert_runs(runs_for_range(&runs, 0, &(0..6)), &[3, 1, 2]); + assert_runs(runs_for_range(&runs, 0, &(1..6)), &[2, 1, 2]); + assert_runs(runs_for_range(&runs, 0, &(3..10)), &[1, 5, 1]); + assert_runs(runs_for_range(&runs, 0, &(5..8)), &[3]); + assert_runs(runs_for_range(&runs, 3, &(0..3)), &[1, 2]); + assert_runs(runs_for_range(&runs, 3, &(2..10)), &[4, 1, 3]); + assert_runs(runs_for_range(&runs, 9, &(0..8)), &[1, 7]); + } + + #[test] + fn test_split_runs_preserve_ime_underline_across_highlight_boundaries() { + let underline = UnderlineStyle { + thickness: px(1.), + color: Some(gpui::black()), + wavy: false, + }; + + let runs = [0..4, 4..10] + .into_iter() + .flat_map(|range| { + split_run_for_ime_underline( + TextStyle::default().to_run(range.len()), + range, + Some(2..7), + Some(underline), + ) + }) + .collect::>(); + + assert_eq!( + runs.iter() + .map(|run| (run.len, run.underline.is_some())) + .collect::>(), + vec![(2, false), (2, true), (3, true), (3, false)] + ); + } + + #[test] + fn test_split_run_applies_ime_underline_without_highlighting() { + let underline = UnderlineStyle { + thickness: px(1.), + color: Some(gpui::black()), + wavy: false, + }; + + let runs = split_run_for_ime_underline( + TextStyle::default().to_run(10), + 0..10, + Some(2..7), + Some(underline), + ); + + assert_eq!( + runs.iter() + .map(|run| (run.len, run.underline.is_some())) + .collect::>(), + vec![(2, false), (5, true), (3, false)] + ); + assert!( + split_run_for_ime_underline(TextStyle::default().to_run(0), 0..0, None, None) + .is_empty() + ); + } + + #[test] + fn test_masked_ime_underline_splits_on_mask_char_boundaries() { + let underline = UnderlineStyle { + thickness: px(1.), + color: Some(gpui::black()), + wavy: false, + }; + let text = Rope::from("abcdef"); + let mask_len = MASK_CHAR.len_utf8(); + + assert_eq!( + ime_marked_display_range(&text, Some(4..6), false), + Some(4..6) + ); + assert_eq!(ime_marked_display_range(&text, None, true), None); + assert_eq!( + ime_marked_display_range(&text, Some(4..6), true), + Some(4 * mask_len..6 * mask_len) + ); + + let display_text = MASK_CHAR.to_string().repeat(text.chars().count()); + let runs = split_run_for_ime_underline( + TextStyle::default().to_run(display_text.len()), + 0..display_text.len(), + ime_marked_display_range(&text, Some(4..6), true), + Some(underline), + ); + + let mut offset = 0; + for run in &runs { + assert!(display_text.is_char_boundary(offset)); + offset += run.len; + } + assert_eq!(offset, display_text.len()); + } + + #[test] + fn test_placeholder_line_runs() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + + let runs = vec![ + TextRun { + len: 2, + ..run.clone() + }, + TextRun { + len: 2, + ..run.clone() + }, + TextRun { len: 1, ..run }, + ]; + + let placeholder_runs = placeholder_line_runs("ab\n\nc", &runs); + + let lines = placeholder_runs + .iter() + .map(|(line, _)| *line) + .collect::>(); + assert_eq!(lines, vec!["ab", "", "c"]); + + let run_lengths = placeholder_runs + .iter() + .map(|(_, line_runs)| line_runs.iter().map(|run| run.len).collect::>()) + .collect::>(); + assert_eq!(run_lengths, vec![vec![2], vec![], vec![1]]); + } + + #[test] + fn test_align_runs_to_char_boundaries() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::blue(), + background_color: None, + underline: None, + strikethrough: None, + }; + let runs = |lens: &[usize]| { + lens.iter() + .map(|&len| TextRun { len, ..run.clone() }) + .collect::>() + }; + let lens = |runs: &[TextRun]| runs.iter().map(|run| run.len).collect::>(); + + // "你好,世界" is 15 bytes; boundaries at 0, 3, 6, 9, 12, 15. + let text = "你好,世界"; + + // Aligned runs pass through untouched. + assert_eq!(align_runs_to_char_boundaries(text, &runs(&[3, 12])), None); + + // A mid-char boundary (4) snaps up to the next boundary (6). + let aligned = align_runs_to_char_boundaries(text, &runs(&[4, 11])).unwrap(); + assert_eq!(lens(&aligned), vec![6, 9]); + + // Several boundaries inside the same char collapse into one run. + let aligned = align_runs_to_char_boundaries(text, &runs(&[1, 1, 13])).unwrap(); + assert_eq!(lens(&aligned), vec![3, 12]); + + // Overshooting runs are capped at the text length. + let aligned = align_runs_to_char_boundaries(text, &runs(&[3, 20])).unwrap(); + assert_eq!(lens(&aligned), vec![3, 12]); + + // Undershooting runs keep the shortfall (the tail is left unshaped, + // matching `runs_for_range` clipping). + assert_eq!(align_runs_to_char_boundaries(text, &runs(&[3, 3])), None); + } + + #[test] + fn test_clip_styles_to_ranges() { + let style = HighlightStyle::default(); + let styles = vec![(0..4, style), (4..10, style), (12..20, style)]; + + // Ranges with a gap (a folded region) drop the gap coverage and split + // styles that span a range edge. + let clipped = clip_styles_to_ranges(styles.clone(), &[2..6, 14..18]); + assert_eq!( + clipped + .iter() + .map(|(range, _)| range.clone()) + .collect::>(), + vec![2..4, 4..6, 14..18] + ); + + // Styles fully inside the ranges are unchanged. + let clipped = clip_styles_to_ranges(styles.clone(), &[0..20]); + assert_eq!( + clipped + .iter() + .map(|(range, _)| range.clone()) + .collect::>(), + vec![0..4, 4..10, 12..20] + ); + + // A single style spanning several ranges splits into one piece per range. + let clipped = clip_styles_to_ranges(vec![(0..30, style)], &[5..10, 20..25]); + assert_eq!( + clipped + .iter() + .map(|(range, _)| range.clone()) + .collect::>(), + vec![5..10, 20..25] + ); + + assert!(clip_styles_to_ranges(styles, &[]).is_empty()); + } + + #[test] + fn test_split_runs_by_bg_segments() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::blue(), + background_color: None, + underline: None, + strikethrough: None, + }; + + let runs = vec![ + TextRun { + len: 5, + ..run.clone() + }, + TextRun { + len: 7, + ..run.clone() + }, + TextRun { + len: 24, + ..run.clone() + }, + ]; + + let bg_segments = vec![(8..12, gpui::red()), (12..18, gpui::blue())]; + let result = split_runs_by_bg_segments(5, &runs, &bg_segments); + assert_eq!( + result.iter().map(|run| run.len).collect::>(), + vec![3, 2, 2, 5, 1, 23] + ); + assert_eq!(result[0].color, gpui::blue()); + assert_eq!(result[1].color, gpui::black()); + assert_eq!(result[2].color, gpui::black()); + assert_eq!(result[3].color, gpui::black()); + assert_eq!(result[4].color, gpui::black()); + assert_eq!(result[5].color, gpui::blue()); + } + + #[test] + fn test_empty_bottom_height_outside_code_editor() { + // Single-line / plain-text / auto-grow modes never reserve empty + // bottom space, regardless of any override. + for override_rows in [None, Some(0), Some(3), Some(99)] { + assert_eq!( + empty_bottom_height(false, override_rows, px(800.), px(20.)), + px(0.), + ); + } + } + + #[test] + fn test_empty_bottom_height_code_editor_default() { + // `None`: roughly half the viewport, floored at + // `BOTTOM_MARGIN_ROWS * line_height` so the empty area never + // collapses to "less than a few lines" on tiny viewports. + let line_height = px(20.); + + // Viewport much taller than the floor → half-viewport wins. + assert_eq!( + empty_bottom_height(true, None, px(800.), line_height), + px(400.), + ); + + // Viewport shorter than 2 × floor → floor wins. + let floor = BOTTOM_MARGIN_ROWS * line_height; + assert_eq!(empty_bottom_height(true, None, px(40.), line_height), floor); + } + + #[test] + fn test_empty_bottom_height_explicit_row_count() { + // `Some(n)`: exactly `n` line-heights. Caller fully controls + // the trailing empty space; viewport size doesn't amplify it. + let line_height = px(20.); + + for rows in [0_usize, 1, 3, 8, 64] { + let expected = rows as f32 * line_height; + assert_eq!( + empty_bottom_height(true, Some(rows), px(800.), line_height), + expected, + ); + // Tiny viewport: still exactly `n × line_height`, no floor + // applied when caller supplied an explicit count. + assert_eq!( + empty_bottom_height(true, Some(rows), px(20.), line_height), + expected, + ); + } + } + + #[test] + fn test_cursor_surrounding_padding_auto_grow() { + // Auto-grow inputs always pad by one line, regardless of any + // override or visible-lines count. + let line_height = px(20.); + for override_lines in [None, Some(0), Some(3), Some(99)] { + for visible_lines in [0_usize, 1, 8, 64] { + assert_eq!( + cursor_surrounding_padding(true, override_lines, visible_lines, line_height,), + line_height, + ); + } + } + } + + #[test] + fn test_cursor_surrounding_padding_default() { + // `None`: historical heuristic — `BOTTOM_MARGIN_ROWS` for normal + // viewports, falls back to one line on small viewports (less + // than `BOTTOM_MARGIN_ROWS × 8` rows tall). + let line_height = px(20.); + + // Small viewport → 1-line fallback. + let small = BOTTOM_MARGIN_ROWS * 8 - 1; + assert_eq!( + cursor_surrounding_padding(false, None, small, line_height), + line_height, + ); + + // Boundary at `BOTTOM_MARGIN_ROWS × 8` flips to the full margin. + let boundary = BOTTOM_MARGIN_ROWS * 8; + assert_eq!( + cursor_surrounding_padding(false, None, boundary, line_height), + BOTTOM_MARGIN_ROWS * line_height, + ); + + // Comfortably-large viewport. + assert_eq!( + cursor_surrounding_padding(false, None, 100, line_height), + BOTTOM_MARGIN_ROWS * line_height, + ); + } + + #[test] + fn test_cursor_surrounding_padding_explicit() { + // `Some(n)`: exactly `n × line_height` when the viewport has + // room for it; saturated against half the viewport when it + // doesn't. + let line_height = px(20.); + + for lines in [0_usize, 1, 2, 5, 50] { + let raw = lines as f32 * line_height; + for visible_lines in [0_usize, 1, 8, 100] { + let viewport_half = (visible_lines as f32 * line_height).half(); + assert_eq!( + cursor_surrounding_padding(false, Some(lines), visible_lines, line_height,), + raw.min(viewport_half), + ); + } + } + } + + #[test] + fn test_cursor_surrounding_padding_saturates_against_viewport() { + // An aggressive override on a small viewport must not produce a + // padding larger than half the visible region — otherwise the + // bottom-edge auto-scroll-into-view threshold sinks below the + // top-edge threshold and the per-frame scroll adjustment loses + // a stable fixed point. + let line_height = px(20.); + + // Override much larger than viewport → clamped to half. + let visible_lines = 10; + let viewport_half = (visible_lines as f32 * line_height).half(); + assert_eq!( + cursor_surrounding_padding(false, Some(50), visible_lines, line_height), + viewport_half, + ); + + // Override that fits → returned unchanged. + let visible_lines = 40; + assert_eq!( + cursor_surrounding_padding(false, Some(3), visible_lines, line_height), + 3.0 * line_height, + ); + + // Default heuristic still saturates if BOTTOM_MARGIN_ROWS would + // exceed the half-viewport bound (only possible at extreme + // sizes — kept for defensive completeness). + let visible_lines = BOTTOM_MARGIN_ROWS * 8; + let half = (visible_lines as f32 * line_height).half(); + let raw = BOTTOM_MARGIN_ROWS * line_height; + assert_eq!( + cursor_surrounding_padding(false, None, visible_lines, line_height), + raw.min(half), + ); + } +} diff --git a/vendor/gpui-base/src/input/base/kind.rs b/vendor/gpui-base/src/input/base/kind.rs new file mode 100644 index 00000000..26c28d53 --- /dev/null +++ b/vendor/gpui-base/src/input/base/kind.rs @@ -0,0 +1,348 @@ +//! Compile-time input modes. +//! +//! The three input states are one engine seen through a mode marker, one per +//! state: +//! +//! ```ignore +//! pub type InputState = InputBaseState; +//! pub type TextareaState = InputBaseState; +//! pub type EditorState = InputBaseState; +//! ``` +//! +//! A method that only makes sense for one mode lives in that mode's `impl` +//! block, so it does not exist on the others: `InputState` has no `auto_grow` +//! or `soft_wrap`, `TextareaState` has no `masked` or `line_number`, and only +//! `EditorState` performs code actions. Methods shared by the two multi-line +//! modes go on [`MultiLineMode`]. Reaching for the wrong one is a compile +//! error rather than a debug assertion. +//! +//! [`super::LayoutMode`] carries the same distinction at runtime, since the +//! engine branches on it while editing. The marker only decides which API is +//! reachable. + +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{Div, Entity, Stateful, Window}; +use ropey::Rope; + +use super::decorations::DecorationCollections; +use super::lsp::{ContextMenuContent, HoverDefinition, InlineCompletion}; +use crate::input::{HighlightStyleResolver, InputEdit, InputHighlighter, TextDecoration}; +use crate::input::{HoverPopoverState, Lsp}; +use gpui::Task; + +use super::InputBaseState; + +/// A single-line text field: the mode of [`crate::input::InputState`]. +pub struct InputMode; + +/// Ordinary multi-line text: the mode of [`crate::input::TextareaState`]. +pub struct TextareaMode; + +/// Source code, with language features: the mode of [`crate::input::EditorState`]. +pub struct EditorMode; + +mod sealed { + pub trait Sealed {} +} + +impl sealed::Sealed for InputMode {} +impl sealed::Sealed for TextareaMode {} +impl sealed::Sealed for EditorMode {} + +/// The modes whose layout spans more than one line: [`TextareaMode`] and +/// [`EditorMode`]. +/// +/// Soft wrap, wrapping indent and the search session are meaningless in a +/// single-line field, but shared by the two multi-line modes. Bounding an +/// `impl` block on this trait puts those methods on both without writing them +/// twice, and keeps them off [`InputMode`]. +pub trait MultiLineMode: InputModeKind {} + +impl MultiLineMode for TextareaMode {} +impl MultiLineMode for EditorMode {} + +/// What the renderer may read out of a mode's extra state. +/// +/// Kept apart from [`InputModeKind`] on purpose. This trait is *data*: the +/// renderer is generic over the mode, so it cannot name `EditorExtras` and +/// reach its fields directly, and these are the accessors it goes through +/// instead. Every one of them has an empty answer, which is what a plain input +/// and a textarea give. +/// +/// [`InputModeKind`] is *behavior*: points where the engine hands control back +/// during an edit. Adding a field an editor renders belongs here and leaves +/// the engine's callbacks alone. +pub trait InputExtras: Default + 'static { + /// Decoration ranges to paint, innermost collection first. + fn decoration_layers(&self) -> Vec<&[TextDecoration]> { + Vec::new() + } + + /// Semantic-token styles for a visible range, when an LSP supplies them. + fn semantic_token_styles( + &self, + _text: &Rope, + _range: &std::ops::Range, + _resolver: &dyn HighlightStyleResolver, + ) -> Vec<(std::ops::Range, gpui::HighlightStyle)> { + Vec::new() + } + + /// Document colours to paint as swatches, when an LSP supplies them. + fn document_color_swatches( + &self, + _text: &Rope, + _range: &std::ops::Range, + ) -> Vec<(std::ops::Range, gpui::Hsla)> { + Vec::new() + } + + /// The symbol range the hover popover is anchored to. + fn hover_symbol_range(&self) -> Option> { + None + } + + /// The inline completion to paint as ghost text. + fn inline_completion_item(&self) -> Option<&lsp_types::InlineCompletionItem> { + None + } + + /// What this mode can offer its context menu: go-to-definition, code actions. + fn context_menu_capabilities(&self) -> (bool, bool) { + (false, false) + } +} + +/// A mode with nothing extra to render. +impl InputExtras for () {} + +/// Hooks the shared engine calls back into for mode-specific work. +/// +/// The engine's render path is generic over the mode, so it cannot name a +/// specific state type. This hook is the seam: each implementation is written +/// for one concrete mode, so inside it `Entity>` is that +/// mode's own state type. +/// Sealed: the engine branches on a closed set of runtime modes, so the +/// markers are a closed set too. The three above are all of them. +pub trait InputModeKind: sealed::Sealed + Sized + 'static { + /// Whether this kind of input spans more than one line. + /// + /// The kind decides this, not the layout: [`super::LayoutMode`] carries + /// how many rows to show and how to grow, which is a different question + /// from whether the input is a text field or a document. Deriving it from + /// the layout let the two disagree — an auto-growing textarea capped at + /// one row used to report itself as single-line. + const MULTI_LINE: bool; + + /// Whether this kind of input is a source-code editor. + const CODE_EDITOR: bool = false; + + /// State only this mode needs. + /// + /// The engine is shared, but its parts are not: a single-line field has no + /// use for an LSP client or a search session, and an editor has no use for + /// number stepping. Keeping those here means a form full of text fields + /// does not carry an editor's worth of machinery. + type Extras: InputExtras; + + /// Drives the syntax highlighter after the text changed. + /// + /// Only a code editor has one. The engine's edit path is generic over the + /// mode, so it dispatches here, where `Self` is concrete and the highlighter + /// can be handed this mode's own context. + fn drive_highlighter( + _highlighter: &Rc>>>, + _edit: InputEdit, + _text: &Rope, + _folding: bool, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) { + } + + /// The range highlighted while Cmd-hovering a symbol, with its style. + fn hover_definition_style( + _state: &InputBaseState, + _cx: &gpui::App, + ) -> Option<(std::ops::Range, gpui::HighlightStyle)> { + None + } + + /// The hitbox for Cmd-hover, when the mode supports go-to-definition. + fn hover_definition_hitbox( + _state: &InputBaseState, + _window: &mut Window, + _cx: &gpui::App, + ) -> Option { + None + } + + /// Drops cached language-server results, e.g. after the text is replaced. + fn reset_language_features(_state: &mut InputBaseState) {} + + /// Drops decorations and hover state when the text is replaced wholesale. + fn reset_annotations(_state: &mut InputBaseState) {} + + /// Slides decoration ranges along with an edit. + fn adjust_annotations( + _state: &mut InputBaseState, + _range: &std::ops::Range, + _new_len: usize, + ) { + } + + /// Refreshes language-server state after the text changed. + fn refresh_language_features( + _state: &mut InputBaseState, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) { + } + + /// Takes the pending inline completion, when Tab should accept it. + fn accept_inline_completion( + _state: &mut InputBaseState, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) -> bool { + false + } + + /// Whether an inline completion is waiting to be accepted. + fn has_inline_completion(_state: &InputBaseState) -> bool { + false + } + + /// Reacts to a click, for Cmd-click go-to-definition. + fn on_click( + _state: &mut InputBaseState, + _event: &gpui::MouseDownEvent, + _offset: usize, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) -> bool { + false + } + + /// Drops hover state when the pointer leaves or focus moves. + fn clear_hover_state( + _state: &mut InputBaseState, + _cx: &mut gpui::Context>, + ) { + } + + /// Offers freshly typed text to the completion engine. + fn on_text_typed( + _state: &mut InputBaseState, + _range: &std::ops::Range, + _text: &str, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) { + } + + /// Drops any inline completion after the text or cursor moved. + fn clear_inline_completion( + _state: &mut InputBaseState, + _cx: &mut gpui::Context>, + ) { + } + + /// Closes any open completion or code-action menu. + fn hide_context_menu( + _state: &mut InputBaseState, + _cx: &mut gpui::Context>, + ) { + } + + /// Whether a completion or code-action menu is currently open. + fn is_context_menu_open(_state: &InputBaseState, _cx: &gpui::App) -> bool { + false + } + + /// Lets an open menu consume the action first. Returns true when it did. + fn handle_context_menu_action( + _state: &mut InputBaseState, + _action: Box, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) -> bool { + false + } + + /// Highlights the symbol under the pointer for go-to-definition. + /// + /// Separate from [`Self::on_mouse_move`]: this runs on paths that have no + /// mouse event to hand over, such as opening the context menu. + fn on_hover_definition( + _state: &mut InputBaseState, + _offset: usize, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) { + } + + /// Reacts to the pointer moving, for the hover popover. + fn on_mouse_move( + _state: &mut InputBaseState, + _offset: usize, + _event: &gpui::MouseMoveEvent, + _window: &mut Window, + _cx: &mut gpui::Context>, + ) { + } + + /// Registers the actions that only this mode handles. + fn register_actions( + element: Stateful
, + _entity: &Entity>, + _window: &mut Window, + ) -> Stateful
{ + element + } +} + +impl InputModeKind for InputMode { + const MULTI_LINE: bool = false; + + /// A single-line field needs nothing beyond the shared engine. Masking, + /// validation and number stepping live there: together they are ~120 bytes + /// and their access sites sit inside the shared edit path, so separating + /// them would cost more in dispatch than it saves. + type Extras = (); +} +impl InputModeKind for TextareaMode { + const MULTI_LINE: bool = true; + + /// Ordinary multi-line text needs nothing beyond the shared engine. + type Extras = (); +} +// `EditorMode`'s implementation lives with the editor code, next to the +// language features it dispatches to. + +/// What a code editor adds on top of multi-line text: language features. +pub struct EditorExtras { + pub(crate) lsp: Lsp, + pub(crate) decorations: DecorationCollections, + pub(crate) inline_completion: InlineCompletion, + pub(crate) context_menu_content: ContextMenuContent, + pub(crate) hover_popover: Option, + pub(crate) hover_definition: HoverDefinition, + pub(crate) context_menu_task: Task>, +} + +impl Default for EditorExtras { + fn default() -> Self { + Self { + lsp: Lsp::default(), + decorations: DecorationCollections::default(), + inline_completion: InlineCompletion::default(), + context_menu_content: ContextMenuContent::default(), + hover_popover: None, + hover_definition: HoverDefinition::default(), + context_menu_task: Task::ready(Ok(())), + } + } +} diff --git a/vendor/gpui-base/src/input/base/layout.rs b/vendor/gpui-base/src/input/base/layout.rs new file mode 100644 index 00000000..df9b7264 --- /dev/null +++ b/vendor/gpui-base/src/input/base/layout.rs @@ -0,0 +1,43 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{Bounds, Half, Pixels, ShapedLine, TextAlign, px}; + +use super::{WrappingIndent, display_map::LineLayout}; + +#[derive(Clone, Default)] +pub(crate) struct WhitespaceIndicators { + pub(crate) space: ShapedLine, + pub(crate) tab: ShapedLine, +} + +#[derive(Clone)] +pub(super) struct LastLayout { + pub(super) visible_range: Range, + pub(super) visible_buffer_lines: Vec, + pub(super) visible_line_byte_offsets: Vec, + pub(super) visible_top: Pixels, + pub(super) visible_range_offset: Range, + pub(super) lines: Rc>, + pub(super) line_height: Pixels, + pub(super) wrap_width: Option, + pub(super) wrapping_indent: WrappingIndent, + pub(super) line_number_width: Pixels, + pub(super) cursor_bounds: Option>, + pub(super) text_align: TextAlign, + pub(super) content_width: Pixels, +} + +impl LastLayout { + pub(crate) fn line(&self, row: usize) -> Option<&LineLayout> { + let pos = self.visible_buffer_lines.binary_search(&row).ok()?; + self.lines.get(pos) + } + + pub(super) fn alignment_offset(&self, line_width: Pixels) -> Pixels { + match self.text_align { + TextAlign::Left => px(0.), + TextAlign::Center => (self.content_width - line_width).half().max(px(0.)), + TextAlign::Right => (self.content_width - line_width).max(px(0.)), + } + } +} diff --git a/vendor/gpui-component/src/input/mask_pattern.rs b/vendor/gpui-base/src/input/base/mask_pattern.rs similarity index 86% rename from vendor/gpui-component/src/input/mask_pattern.rs rename to vendor/gpui-base/src/input/base/mask_pattern.rs index a1b40a47..b0ed150f 100644 --- a/vendor/gpui-component/src/input/mask_pattern.rs +++ b/vendor/gpui-base/src/input/base/mask_pattern.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use gpui::SharedString; #[derive(Clone, PartialEq, Debug)] @@ -198,7 +200,8 @@ impl MaskPattern { let int_part = parts.next().unwrap_or(""); let frac_part = parts.next(); - if int_part.is_empty() { + // only one dot is valid + if parts.next().is_some() { return false; } @@ -413,9 +416,47 @@ fn is_sign(ch: &char) -> bool { matches!(ch, '+' | '-') } +/// Normalize full-width and CJK number characters into their ASCII equivalents. +/// +/// E.g. `123。5` -> `123.5` +/// +/// Every mapping is 1 char to 1 char with the same UTF-16 length, so the IME +/// marked-range offsets (in UTF-16) stay valid. The UTF-8 byte length may +/// shrink (3 bytes to 1), the caller must use the normalized string for all +/// byte-offset calculations. +#[doc(hidden)] +pub(super) fn normalize_number_input(text: &str) -> Cow<'_, str> { + #[inline] + fn normalize_char(ch: char) -> Option { + match ch { + // Full-width digits 0-9 + '\u{FF10}'..='\u{FF19}' => char::from_u32(ch as u32 - 0xFF10 + '0' as u32), + // Full-width plus + + '\u{FF0B}' => Some('+'), + // Full-width hyphen - and minus sign − + '\u{FF0D}' | '\u{2212}' => Some('-'), + // Full-width dot . and ideographic full stop 。 + '\u{FF0E}' | '\u{3002}' => Some('.'), + // Full-width comma , + '\u{FF0C}' => Some(','), + _ => None, + } + } + + if !text.chars().any(|ch| normalize_char(ch).is_some()) { + return Cow::Borrowed(text); + } + + Cow::Owned( + text.chars() + .map(|ch| normalize_char(ch).unwrap_or(ch)) + .collect(), + ) +} + #[cfg(test)] mod tests { - use crate::input::mask_pattern::{MaskPattern, MaskToken}; + use super::{MaskPattern, MaskToken}; #[test] fn test_is_match() { @@ -634,4 +675,51 @@ mod tests { assert_eq!(mask.mask("-1234567."), "-1,234,567."); assert_eq!(mask.mask("-1234567.89"), "-1,234,567.89"); } + + #[test] + fn test_number_leading_dot() { + let mask = MaskPattern::number(None); + assert_eq!(mask.is_valid("."), true); + assert_eq!(mask.is_valid(".5"), true); + assert_eq!(mask.is_valid("-."), true); + assert_eq!(mask.is_valid("-.5"), true); + assert_eq!(mask.is_valid("1.2.3"), false); + assert_eq!(mask.is_valid("1.."), false); + + // A bare leading dot is kept as-is (not completed to "0."), so that + // deleting the integer part of "1.2" keeps ".2" and stays editable. + assert_eq!(mask.mask("."), "."); + assert_eq!(mask.mask(".5"), ".5"); + assert_eq!(mask.mask("-.5"), "-.5"); + assert_eq!(mask.mask("+.5"), "+.5"); + + let mask = MaskPattern::number(Some(',')); + assert_eq!(mask.mask(".5"), ".5"); + assert_eq!(mask.mask("-.5"), "-.5"); + } + + #[test] + fn test_normalize_number_input() { + use std::borrow::Cow; + + use super::normalize_number_input; + + // Fast path: no allocation when nothing to normalize + assert!(matches!( + normalize_number_input("-1,234.5"), + Cow::Borrowed(_) + )); + + // Full-width digits + assert_eq!(normalize_number_input("0123456789"), "0123456789"); + // Full-width signs, dot and comma + assert_eq!(normalize_number_input("+1.5"), "+1.5"); + assert_eq!(normalize_number_input("-1,234"), "-1,234"); + // Minus sign (U+2212) + assert_eq!(normalize_number_input("−1.5"), "-1.5"); + // Ideographic full stop + assert_eq!(normalize_number_input("12。5"), "12.5"); + // Other characters are kept as-is + assert_eq!(normalize_number_input("ab 中 1"), "ab 中 1"); + } } diff --git a/vendor/gpui-base/src/input/base/mod.rs b/vendor/gpui-base/src/input/base/mod.rs new file mode 100644 index 00000000..5ded73a6 --- /dev/null +++ b/vendor/gpui-base/src/input/base/mod.rs @@ -0,0 +1,257 @@ +use crate::{StateStyle, StyledExt as _}; +use gpui::{ + AnyElement, App, Div, ElementId, InteractiveElement, Interactivity, IntoElement, ParentElement, + Refineable as _, RenderOnce, Role, SharedString, StatefulInteractiveElement, StyleRefinement, + Styled, Window, div, prelude::FluentBuilder as _, +}; + +/// What the input can offer to its context menu, at the moment it is opened. +/// +/// Built by the input and read by the menu, the fields are private and reached +/// through the methods below, so that a new capability can be added without +/// breaking the menu builders. +/// +/// ``` +/// use gpui_base::input::InputContextMenuCapabilities; +/// +/// let capabilities = InputContextMenuCapabilities::new() +/// .code_editor(true) +/// .selection(true); +/// +/// assert!(capabilities.is_editable()); +/// assert!(capabilities.has_selection()); +/// ``` +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct InputContextMenuCapabilities { + disabled: bool, + readonly: bool, + code_editor: bool, + selection: bool, + go_to_definition: bool, + code_actions: bool, +} + +impl InputContextMenuCapabilities { + pub fn new() -> Self { + Self::default() + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn readonly(mut self, readonly: bool) -> Self { + self.readonly = readonly; + self + } + + pub fn code_editor(mut self, code_editor: bool) -> Self { + self.code_editor = code_editor; + self + } + + /// Set whether the input has a non-empty selection. + pub fn selection(mut self, selection: bool) -> Self { + self.selection = selection; + self + } + + pub fn go_to_definition(mut self, go_to_definition: bool) -> Self { + self.go_to_definition = go_to_definition; + self + } + + pub fn code_actions(mut self, code_actions: bool) -> Self { + self.code_actions = code_actions; + self + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn is_readonly(&self) -> bool { + self.readonly + } + + /// Returns true if the user is allowed to change the text. + /// + /// The items that write to the input (Cut, Paste, Code Actions) belong to + /// this, the reading ones (Copy, Go to Definition) do not. + pub fn is_editable(&self) -> bool { + !self.disabled && !self.readonly + } + + pub fn is_code_editor(&self) -> bool { + self.code_editor + } + + pub fn has_selection(&self) -> bool { + self.selection + } + + pub fn can_go_to_definition(&self) -> bool { + self.go_to_definition + } + + pub fn has_code_actions(&self) -> bool { + self.code_actions + } +} + +/// The foundational input frame. +/// +/// It intentionally owns only input semantics, interaction forwarding, and +/// normal children. Applications remain responsible for all presentation. +#[derive(IntoElement)] +pub struct InputBase { + base: gpui::Stateful
, + style: StyleRefinement, + semantic_styles: InputStyles, + children: Vec, + focused: bool, + disabled: bool, + role: crate::RoleOverride, +} + +impl InputBase { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + semantic_styles: InputStyles::default(), + children: Vec::new(), + focused: false, + disabled: false, + role: crate::RoleOverride::Implicit, + } + } + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn styles(mut self, build: impl FnOnce(InputStyles) -> InputStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.base = self.base.aria_label(label.into()); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.focused.then_some(&self.semantic_styles.focused), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +#[derive(Default)] +pub struct InputStyles { + focused: StyleRefinement, + disabled: StyleRefinement, +} + +impl InputStyles { + pub fn focused(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.focused + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl Styled for InputBase { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for InputBase { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for InputBase { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for InputBase {} + +impl RenderOnce for InputBase { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let style = self.resolved_style(); + self.base + .when_some(self.role.resolve(|| Role::TextInput), |this, role| { + this.role(role) + }) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_accepts_application_owned_content_and_style() { + let _ = InputBase::new("input") + .focused(true) + .disabled(false) + .styles(|styles| { + styles + .focused(|style| style.border_1()) + .disabled(|style| style.opacity(0.5)) + }) + .child("value") + .opacity(0.8); + } + + #[test] + fn semantic_state_styles_override_the_normal_style() { + let focused = InputBase::new("focused") + .focused(true) + .border_color(gpui::red()) + .styles(|styles| styles.focused(|style| style.border_color(gpui::blue()))); + assert_eq!(focused.resolved_style().border_color, Some(gpui::blue())); + + let disabled = InputBase::new("disabled") + .focused(true) + .disabled(true) + .opacity(1.) + .styles(|styles| { + styles + .focused(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }); + assert_eq!(disabled.resolved_style().opacity, Some(0.5)); + } +} diff --git a/vendor/gpui-base/src/input/base/mode.rs b/vendor/gpui-base/src/input/base/mode.rs new file mode 100644 index 00000000..9b6cac21 --- /dev/null +++ b/vendor/gpui-base/src/input/base/mode.rs @@ -0,0 +1,371 @@ +use crate::input::InputModeKind; +use std::rc::Rc; +use std::{cell::RefCell, ops::Range}; + +use gpui::{Context, SharedString, Window}; +use ropey::Rope; + +use super::DisplayMap; +use crate::input::{ + DiagnosticSet, InputEdit, InputHighlighter, InputHighlighterFactory, RopeExt as _, TabSize, +}; + +/// What changed, handed to the syntax highlighter. +pub(crate) struct HighlighterUpdate<'a> { + pub(super) selected_range: &'a Range, + pub(super) old_text: &'a Rope, + pub(super) new_text: &'a Rope, + pub(super) change_text: &'a str, + pub(super) force: bool, +} + +/// How the input lays its text out: rows, growth, and the code-editor extras. +/// +/// This does not say what *kind* of input this is — that is fixed at the type +/// level by [`crate::input::InputModeKind`], and asking it here is what used to +/// let the two disagree. +#[derive(Clone)] +pub(crate) enum LayoutMode { + /// A plain text input mode. + PlainText { tab: TabSize, rows: usize }, + /// An auto grow input mode. + AutoGrow { + rows: usize, + min_rows: usize, + max_rows: usize, + }, + /// A code editor input mode. + CodeEditor { + tab: TabSize, + rows: usize, + /// Show line number + line_number: bool, + language: SharedString, + indent_guides: bool, + folding: bool, + highlighter: Rc>>>, + highlighter_factory: Option, + diagnostics: DiagnosticSet, + }, +} + +impl Default for LayoutMode { + fn default() -> Self { + LayoutMode::plain_text() + } +} + +#[allow(unused)] +impl LayoutMode { + /// Create a plain input mode with default settings. + pub(super) fn plain_text() -> Self { + LayoutMode::PlainText { + tab: TabSize::default(), + rows: 1, + } + } + + /// Create a code editor input mode with default settings. + /// + /// Starts with no language; the state sets one through its own builder. + pub(super) fn code_editor() -> Self { + LayoutMode::CodeEditor { + rows: 2, + tab: TabSize::default(), + language: SharedString::default(), + highlighter: Rc::new(RefCell::new(None)), + highlighter_factory: None, + line_number: true, + indent_guides: true, + folding: true, + diagnostics: DiagnosticSet::new(&Rope::new()), + } + } + + /// Create an auto grow input mode with given min and max rows. + pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self { + LayoutMode::AutoGrow { + rows: min_rows, + min_rows, + max_rows, + } + } + + /// Return true if this layout is a code editor with folding enabled. + #[inline] + pub(crate) fn is_folding(&self) -> bool { + if cfg!(target_family = "wasm") { + return false; + } + + matches!(self, LayoutMode::CodeEditor { folding: true, .. }) + } + + #[inline] + pub(super) fn is_auto_grow(&self) -> bool { + matches!(self, LayoutMode::AutoGrow { .. }) + } + + pub(super) fn set_rows(&mut self, new_rows: usize) { + match self { + LayoutMode::PlainText { rows, .. } => { + *rows = new_rows; + } + LayoutMode::CodeEditor { rows, .. } => { + *rows = new_rows; + } + LayoutMode::AutoGrow { + rows, + min_rows, + max_rows, + } => { + *rows = new_rows.clamp(*min_rows, *max_rows); + } + } + } + + /// Grow the row count to fit the content. + /// + /// Callers gate this on the input being multi-line; a single-line field + /// keeps its one row. + pub(super) fn update_auto_grow(&mut self, display_map: &DisplayMap) { + let wrapped_lines = display_map.wrap_row_count(); + self.set_rows(wrapped_lines); + } + + /// At least 1 row be return. + pub(super) fn rows(&self) -> usize { + match self { + LayoutMode::PlainText { rows, .. } => *rows, + LayoutMode::CodeEditor { rows, .. } => *rows, + LayoutMode::AutoGrow { rows, .. } => *rows, + } + .max(1) + } + + /// At least 1 row be return. + #[allow(unused)] + pub(super) fn min_rows(&self) -> usize { + match self { + LayoutMode::AutoGrow { min_rows, .. } => *min_rows, + _ => 1, + } + .max(1) + } + + #[allow(unused)] + pub(super) fn max_rows(&self) -> usize { + match self { + LayoutMode::AutoGrow { max_rows, .. } => *max_rows, + _ => usize::MAX, + } + } + + /// Return false if the mode is not [`LayoutMode::CodeEditor`]. + #[inline] + pub(super) fn line_number(&self) -> bool { + match self { + LayoutMode::CodeEditor { line_number, .. } => *line_number, + _ => false, + } + } + + /// Update the syntax highlighter with new text. + /// + pub(crate) fn update_highlighter( + &mut self, + update: HighlighterUpdate<'_>, + window: &mut Window, + cx: &mut Context>, + ) { + match &self { + LayoutMode::CodeEditor { + language, + highlighter, + highlighter_factory, + folding, + .. + } => { + if !update.force && highlighter.borrow().is_some() { + return; + } + + let mut highlighter_ref = highlighter.borrow_mut(); + if highlighter_ref.is_none() { + let Some(factory) = highlighter_factory else { + return; + }; + *highlighter_ref = factory(language); + } + + if highlighter_ref.is_none() { + return; + } + drop(highlighter_ref); + + let edit = replacement_input_edit( + update.old_text, + update.new_text, + update.selected_range, + update.change_text, + ); + M::drive_highlighter(highlighter, edit, update.new_text, *folding, window, cx); + } + _ => {} + } + } + + #[allow(unused)] + pub(super) fn diagnostics(&self) -> Option<&DiagnosticSet> { + match self { + LayoutMode::CodeEditor { diagnostics, .. } => Some(diagnostics), + _ => None, + } + } + + pub(super) fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticSet> { + match self { + LayoutMode::CodeEditor { diagnostics, .. } => Some(diagnostics), + _ => None, + } + } + + /// Get a reference to the highlighter (if available) + pub(super) fn highlighter(&self) -> Option<&Rc>>>> { + match self { + LayoutMode::CodeEditor { highlighter, .. } => Some(highlighter), + _ => None, + } + } + + pub(super) fn set_highlighter_factory(&mut self, factory: InputHighlighterFactory) { + if let LayoutMode::CodeEditor { + highlighter_factory, + highlighter, + .. + } = self + { + *highlighter_factory = Some(factory); + *highlighter.borrow_mut() = None; + } + } + + pub(super) fn ensure_highlighter_factory(&mut self, factory: InputHighlighterFactory) { + if let LayoutMode::CodeEditor { + highlighter_factory, + .. + } = self + { + if highlighter_factory.is_none() { + *highlighter_factory = Some(factory); + } + } + } +} + +/// Builds the tree-sitter edit for a text replacement. +/// +/// Byte offsets and positions for `start`/`old_end` come from `old_text`; +/// `new_end` byte/position come from the post-edit `text`. +fn replacement_input_edit( + old_text: &Rope, + new_text: &Rope, + selected_range: &Range, + change_text: &str, +) -> InputEdit { + let start_byte = selected_range.start.min(old_text.len()); + let old_end_byte = selected_range.end.min(old_text.len()).max(start_byte); + let new_end_byte = (start_byte + change_text.len()).min(new_text.len()); + + InputEdit { + start_byte, + old_end_byte, + new_end_byte, + start_position: old_text.offset_to_point(start_byte), + old_end_position: old_text.offset_to_point(old_end_byte), + new_end_position: new_text.offset_to_point(new_end_byte), + } +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::replacement_input_edit; + use crate::input::{DiagnosticSet, Point, TabSize, mode::LayoutMode}; + + #[test] + fn test_replacement_input_edit_backspace_at_end_uses_old_range() { + let old_text = Rope::from_str("-="); + let text = Rope::from_str("-"); + let edit = replacement_input_edit(&old_text, &text, &(1..2), ""); + + assert_eq!(edit.start_byte, 1); + assert_eq!(edit.old_end_byte, 2); + assert_eq!(edit.new_end_byte, 1); + assert_eq!(edit.start_position, Point::new(0, 1)); + assert_eq!(edit.old_end_position, Point::new(0, 2)); + assert_eq!(edit.new_end_position, Point::new(0, 1)); + } + + #[test] + fn test_code_editor() { + let mode = LayoutMode::code_editor(); + assert_eq!(mode.line_number(), true); + assert_eq!(mode.has_indent_guides(), true); + assert_eq!(mode.max_rows(), usize::MAX); + assert_eq!(mode.min_rows(), 1); + assert_eq!(mode.is_folding(), true); + + let mode = LayoutMode::CodeEditor { + line_number: false, + indent_guides: false, + folding: false, + rows: 0, + tab: Default::default(), + language: "rust".into(), + highlighter: Default::default(), + highlighter_factory: None, + diagnostics: DiagnosticSet::new(&Rope::new()), + }; + assert_eq!(mode.line_number(), false); + assert_eq!(mode.has_indent_guides(), false); + assert_eq!(mode.min_rows(), 1); + assert_eq!(mode.is_folding(), false); + } + + #[test] + fn test_plain() { + let mode = LayoutMode::PlainText { + tab: TabSize::default(), + rows: 5, + }; + assert_eq!(mode.line_number(), false); + assert_eq!(mode.rows(), 5); + assert_eq!(mode.max_rows(), usize::MAX); + assert_eq!(mode.min_rows(), 1); + + let mode = LayoutMode::plain_text(); + assert_eq!(mode.line_number(), false); + assert_eq!(mode.rows(), 1); + assert_eq!(mode.min_rows(), 1); + } + + #[test] + fn test_auto_grow() { + let mut mode = LayoutMode::auto_grow(2, 5); + assert_eq!(mode.line_number(), false); + assert_eq!(mode.rows(), 2); + assert_eq!(mode.max_rows(), 5); + assert_eq!(mode.min_rows(), 2); + + mode.set_rows(4); + assert_eq!(mode.rows(), 4); + + mode.set_rows(1); + assert_eq!(mode.rows(), 2); + + mode.set_rows(10); + assert_eq!(mode.rows(), 5); + } +} diff --git a/vendor/gpui-component/src/input/movement.rs b/vendor/gpui-base/src/input/base/movement.rs similarity index 78% rename from vendor/gpui-component/src/input/movement.rs rename to vendor/gpui-base/src/input/base/movement.rs index a63549d1..e996a28c 100644 --- a/vendor/gpui-component/src/input/movement.rs +++ b/vendor/gpui-base/src/input/base/movement.rs @@ -1,7 +1,8 @@ +use crate::input::InputModeKind; use gpui::{Context, Point, Window}; use crate::input::{ - InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, + InputBaseState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _, }; @@ -11,7 +12,7 @@ pub(crate) enum MoveDirection { Down, } -impl InputState { +impl InputBaseState { /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. pub(super) fn update_preferred_column(&mut self) { let Some(last_layout) = &self.last_layout else { @@ -20,13 +21,12 @@ impl InputState { }; let point = self.text.offset_to_point(self.cursor()); - let row = point.row.saturating_sub(last_layout.visible_range.start); - let Some(line) = last_layout.lines.get(row) else { + let Some(line) = last_layout.line(point.row) else { self.preferred_column = None; return; }; - let Some(pos) = line.position_for_index(point.column, last_layout.line_height) else { + let Some(pos) = line.position_for_index(point.column, last_layout, false) else { self.preferred_column = None; return; }; @@ -45,13 +45,15 @@ impl InputState { direction: Option, cx: &mut Context, ) { + self.undo_manager.break_transaction_coalescing(); let offset = offset.clamp(0, self.text.len()); + self.cursor_line_end_affinity = false; self.selected_range = (offset..offset).into(); self.scroll_to(offset, direction, cx); self.pause_blink_cursor(cx); self.update_preferred_column(); - self.hide_context_menu(cx); - self.clear_inline_completion(cx); + M::hide_context_menu(self, cx); + M::clear_inline_completion(self, cx); cx.notify() } @@ -64,7 +66,7 @@ impl InputState { _: &mut Window, cx: &mut Context, ) { - if self.mode.is_single_line() { + if self.is_single_line() { return; } let Some(last_layout) = &self.last_layout else { @@ -74,16 +76,36 @@ impl InputState { let offset = self.cursor(); let was_preferred_column = self.preferred_column; - let mut display_point = self.text_wrapper.offset_to_display_point(offset); - display_point.row = display_point.row.saturating_add_signed(move_lines); + let mut display_point = self.display_map.offset_to_wrap_display_point(offset); + + // Convert wrap row → display row (skips folded rows), move, then convert back + let current_display_row = self + .display_map + .wrap_row_to_display_row(display_point.row) + .unwrap_or_else(|| { + self.display_map + .nearest_visible_display_row(display_point.row) + }); + let max_display_row = self.display_map.display_row_count().saturating_sub(1); + let target_display_row = current_display_row + .saturating_add_signed(move_lines) + .min(max_display_row); + let target_wrap_row = self + .display_map + .display_row_to_wrap_row(target_display_row) + .unwrap_or(display_point.row); + + display_point.row = target_wrap_row; display_point.column = 0; - let mut new_offset = self.text_wrapper.display_point_to_offset(display_point); + let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point); if let Some((preferred_x, column)) = was_preferred_column { // Get display point again to update local_row. - let mut next_display_point = self.text_wrapper.offset_to_display_point(new_offset); + let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset); next_display_point.column = 0; - let next_point = self.text_wrapper.display_point_to_point(next_display_point); + let next_point = self + .display_map + .wrap_display_point_to_point(next_display_point); let line_start_offset = self.text.line_start_offset(next_point.row); // If in visible range, prefer to use position to get column. @@ -93,7 +115,7 @@ impl InputState { x: preferred_x, y: next_display_point.local_row * last_layout.line_height, }, - last_layout.line_height, + last_layout, ) { new_offset = line_start_offset + x; } @@ -135,11 +157,11 @@ impl InputState { } pub(super) fn up(&mut self, action: &MoveUp, window: &mut Window, cx: &mut Context) { - if self.handle_action_for_context_menu(Box::new(action.clone()), window, cx) { + if M::handle_context_menu_action(self, Box::new(action.clone()), window, cx) { return; } - if self.mode.is_single_line() { + if self.is_single_line() { return; } @@ -155,11 +177,11 @@ impl InputState { } pub(super) fn down(&mut self, action: &MoveDown, window: &mut Window, cx: &mut Context) { - if self.handle_action_for_context_menu(Box::new(action.clone()), window, cx) { + if M::handle_context_menu_action(self, Box::new(action.clone()), window, cx) { return; } - if self.mode.is_single_line() { + if self.is_single_line() { return; } @@ -176,7 +198,7 @@ impl InputState { } pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { + if self.is_single_line() { return; } @@ -194,7 +216,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if self.mode.is_single_line() { + if self.is_single_line() { return; } @@ -216,6 +238,7 @@ impl InputState { self.pause_blink_cursor(cx); let offset = self.end_of_line(); self.move_to(offset, Some(MoveDirection::Down), cx); + self.cursor_line_end_affinity = true; } pub(super) fn move_to_start( diff --git a/vendor/gpui-base/src/input/base/native.rs b/vendor/gpui-base/src/input/base/native.rs new file mode 100644 index 00000000..f0cb5a20 --- /dev/null +++ b/vendor/gpui-base/src/input/base/native.rs @@ -0,0 +1,154 @@ +#[cfg(target_os = "macos")] +mod macos { + use std::{cell::RefCell, collections::HashMap, mem, ptr, sync::Once}; + + use gpui::Window; + use objc2::{ + ffi, msg_send, + rc::Retained, + runtime::{AnyObject, AnyProtocol, Imp, Sel}, + sel, + }; + use objc2_foundation::NSString; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + static INSTALL_TEXT_CONTENT: Once = Once::new(); + + thread_local! { + static CONTENT_TYPES: RefCell>> = + RefCell::new(HashMap::new()); + } + + pub fn set_text_content_type(window: &Window, content_type: Option<&str>) { + let Some(view) = ns_view(window) else { + return; + }; + + INSTALL_TEXT_CONTENT.call_once(|| install_text_content(view)); + if view + .class() + .instance_method(sel!(setContentType:)) + .is_none() + { + return; + } + + let ns_content_type = content_type.map(NSString::from_str); + let ns_content_type = ns_content_type.as_ref().map_or(ptr::null_mut(), |value| { + Retained::as_ptr(value).cast_mut().cast::() + }); + + unsafe { + let _: () = msg_send![view, setContentType: ns_content_type]; + } + } + + fn ns_view(window: &Window) -> Option<&AnyObject> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + + Some(unsafe { &*(handle.ns_view.as_ptr() as *const AnyObject) }) + } + + fn install_text_content(view: &AnyObject) { + let class = view.class(); + let class = class as *const _ as *mut _; + + unsafe { + let protocol = AnyProtocol::get(c"NSTextContent"); + if let Some(protocol) = protocol { + ffi::class_addProtocol(class, protocol); + } + + let content_type_imp: Imp = mem::transmute( + content_type as unsafe extern "C-unwind" fn(&AnyObject, Sel) -> *mut AnyObject, + ); + let set_content_type_imp: Imp = mem::transmute( + set_content_type as unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut AnyObject), + ); + + ffi::class_addMethod(class, sel!(contentType), content_type_imp, c"@@:".as_ptr()); + ffi::class_addMethod( + class, + sel!(setContentType:), + set_content_type_imp, + c"v@:@".as_ptr(), + ); + } + } + + unsafe extern "C-unwind" fn content_type(this: &AnyObject, _: Sel) -> *mut AnyObject { + let key = this as *const _ as usize; + CONTENT_TYPES.with(|content_types| { + content_types + .borrow() + .get(&key) + .map_or(ptr::null_mut(), |value| { + Retained::as_ptr(value).cast_mut().cast::() + }) + }) + } + + unsafe extern "C-unwind" fn set_content_type(this: &AnyObject, _: Sel, value: *mut AnyObject) { + let key = this as *const _ as usize; + CONTENT_TYPES.with(|content_types| { + let mut content_types = content_types.borrow_mut(); + if value.is_null() { + content_types.remove(&key); + } else if let Some(value) = unsafe { Retained::retain(value.cast::()) } { + content_types.insert(key, value); + } + }); + } +} + +#[cfg(target_os = "macos")] +pub use macos::set_text_content_type; + +use gpui::SharedString; + +/// Presentation-independent context-menu model produced by the editor. +#[derive(Default)] +pub struct NativeMenu { + pub items: Vec, +} + +pub enum NativeMenuItem { + Separator, + Action { + label: SharedString, + disabled: bool, + action: Box, + }, +} + +impl NativeMenu { + pub fn new() -> Self { + Self::default() + } + + pub fn menu(self, label: impl Into, action: Box) -> Self { + self.menu_with_disabled(label, false, action) + } + + pub fn menu_with_disabled( + mut self, + label: impl Into, + disabled: bool, + action: Box, + ) -> Self { + self.items.push(NativeMenuItem::Action { + label: label.into(), + disabled, + action, + }); + self + } + + pub fn separator(mut self) -> Self { + self.items.push(NativeMenuItem::Separator); + self + } +} diff --git a/vendor/gpui-component/src/input/rope_ext.rs b/vendor/gpui-base/src/input/base/rope_ext.rs similarity index 93% rename from vendor/gpui-component/src/input/rope_ext.rs rename to vendor/gpui-base/src/input/base/rope_ext.rs index e8d67a90..38ded5b6 100644 --- a/vendor/gpui-component/src/input/rope_ext.rs +++ b/vendor/gpui-base/src/input/base/rope_ext.rs @@ -2,9 +2,32 @@ use std::ops::Range; use ropey::{LineType, Rope, RopeSlice}; use sum_tree::Bias; -use tree_sitter::Point; -use crate::input::Position; +/// Parser-independent byte/row/column position used for incremental edits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Point { + pub row: usize, + pub column: usize, +} + +impl Point { + pub fn new(row: usize, column: usize) -> Self { + Self { row, column } + } +} + +/// Parser-independent description of an incremental text replacement. +#[derive(Debug, Clone, Copy)] +pub struct InputEdit { + pub start_byte: usize, + pub old_end_byte: usize, + pub new_end_byte: usize, + pub start_position: Point, + pub old_end_position: Point, + pub new_end_position: Point, +} + +use lsp_types::Position; /// An iterator over the lines of a `Rope`. pub struct RopeLines<'a> { @@ -61,7 +84,7 @@ pub trait RopeExt { /// # Example /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// assert_eq!(rope.line_start_offset(0), 0); @@ -74,7 +97,7 @@ pub trait RopeExt { /// Return the end of the rope if the row is out of bounds. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n" /// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n" @@ -84,7 +107,7 @@ pub trait RopeExt { /// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// assert_eq!(rope.slice_line(0).to_string(), "Hello"); /// assert_eq!(rope.slice_line(1).to_string(), "World\r"); @@ -98,7 +121,7 @@ pub trait RopeExt { /// If the range is out of bounds, it will be clamped to the valid range. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r"); /// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文"); @@ -113,7 +136,7 @@ pub trait RopeExt { /// Each line slice includes `\r` if present, but not `\n`. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect(); /// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]); @@ -123,7 +146,7 @@ pub trait RopeExt { /// Return the number of lines in the rope. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// assert_eq!(rope.lines_len(), 4); /// ``` @@ -134,7 +157,7 @@ pub trait RopeExt { /// If the row is out of bounds, return 0. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// assert_eq!(rope.line_len(0), 5); // "Hello" /// assert_eq!(rope.line_len(1), 6); // "World\r" @@ -151,7 +174,7 @@ pub trait RopeExt { /// - If the range is out of bounds. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); /// rope.replace(6..11, "Universe"); /// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope"); @@ -207,7 +230,7 @@ pub trait RopeExt { /// - Otherwise return the ix. /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// use sum_tree::Bias; /// /// let rope = Rope::from("Hello 中文🎉 test\nRope"); @@ -225,7 +248,7 @@ pub trait RopeExt { /// # Example /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("a 中文🎉 test\nRope"); /// assert_eq!(rope.char_index_to_offset(0), 0); /// assert_eq!(rope.char_index_to_offset(1), 1); @@ -241,7 +264,7 @@ pub trait RopeExt { /// # Example /// /// ``` - /// use gpui_component::{Rope, RopeExt}; + /// use gpui_base::input::{Rope, RopeExt}; /// let rope = Rope::from("a 中文🎉 test\nRope"); /// assert_eq!(rope.offset_to_char_index(0), 0); /// assert_eq!(rope.offset_to_char_index(1), 1); @@ -306,12 +329,9 @@ impl RopeExt for Rope { fn position_to_offset(&self, pos: &Position) -> usize { let line = self.slice_line(pos.line as usize); + // Clamp out-of-range columns, then use Ropey's index to avoid rescanning long lines. self.line_start_offset(pos.line as usize) - + line - .chars() - .take(pos.character as usize) - .map(|c| c.len_utf8()) - .sum::() + + line.char_to_byte_idx((pos.character as usize).min(line.len_chars())) } fn offset_to_position(&self, offset: usize) -> Position { @@ -365,11 +385,7 @@ impl RopeExt for Rope { let end = offset + right.len(); - if start == end { - None - } else { - Some(start..end) - } + if start == end { None } else { Some(start..end) } } fn word_at(&self, offset: usize) -> String { @@ -433,11 +449,11 @@ impl RopeExt for Rope { #[cfg(test)] mod tests { + use super::Point; use ropey::Rope; use sum_tree::Bias; - use tree_sitter::Point; - use crate::{input::Position, RopeExt}; + use crate::input::{Position, RopeExt}; #[test] fn test_slice_line() { @@ -533,6 +549,10 @@ mod tests { rope.position_to_offset(&Position::new(1, 1)), "a 中文🎉 test\nR".len() ); + assert_eq!( + rope.position_to_offset(&Position::new(0, u32::MAX)), + "a 中文🎉 test".len() + ); assert_eq!( rope.offset_to_position("a 中文🎉 test\nR".len()), diff --git a/vendor/gpui-base/src/input/base/selection.rs b/vendor/gpui-base/src/input/base/selection.rs new file mode 100644 index 00000000..e44d3f57 --- /dev/null +++ b/vendor/gpui-base/src/input/base/selection.rs @@ -0,0 +1,145 @@ +use crate::input::InputModeKind; +use std::ops::Range; + +use gpui::{Context, Window}; +use ropey::Rope; +use sum_tree::Bias; + +use super::{InputBaseState, RopeExt as _}; +use crate::text_boundary::word_range_from_chars; + +impl InputBaseState { + /// Select the word at the given offset on double-click. + /// + /// The offset is the UTF-8 offset. + pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let Some(range) = TextSelector::word_range(&self.text, offset) else { + return; + }; + + self.undo_manager.break_transaction_coalescing(); + self.selected_range = (range.start..range.end).into(); + self.selected_word_range = Some(self.selected_range); + cx.notify() + } + + /// Select the line at the given offset on triple-click. + /// + /// The offset is the UTF-8 offset. + pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let range = TextSelector::line_range(&self.text, offset); + self.undo_manager.break_transaction_coalescing(); + self.selected_range = (range.start..range.end).into(); + self.selected_word_range = None; + cx.notify() + } +} + +struct TextSelector; +impl TextSelector { + /// Select a line in the given text at the specified offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Returns the start and end offsets of the selected line. + pub(crate) fn line_range(text: &Rope, offset: usize) -> Range { + let offset = text.clip_offset(offset, Bias::Left); + let row = text.offset_to_point(offset).row; + let start = text.line_start_offset(row); + let end = text.line_end_offset(row); + + start..end + } + + /// Select a word in the given text at the specified offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Returns the start and end offsets of the selected word. + pub(crate) fn word_range(text: &Rope, offset: usize) -> Option> { + let offset = text.clip_offset(offset, Bias::Left); + let Some(char) = text.char_at(offset) else { + return None; + }; + + let end = offset + char.len_utf8(); + let prev_chars = text.chars_at(offset).reversed().take(128); + let next_chars = text.chars_at(end).take(128); + Some(word_range_from_chars(offset, char, prev_chars, next_chars)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ropey::Rope; + + #[test] + fn test_word_range() { + use indoc::indoc; + + let rope = Rope::from(indoc! { + r#" + test text: + abcde 中文🎉 test + hello[()] + test_connector ____ + Rope + rök + grande île + "# + }); + + let tests = vec![ + (0, 0, Some("test")), + (0, 4, Some(" ")), + (1, 0, Some("abcde")), + (1, 4, Some("abcde")), + (1, 5, Some(" ")), + (1, 6, Some("中")), + (1, 9, Some("文")), + (1, 13, Some("🎉")), + (1, 20, Some("test")), + (2, 5, Some("[")), + (2, 6, Some("(")), + (2, 7, Some(")")), + (2, 8, Some("]")), + (3, 5, Some("test_connector")), + (3, 14, Some(" ")), + (3, 16, Some("____")), + (4, 0, Some("Rope")), + (5, 0, Some("rök")), + (6, 8, Some("île")), + ]; + + for (line, column, expected) in tests { + let line_start_offset = rope.line_start_offset(line); + let offset = line_start_offset + column; + let range = TextSelector::word_range(&rope, offset); + + let actual = range.map(|r| rope.slice(r).to_string()); + let expect = expected.map(|s| s.to_string()); + assert_eq!(actual, expect, "line {}, column {}", line, column); + } + } + + #[test] + fn test_line_range() { + let rope = Rope::from("first line\nsecond line\nthird"); + let tests = vec![ + (0, 0, "first line"), + (0, 5, "first line"), + (1, 3, "second line"), + (2, 1, "third"), + ]; + + for (line, column, expected) in tests { + let line_start_offset = rope.line_start_offset(line); + let offset = line_start_offset + column; + let range = TextSelector::line_range(&rope, offset); + + let actual = rope.slice(range).to_string(); + assert_eq!(actual, expected, "line {}, column {}", line, column); + } + } +} diff --git a/vendor/gpui-base/src/input/base/state.rs b/vendor/gpui-base/src/input/base/state.rs new file mode 100644 index 00000000..e59787f1 --- /dev/null +++ b/vendor/gpui-base/src/input/base/state.rs @@ -0,0 +1,4987 @@ +//! A text input field that allows the user to enter text. +//! +//! Based on the `Input` example from the `gpui` crate. +//! https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/input.rs +use gpui::TextAlign; +use gpui::{ + Action, App, AppContext, Bounds, ClipboardItem, Context, Edges, Entity, EntityInputHandler, + EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, + KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, + Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, + UTF16Selection, Window, actions, div, point, prelude::FluentBuilder as _, px, +}; +use ropey::{Rope, RopeSlice}; +use serde::Deserialize; +use std::borrow::Cow; +use std::cell::Cell; +use std::ops::Range; +use std::rc::Rc; +use sum_tree::Bias; +use unicode_segmentation::*; + +use super::{ + DiagnosticSet, DisplayMap, InputContextMenuCapabilities, InputEditorStyle, + InputHighlighterFactory, MASK_CHAR, MaskPattern, NativeMenu, NumberStep, WrappingIndent, + blink_cursor::BlinkCursor, + change::Change, + element::{EditorScrollbar, EditorScrollbarSnapshot, TextElement}, + kind::InputModeKind, + mask_pattern::normalize_number_input, + mode::LayoutMode, + undo_manager::{EditIntent, UndoManager}, +}; +use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; +use crate::input::blink_cursor::CURSOR_WIDTH; +use crate::input::movement::MoveDirection; +use crate::input::{ + InputExtras as _, Position, RopeExt as _, Selection, element::RIGHT_MARGIN, layout::LastLayout, +}; +use crate::{AutoScroll, StepAction}; + +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = input, no_json)] +pub struct Enter { + /// Is confirm with secondary. + pub secondary: bool, + /// Whether the Shift modifier was held when Enter was pressed. + pub shift: bool, +} + +impl Enter { + /// Returns true if `action` is a primary `Enter` action (`secondary: false`), + /// regardless of whether Shift was held. + pub fn is_primary(action: &dyn Action) -> bool { + action.partial_eq(&Enter { + secondary: false, + shift: false, + }) || action.partial_eq(&Enter { + secondary: false, + shift: true, + }) + } +} + +actions!( + input, + [ + Backspace, + Delete, + DeleteToBeginningOfLine, + DeleteToEndOfLine, + DeleteToPreviousWordStart, + DeleteToNextWordEnd, + Indent, + Outdent, + IndentInline, + OutdentInline, + MoveUp, + MoveDown, + MoveLeft, + MoveRight, + MoveHome, + MoveEnd, + MovePageUp, + MovePageDown, + SelectAll, + SelectToStartOfLine, + SelectToEndOfLine, + SelectToStart, + SelectToEnd, + SelectToPreviousWordStart, + SelectToNextWordEnd, + ShowCharacterPalette, + Copy, + Cut, + Paste, + Undo, + Redo, + MoveToStartOfLine, + MoveToEndOfLine, + MoveToStart, + MoveToEnd, + MoveToPreviousWord, + MoveToNextWord, + Escape, + ToggleCodeActions, + Search, + Replace, + GoToDefinition, + ] +); + +#[derive(Clone)] +pub enum InputEvent { + Change, + BackspaceEmpty, + DeleteEmpty, + PressEnter { secondary: bool, shift: bool }, + Focus, + Blur, +} + +pub(super) const CONTEXT: &str = "Input"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, Some(CONTEXT)), + KeyBinding::new("shift-backspace", Backspace, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-backspace", Backspace, Some(CONTEXT)), + KeyBinding::new("delete", Delete, Some(CONTEXT)), + KeyBinding::new("shift-delete", Delete, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-delete", DeleteToEndOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-backspace", DeleteToPreviousWordStart, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-backspace", DeleteToPreviousWordStart, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-delete", DeleteToNextWordEnd, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)), + KeyBinding::new( + "enter", + Enter { + secondary: false, + shift: false, + }, + Some(CONTEXT), + ), + KeyBinding::new( + "shift-enter", + Enter { + secondary: false, + shift: true, + }, + Some(CONTEXT), + ), + KeyBinding::new( + "secondary-enter", + Enter { + secondary: true, + shift: false, + }, + Some(CONTEXT), + ), + KeyBinding::new("escape", Escape, Some(CONTEXT)), + KeyBinding::new("up", MoveUp, Some(CONTEXT)), + KeyBinding::new("down", MoveDown, Some(CONTEXT)), + KeyBinding::new("left", MoveLeft, Some(CONTEXT)), + KeyBinding::new("right", MoveRight, Some(CONTEXT)), + KeyBinding::new("pageup", MovePageUp, Some(CONTEXT)), + KeyBinding::new("pagedown", MovePageDown, Some(CONTEXT)), + KeyBinding::new("tab", IndentInline, Some(CONTEXT)), + KeyBinding::new("shift-tab", OutdentInline, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-]", Indent, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-]", Indent, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-[", Outdent, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-[", Outdent, Some(CONTEXT)), + KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), + KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), + KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), + KeyBinding::new("home", MoveHome, Some(CONTEXT)), + KeyBinding::new("end", MoveEnd, Some(CONTEXT)), + KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), + KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-shift-e", SelectToEndOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("shift-cmd-left", SelectToStartOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("shift-cmd-right", SelectToEndOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-shift-right", SelectToNextWordEnd, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-shift-right", SelectToNextWordEnd, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-c", Copy, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-x", Cut, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-v", Paste, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-a", MoveHome, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-left", MoveHome, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-e", MoveEnd, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-right", MoveEnd, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-z", Undo, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-z", Redo, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-up", MoveToStart, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-down", MoveToEnd, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-left", MoveToPreviousWord, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("alt-right", MoveToNextWord, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-left", MoveToPreviousWord, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-right", MoveToNextWord, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-up", SelectToStart, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-down", SelectToEnd, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-f", Search, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-f", Search, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-f", Replace, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-h", Replace, Some(CONTEXT)), + ]); +} + +/// The shared text-editing engine behind [`crate::input::InputState`], +/// [`crate::input::TextareaState`] and [`crate::input::EditorState`]. +/// +/// `M` is the mode marker: it carries no data and only decides which methods +/// exist, so an ordinary input cannot reach the editor's language features. +/// +/// The three states are type aliases of this one, which is why this name is +/// public: an alias is only as usable as the type behind it, so hiding this +/// would leave `InputState` unable to do anything. Prefer naming the aliases +/// — write `InputState`, not `InputBaseState`. +pub struct InputBaseState { + /// State only this mode needs. See [`InputModeKind::Extras`]. + pub(crate) extras: M::Extras, + pub(super) focus_handle: FocusHandle, + pub(super) mode: LayoutMode, + pub(super) text: Rope, + pub(super) display_map: DisplayMap, + pub(super) undo_manager: UndoManager, + pub(super) search_session: super::SearchSession, + pub(super) searchable: bool, + pub(super) replaceable: bool, + pub(super) soft_wrap: bool, + pub(super) wrapping_indent: WrappingIndent, + pub(super) scroll_beyond_last_line: Option, + pub(super) cursor_surrounding_lines: Option, + pub(super) blink_cursor: Entity, + pub(super) loading: bool, + /// Range in UTF-8 length for the selected text. + /// + /// - "Hello 世界💝" = 16 + /// - "💝" = 4 + pub(super) selected_range: Selection, + /// Range for save the selected word, use to keep word range when drag move. + pub(super) selected_word_range: Option, + pub(super) selection_reversed: bool, + /// The marked range is the temporary insert text on IME typing. + pub(super) ime_marked_range: Option, + pub(super) last_layout: Option, + pub(super) last_cursor: Option, + /// The input container bounds + pub(super) input_bounds: Bounds, + /// The text bounds + pub(super) last_bounds: Option>, + pub(super) last_selected_range: Option, + pub(super) selecting: bool, + pub(crate) disabled: bool, + pub(crate) readonly: bool, + pub(crate) text_align: TextAlign, + pub(super) masked: bool, + pub(super) clean_on_escape: bool, + pub(super) submit_on_enter: bool, + pub(super) show_whitespaces: bool, + /// This flag tells the renderer to prefer the end of the current visual line. + pub(crate) cursor_line_end_affinity: bool, + pub(super) pattern: Option, + pub(super) validate: Option bool + 'static>>, + /// The step strategy for [`super::NumberInput`] to increment/decrement. + /// See [`Self::step`] and [`Self::step_by`]. + pub(crate) number_step: Option, + /// The minimum value for [`super::NumberInput`]. See [`Self::min`]. + pub(crate) number_min: Option, + /// The maximum value for [`super::NumberInput`]. See [`Self::max`]. + pub(crate) number_max: Option, + pub(crate) scroll_handle: ScrollHandle, + /// The deferred scroll offset to apply on next layout. + pub(crate) deferred_scroll_offset: Option>, + /// The size of the scrollable content. + pub(crate) scroll_size: gpui::Size, + pub(super) editor_scrollbar_snapshot: Cell>, + pub(super) editor_paddings: Edges, + pub(super) editor_style: InputEditorStyle, + + /// The mask pattern for formatting the input text + pub(crate) mask_pattern: MaskPattern, + /// Whether the `mask_pattern` was explicitly set (via [`Self::mask_pattern`] + /// or [`Self::set_mask_pattern`]), to let [`super::NumberInput`] only apply + /// its default mask when the user has not made an explicit choice. + pub(super) mask_pattern_set: bool, + pub(super) placeholder: SharedString, + + /// Diagnostic currently requested by pointer hover; applications render it. + pub(super) diagnostic_popover: Option>, + + context_menu_handler: Option< + Rc, &mut Window, &mut App)>, + >, + pending_context_menu: Option<(Point, usize)>, + + /// Whether the context menu that shows on right-click is enabled. + /// + pub(super) enable_context_menu: bool, + + /// A flag to indicate if we are currently inserting a completion item. + pub(super) completion_inserting: bool, + pub(super) overlay_action_handler: Option< + Rc< + dyn Fn( + super::InputOverlayKind, + Box, + &mut Window, + &mut Context>, + ) -> bool, + >, + >, + + /// A flag to indicate if we have a pending update to the text. + /// + /// If true, will call some update (for example LSP, Syntax Highlight) before render. + _pending_update: bool, + /// A flag to indicate if we should ignore the next completion event. + pub(super) silent_replace_text: bool, + /// A flag to indicate if we should emit InputEvents. + pub(super) emit_events: bool, + + /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. + /// + /// The first element is the x-coordinate (Pixels), preferred to use this. + /// The second element is the column (usize), fallback to use this. + pub(super) preferred_column: Option<(Pixels, usize)>, + _subscriptions: Vec, + + pub(super) auto_scroll: AutoScroll, +} + +/// Read-only styling data exposed to presentation facades. +/// +/// The fields are private and read through the methods below, so that a new +/// one can be added without breaking the facades. +#[derive(Clone)] +pub struct InputPresentation { + focus_handle: FocusHandle, + disabled: bool, + readonly: bool, + loading: bool, + masked: bool, + multi_line: bool, + code_editor: bool, + text_align: TextAlign, + placeholder: SharedString, + mask_placeholder: Option, +} + +impl InputPresentation { + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn is_readonly(&self) -> bool { + self.readonly + } + + /// Returns true if the user is allowed to change the text. + /// + /// See also: [`InputBaseState::is_editable`]. + pub fn is_editable(&self) -> bool { + !self.disabled && !self.readonly + } + + pub fn is_loading(&self) -> bool { + self.loading + } + + pub fn is_masked(&self) -> bool { + self.masked + } + + pub fn is_multi_line(&self) -> bool { + self.multi_line + } + + pub fn is_code_editor(&self) -> bool { + self.code_editor + } + + pub fn text_align(&self) -> TextAlign { + self.text_align + } + + pub fn placeholder(&self) -> &SharedString { + &self.placeholder + } + + /// The placeholder derived from the mask pattern, e.g.: `(___) ___-____`. + pub fn mask_placeholder(&self) -> Option<&str> { + self.mask_placeholder.as_deref() + } +} + +impl EventEmitter for InputBaseState {} + +impl InputBaseState { + #[doc(hidden)] + pub fn cursor_layout(&self) -> Option<(Bounds, Pixels)> { + let layout = self.last_layout.as_ref()?; + Some((layout.cursor_bounds?, layout.line_height)) + } + + pub fn input_bounds(&self) -> Bounds { + self.input_bounds + } + + pub fn text_bounds(&self) -> Option> { + self.last_bounds + } + + pub fn diagnostic_popover(&self) -> Option> { + self.diagnostic_popover.clone() + } + + pub fn presentation(&self) -> InputPresentation { + InputPresentation { + focus_handle: self.focus_handle.clone(), + disabled: self.disabled, + readonly: self.readonly, + loading: self.loading, + masked: self.masked, + multi_line: self.is_multi_line(), + code_editor: self.is_code_editor(), + text_align: self.text_align, + placeholder: self.placeholder.clone(), + mask_placeholder: self.mask_pattern.placeholder(), + } + } + + /// Whether this input spans more than one line. + /// + /// Answered by the mode marker, which is fixed when the state is built. + /// [`LayoutMode`] holds the row counts and growth policy, not the kind. + #[inline] + pub fn is_multi_line(&self) -> bool { + M::MULTI_LINE + } + + /// Whether this input is a single-line text field. See [`Self::is_multi_line`]. + #[inline] + pub fn is_single_line(&self) -> bool { + !M::MULTI_LINE + } + + /// Whether this input is a source-code editor. + #[inline] + pub fn is_code_editor(&self) -> bool { + M::CODE_EDITOR + } + + pub fn context_menu_capabilities(&self) -> InputContextMenuCapabilities { + let (go_to_definition, code_actions) = self.extras.context_menu_capabilities(); + InputContextMenuCapabilities::new() + .disabled(self.disabled) + .readonly(self.readonly) + .code_editor(self.is_code_editor()) + .selection(!self.selected_range.is_empty()) + .go_to_definition(go_to_definition) + .code_actions(code_actions) + } + + pub fn set_text_align(&mut self, text_align: TextAlign, cx: &mut Context) { + if !self.is_single_line() || self.text_align == text_align { + return; + } + + self.text_align = text_align; + cx.notify(); + } + + /// Flip the password mask. + /// + /// Setting the mask is a single-line method, but flipping it stays here: + /// the reveal button is rendered from the generic path, and it can only be + /// switched on through [`crate::input::InputState`] anyway. + pub fn toggle_masked(&mut self, _: &mut Window, cx: &mut Context) { + self.masked = !self.masked; + cx.notify(); + } + + pub fn on_context_menu( + &mut self, + handler: Rc< + dyn Fn(NativeMenu, InputContextMenuCapabilities, Point, &mut Window, &mut App), + >, + ) { + self.context_menu_handler = Some(handler); + } + + /// Build the engine. Each mode's own `new` sets its layout on top of this. + fn new_in_mode(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle().tab_stop(true); + let blink_cursor = cx.new(|_| BlinkCursor::new()); + let undo_manager = UndoManager::new(); + + let _subscriptions = vec![ + // Observe the blink cursor to repaint the view when it changes. + cx.observe(&blink_cursor, |_, _, cx| cx.notify()), + // Blink the cursor when the window is active, pause when it's not. + cx.observe_window_activation(window, |input, window, cx| { + if window.is_window_active() { + let focus_handle = input.focus_handle.clone(); + if focus_handle.is_focused(window) { + input.blink_cursor.update(cx, |blink_cursor, cx| { + blink_cursor.start(cx); + }); + } + } + }), + cx.on_focus(&focus_handle, window, Self::on_focus), + cx.on_blur(&focus_handle, window, Self::on_blur), + ]; + + let text_style = window.text_style(); + + Self { + extras: M::Extras::default(), + focus_handle: focus_handle.clone(), + text: "".into(), + display_map: DisplayMap::new(text_style.font(), window.rem_size(), None), + search_session: super::SearchSession::default(), + searchable: false, + replaceable: true, + soft_wrap: true, + wrapping_indent: WrappingIndent::default(), + scroll_beyond_last_line: None, + cursor_surrounding_lines: None, + blink_cursor, + undo_manager, + selected_range: Selection::default(), + selected_word_range: None, + selection_reversed: false, + ime_marked_range: None, + input_bounds: Bounds::default(), + selecting: false, + disabled: false, + readonly: false, + text_align: TextAlign::Left, + masked: false, + clean_on_escape: false, + submit_on_enter: false, + show_whitespaces: false, + loading: false, + pattern: None, + validate: None, + number_step: Some(NumberStep::Fixed(1.)), + number_min: None, + number_max: None, + mode: LayoutMode::default(), + last_layout: None, + last_bounds: None, + last_selected_range: None, + last_cursor: None, + scroll_handle: ScrollHandle::new(), + scroll_size: gpui::size(px(0.), px(0.)), + editor_scrollbar_snapshot: Cell::new(None), + editor_paddings: Edges::default(), + deferred_scroll_offset: None, + preferred_column: None, + placeholder: SharedString::default(), + mask_pattern: MaskPattern::default(), + mask_pattern_set: false, + editor_style: InputEditorStyle::default(), + diagnostic_popover: None, + context_menu_handler: None, + pending_context_menu: None, + enable_context_menu: true, + completion_inserting: false, + overlay_action_handler: None, + silent_replace_text: false, + emit_events: true, + _subscriptions, + _pending_update: false, + cursor_line_end_affinity: false, + auto_scroll: AutoScroll::default(), + } + } + + /// Sets whether the context menu that shows on right-click is enabled. + /// + /// The context menu is enabled by default. + /// This value is ignored if a custom context menu builder is defined on the input. + pub fn context_menu(mut self, enable: bool) -> Self { + self.enable_context_menu = enable; + self + } + + pub fn set_context_menu_enabled(&mut self, enabled: bool) { + self.enable_context_menu = enabled; + } + + /// Set whether search UI allows replacement, default is true. + #[doc(hidden)] + pub fn replaceable(mut self, allow: bool) -> Self { + self.replaceable = allow; + self + } + + /// Set placeholder + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = placeholder.into(); + self + } + + /// Set highlighter language for for [`LayoutMode::CodeEditor`] mode. + pub fn set_highlighter( + &mut self, + new_language: impl Into, + cx: &mut Context, + ) { + match &mut self.mode { + LayoutMode::CodeEditor { + language, + highlighter, + .. + } => { + *language = new_language.into(); + *highlighter.borrow_mut() = None; + } + _ => {} + } + cx.notify(); + } + + fn reset_highlighter(&mut self, cx: &mut Context) { + match &mut self.mode { + LayoutMode::CodeEditor { highlighter, .. } => { + *highlighter.borrow_mut() = None; + } + _ => {} + } + cx.notify(); + } + + /// Install the parser/highlighter adapter used by code-editor mode. + pub fn set_highlighter_factory( + &mut self, + factory: InputHighlighterFactory, + cx: &mut Context, + ) { + self.mode.set_highlighter_factory(factory); + self._pending_update = true; + cx.notify(); + } + + /// Install a default adapter without replacing an application-provided one. + pub fn ensure_highlighter_factory(&mut self, factory: InputHighlighterFactory) { + self.mode.ensure_highlighter_factory(factory); + } + + pub fn set_editor_style(&mut self, style: InputEditorStyle) { + self.editor_style = style; + } + + /// Set presentation padding for multi-line text and its scrollbar layout. + #[doc(hidden)] + pub fn set_editor_paddings(&mut self, paddings: Edges) { + self.editor_paddings = paddings; + } + + pub fn apply_highlighter_fold_candidates( + &mut self, + candidates: Vec, + cx: &mut Context, + ) { + if self.mode.is_folding() { + self.display_map.set_fold_candidates(candidates); + } + cx.notify(); + } + + #[inline] + pub fn diagnostics(&self) -> Option<&DiagnosticSet> { + self.mode.diagnostics() + } + + #[inline] + pub fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticSet> { + self.mode.diagnostics_mut() + } + + /// Set placeholder + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. + /// + /// Returns: + /// + /// - The index of the line (zero-based) containing the offset. + /// - The index of the sub-line (zero-based) within the line containing the offset. + /// - The position of the offset. + pub(super) fn line_and_position_for_offset( + &self, + offset: usize, + ) -> (usize, usize, Option>) { + let Some(last_layout) = &self.last_layout else { + return (0, 0, None); + }; + let line_height = last_layout.line_height; + + let mut y_offset = last_layout.visible_top; + for (vi, line) in last_layout.lines.iter().enumerate() { + let prev_lines_offset = last_layout.visible_line_byte_offsets[vi]; + let local_offset = offset.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(local_offset, last_layout, false) { + let sub_line_index = (pos.y / line_height) as usize; + let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); + return (vi, sub_line_index, Some(adjusted_pos)); + } + + y_offset += line.size(line_height).height; + } + (0, 0, None) + } + + /// Set the text of the input field. + /// + /// For single-line inputs the caret is placed at the end of the text while + /// the view is scrolled back to the start, so a long value shows its + /// beginning instead of its tail (matching HTML ``). Multi-line + /// inputs reset the selection to `0..0`. + pub fn set_value( + &mut self, + value: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.set_ignoring(true); + self.emit_events = false; + self.replace_text(value, window, cx); + self.undo_manager.set_ignoring(false); + self.emit_events = true; + + self.reset_selection(); + self.reset_lsp_state(); + self.reset_scroll_to_start(); + + self.undo_manager.clear(); + cx.notify(); + } + + /// Replace the entire text content while preserving undo history. + /// + /// Unlike [`set_value`](Self::set_value), this method records the + /// replacement in the undo stack, allowing the user to undo/redo + /// the change. The selection is placed at the end of the new text + /// for single-line inputs, or cleared (0..0) for multi-line inputs. + /// + /// Use this when programmatically replacing the full text but the + /// user should still be able to undo the operation — e.g. formatting. + pub fn replace_all( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_text(text, window, cx); + self.reset_selection(); + self.reset_lsp_state(); + self.reset_scroll_to_start(); + + cx.notify(); + } + + /// Perform `f` with the user-facing edit restrictions lifted. + /// + /// The `disabled` and `readonly` modes only reject the changes made by the + /// user, the programmatic APIs must always be able to update the text. + fn with_edits_allowed(&mut self, f: impl FnOnce(&mut Self)) { + let (was_disabled, was_readonly) = (self.disabled, self.readonly); + (self.disabled, self.readonly) = (false, false); + f(self); + (self.disabled, self.readonly) = (was_disabled, was_readonly); + } + + /// Insert text at the current cursor position. + /// + /// And the cursor will be moved to the end of inserted text. + pub fn insert( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let text: SharedString = text.into(); + self.with_edits_allowed(|this| { + this.undo_manager.pending_intent = Some(EditIntent::Atomic); + let range_utf16 = this.range_to_utf16(&(this.cursor()..this.cursor())); + this.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); + this.selected_range = (this.selected_range.end..this.selected_range.end).into(); + }); + } + + /// Replace text at the current cursor position. + /// + /// And the cursor will be moved to the end of replaced text. + pub fn replace( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let text: SharedString = text.into(); + self.with_edits_allowed(|this| { + this.undo_manager.pending_intent = Some(EditIntent::Atomic); + this.replace_text_in_range_silent(None, &text, window, cx); + this.selected_range = (this.selected_range.end..this.selected_range.end).into(); + }); + } + + fn replace_text( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let text: SharedString = text.into(); + self.with_edits_allowed(|this| { + this.undo_manager.pending_intent = Some(EditIntent::Atomic); + let range = 0..this.text.chars().map(|c| c.len_utf16()).sum(); + this.replace_text_in_range_silent(Some(range), &text, window, cx); + this.reset_highlighter(cx); + }); + } + + fn reset_selection(&mut self) { + // For single-line inputs the caret is placed at the end of the text + // (matching HTML ``); multi-line inputs reset the selection to + // `0..0`. + if self.is_single_line() { + let end = self.text.len(); + self.selected_range = (end..end).into(); + } else { + self.selected_range.clear(); + } + } + + fn reset_lsp_state(&mut self) { + if self.is_code_editor() { + self._pending_update = true; + M::reset_language_features(self); + } + } + + fn reset_scroll_to_start(&mut self) { + // Move scroll to the start. For single-line the caret is at the end, so + // override the cursor-follow scroll for the next painted frame to keep + // the start visible; the deferred offset is consumed during that paint. + self.scroll_handle.set_offset(point(px(0.), px(0.))); + if self.is_single_line() { + self.deferred_scroll_offset = Some(point(px(0.), px(0.))); + } + } + + /// Set with disabled mode. + /// + /// See also: [`Self::set_disabled`]. + #[allow(unused)] + pub(crate) fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + if self.disabled == disabled { + return; + } + + self.disabled = disabled; + cx.notify(); + } + + /// Set with read-only mode. + /// + /// Unlike [`Self::disabled`], a read-only input keeps the normal appearance, + /// focus, cursor, selection and copy behavior, it only rejects any change + /// of the text made by the user. + /// + /// See also: [`Self::set_readonly`]. + #[allow(unused)] + pub(crate) fn readonly(mut self, readonly: bool) -> Self { + self.readonly = readonly; + self + } + + pub fn set_readonly(&mut self, readonly: bool, cx: &mut Context) { + if self.readonly == readonly { + return; + } + + self.readonly = readonly; + if readonly { + self.search_session.replace_mode = false; + } + cx.notify(); + } + + /// Returns true if the user is allowed to change the text. + /// + /// This is false when the input is `disabled` or `readonly`, the programmatic + /// APIs (e.g.: [`Self::set_value`], [`Self::insert`]) are not limited by this. + pub fn is_editable(&self) -> bool { + !self.disabled && !self.readonly + } + + /// Set true to clear the input by pressing Escape key. + pub fn clean_on_escape(mut self) -> Self { + self.clean_on_escape = true; + self + } + + pub fn set_clean_on_escape(&mut self, clean: bool) { + self.clean_on_escape = clean; + } + + /// Set true to treat `Enter` as a submit action in multi-line mode, + /// while `Shift+Enter` inserts a newline. + /// + /// Default is `false` (both `Enter` and `Shift+Enter` insert a newline). + #[doc(hidden)] + pub fn submit_on_enter(mut self, submit: bool) -> Self { + self.submit_on_enter = submit; + self + } + + pub fn set_submit_on_enter(&mut self, submit: bool, cx: &mut Context) { + self.submit_on_enter = submit; + cx.notify(); + } + + /// Set whether to show whitespace characters. + #[doc(hidden)] + pub fn show_whitespaces(mut self, show: bool) -> Self { + self.show_whitespaces = show; + self + } + + /// Update whether to show whitespace characters. + pub fn set_show_whitespaces(&mut self, show: bool, _: &mut Window, cx: &mut Context) { + self.show_whitespaces = show; + cx.notify(); + } + + /// Empty rows reserved below the last line of content ("scroll + /// beyond last line"), code-editor mode only. Mirrors VSCode's + /// `editor.scrollBeyondLastLine` / Zed's `scroll_beyond_last_line`. + /// + /// - `None` (default): half the viewport, floored at + /// [`BOTTOM_MARGIN_ROWS`] line-heights. + /// - `Some(0)`: no trailing space; the cursor sits flush with the + /// last row at scroll-max. + /// - `Some(n)`: exactly `n` rows. + pub fn scroll_beyond_last_line(mut self, rows: Option) -> Self { + self.scroll_beyond_last_line = rows; + self + } + + /// Update [`Self::scroll_beyond_last_line`] after construction. + pub fn set_scroll_beyond_last_line( + &mut self, + rows: Option, + _: &mut Window, + cx: &mut Context, + ) { + if self.scroll_beyond_last_line == rows { + return; + } + self.scroll_beyond_last_line = rows; + cx.notify(); + } + + /// Minimum number of lines the cursor is kept clear of the viewport's + /// top/bottom edge before auto-scroll engages. Mirrors VSCode's + /// `editor.cursorSurroundingLines` / Zed's `vertical_scroll_margin`. + /// Orthogonal to [`Self::scroll_beyond_last_line`], which sizes the + /// empty region; this controls the cursor's resting distance from the + /// edge. + /// + /// - `None` (default): [`BOTTOM_MARGIN_ROWS`] lines, falling back to + /// one line on small viewports. + /// - `Some(n)`: exactly `n` lines, clamped to half the viewport. + pub fn cursor_surrounding_lines(mut self, lines: Option) -> Self { + self.cursor_surrounding_lines = lines; + self + } + + /// Update [`Self::cursor_surrounding_lines`] after construction. + pub fn set_cursor_surrounding_lines( + &mut self, + lines: Option, + _: &mut Window, + cx: &mut Context, + ) { + if self.cursor_surrounding_lines == lines { + return; + } + self.cursor_surrounding_lines = lines; + cx.notify(); + } + + /// Set the default value of the input field. + pub fn default_value(mut self, value: impl Into) -> Self { + let text: SharedString = value.into(); + self.text = Rope::from(self.normalize_input(&text).as_ref()); + if let Some(diagnostics) = self.mode.diagnostics_mut() { + diagnostics.reset(&self.text) + } + // Note: We can't call display_map.set_text here because it needs cx. + // The text will be set during prepare_if_need in element.rs + self._pending_update = true; + self + } + + /// Return the value of the input field as an owned string. + /// + /// The string is materialized on each call. See [`Self::text`] for the + /// [`Rope`] the state owns, which is borrowed and costs nothing to read. + pub fn value(&self) -> SharedString { + SharedString::new(self.text.to_string()) + } + + /// Return the portion of the value within the input field that + /// is selected by the user, as an owned string. + /// + /// The string is materialized on each call. See [`Self::selected_text`] + /// for the same selection borrowed out of the [`Rope`] the state owns. + pub fn selected_value(&self) -> SharedString { + SharedString::new(self.selected_text().to_string()) + } + + /// Return the value without mask. + pub fn unmask_value(&self) -> SharedString { + self.mask_pattern.unmask(&self.text.to_string()).into() + } + + /// Kept so existing render paths keep compiling. + /// + /// Configuration used to be collected by a facade and applied here; the + /// state now configures itself, so this does nothing and can be deleted at + /// the call site. + #[doc(hidden)] + pub fn prepare(&mut self, _: &mut Window, _: &mut Context) {} + + /// Return the text [`Rope`] of the input field. + /// + /// Borrowed from the state, so reading even a large document copies + /// nothing. See [`Self::value`] when an owned string is wanted. + pub fn text(&self) -> &Rope { + &self.text + } + + /// Return the (0-based) [`Position`] of the cursor. + pub fn cursor_position(&self) -> Position { + let offset = self.cursor(); + self.text.offset_to_position(offset) + } + + /// Set (0-based) [`Position`] of the cursor. + /// + /// This will move the cursor to the specified line and column, and update the selection range. + pub fn set_cursor_position( + &mut self, + position: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let position: Position = position.into(); + let offset = self.text.position_to_offset(&position); + + self.move_to(offset, None, cx); + self.update_preferred_column(); + self.focus(window, cx); + } + + /// Focus the input field. + pub fn focus(&self, window: &mut Window, cx: &mut Context) { + self.focus_handle.focus(window, cx); + self.blink_cursor.update(cx, |cursor, cx| { + cursor.start(cx); + }); + } + + /// Refresh the input, so the next render re-runs syntax highlighting and + /// the LSP providers, not just a redraw. + /// + /// Assigning the `lsp` providers (or other render-affecting state) at + /// runtime does not take effect until the text next changes. Call this + /// afterwards to force the refresh on the next render. + /// + /// ```ignore + /// input.update(cx, |state, cx| { + /// state.extras.lsp.hover_provider = Some(provider); + /// state.refresh(cx); + /// }); + /// ``` + pub fn refresh(&mut self, cx: &mut Context) { + self._pending_update = true; + cx.notify(); + } + + pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.undo_manager.break_transaction_coalescing(); + self.select_to(self.previous_boundary(self.cursor()), cx); + } + + pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.undo_manager.break_transaction_coalescing(); + self.select_to(self.next_boundary(self.cursor()), cx); + } + + pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + if self.is_single_line() { + return; + } + self.undo_manager.break_transaction_coalescing(); + let offset = self.start_of_line().saturating_sub(1); + self.select_to(self.previous_boundary(offset), cx); + } + + pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + if self.is_single_line() { + return; + } + self.undo_manager.break_transaction_coalescing(); + let offset = (self.end_of_line() + 1).min(self.text.len()); + self.select_to(self.next_boundary(offset), cx); + } + + pub(super) fn on_action_select_all( + &mut self, + _: &SelectAll, + window: &mut Window, + cx: &mut Context, + ) { + self.select_all(window, cx); + } + + pub(super) fn select_to_start( + &mut self, + _: &SelectToStart, + _: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + self.select_to(0, cx); + } + + pub(super) fn select_to_end( + &mut self, + _: &SelectToEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + let end = self.text.len(); + self.select_to(end, cx); + } + + pub(super) fn select_to_start_of_line( + &mut self, + _: &SelectToStartOfLine, + _: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + let offset = self.start_of_line(); + self.select_to(offset, cx); + } + + pub(super) fn select_to_end_of_line( + &mut self, + _: &SelectToEndOfLine, + _: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + let offset = self.end_of_line(); + self.select_to(offset, cx); + } + + pub(super) fn select_to_previous_word( + &mut self, + _: &SelectToPreviousWordStart, + _: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + let offset = self.previous_start_of_word(); + self.select_to(offset, cx); + } + + pub(super) fn select_to_next_word( + &mut self, + _: &SelectToNextWordEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + let offset = self.next_end_of_word(); + self.select_to(offset, cx); + } + + /// Return the start offset of the previous word. + pub(super) fn previous_start_of_word(&mut self) -> usize { + let offset = self.selected_range.start; + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); + // FIXME: Avoid to_string + let left_part = self.text.slice(0..offset).to_string(); + + UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) + .rfind(|(_, s)| !s.trim_start().is_empty()) + .map(|(i, _)| i) + .unwrap_or(0) + } + + /// Return the next end offset of the next word. + pub(super) fn next_end_of_word(&mut self) -> usize { + let offset = self.cursor(); + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); + let right_part = self.text.slice(offset..self.text.len()).to_string(); + + UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) + .find(|(_, s)| !s.trim_start().is_empty()) + .map(|(i, s)| offset + i + s.len()) + .unwrap_or(self.text.len()) + } + + /// Get start of line byte offset of cursor. + /// + /// When soft wrap is active, first press goes to visual line start, + /// second press (already at visual start) goes to logical line start. + pub(super) fn start_of_line(&self) -> usize { + if self.is_single_line() { + return 0; + } + + let row = self.text.offset_to_point(self.cursor()).row; + let logical_start = self.text.line_start_offset(row); + + if self.soft_wrap && self.is_code_editor() { + let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); + if let Some(line) = self.display_map.line(row) + && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) + { + let visual_start = logical_start + range.start; + if self.cursor() != visual_start { + return visual_start; + } + } + } + + logical_start + } + + /// Get end of line byte offset of cursor. + /// + /// When soft wrap is active, first press goes to visual line end, + /// second press (already at visual end) goes to logical line end. + pub(super) fn end_of_line(&self) -> usize { + if self.is_single_line() { + return self.text.len(); + } + + let row = self.text.offset_to_point(self.cursor()).row; + let logical_start = self.text.line_start_offset(row); + let logical_end = self.text.line_end_offset(row); + + if self.soft_wrap && self.is_code_editor() { + let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); + if let Some(line) = self.display_map.line(row) + && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) + { + let visual_end = logical_start + range.end; + if self.cursor() != visual_end { + return visual_end; + } + } + } + + logical_end + } + + /// Get start line of selection start or end (The min value). + /// + /// This is means is always get the first line of selection. + pub(super) fn start_of_line_of_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> usize { + if self.is_single_line() { + return 0; + } + + let mut offset = + self.previous_boundary(self.selected_range.start.min(self.selected_range.end)); + if self.text.char_at(offset) == Some('\r') { + offset += 1; + } + + let line = self + .text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) + .unwrap_or_default() + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + line + } + + /// Get indent string of next line. + /// + /// To get current and next line indent, to return more depth one. + pub(super) fn indent_of_next_line(&mut self) -> String { + if self.is_single_line() { + return "".into(); + } + + let mut current_indent = String::new(); + let mut next_indent = String::new(); + let current_line_start_pos = self.start_of_line(); + let next_line_start_pos = self.end_of_line(); + for c in self.text.slice(current_line_start_pos..).chars() { + if !c.is_whitespace() { + break; + } + if c == '\n' || c == '\r' { + break; + } + current_indent.push(c); + } + + for c in self.text.slice(next_line_start_pos..).chars() { + if !c.is_whitespace() { + break; + } + if c == '\n' || c == '\r' { + break; + } + next_indent.push(c); + } + + if next_indent.len() > current_indent.len() { + return next_indent; + } else { + return current_indent; + } + } + + pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.text.len() == 0 && self.selected_range.is_empty() { + cx.emit(InputEvent::BackspaceEmpty); + return; + } + let intent = if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor()), cx); + EditIntent::Backspace + } else { + EditIntent::Atomic + }; + self.undo_manager.pending_intent = Some(intent); + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + } + + pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.text.len() == 0 && self.selected_range.is_empty() { + cx.emit(InputEvent::DeleteEmpty); + return; + } + let intent = if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor()), cx); + EditIntent::DeleteForward + } else { + EditIntent::Atomic + }; + self.undo_manager.pending_intent = Some(intent); + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + } + + pub(super) fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.text.len() == 0 && self.selected_range.is_empty() { + cx.emit(InputEvent::BackspaceEmpty); + return; + } + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + + let mut offset = self.start_of_line(); + if offset == self.cursor() { + offset = offset.saturating_sub(1); + } + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..self.cursor()))), + "", + window, + cx, + ); + self.pause_blink_cursor(cx); + } + + pub(super) fn delete_to_end_of_line( + &mut self, + _: &DeleteToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.text.len() == 0 && self.selected_range.is_empty() { + cx.emit(InputEvent::DeleteEmpty); + return; + } + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + + let mut offset = self.end_of_line(); + if offset == self.cursor() { + offset = (offset + 1).clamp(0, self.text.len()); + } + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(self.cursor()..offset))), + "", + window, + cx, + ); + self.pause_blink_cursor(cx); + } + + pub(super) fn delete_previous_word( + &mut self, + _: &DeleteToPreviousWordStart, + window: &mut Window, + cx: &mut Context, + ) { + if self.text.len() == 0 && self.selected_range.is_empty() { + cx.emit(InputEvent::BackspaceEmpty); + return; + } + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + + let offset = self.previous_start_of_word(); + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..self.cursor()))), + "", + window, + cx, + ); + self.pause_blink_cursor(cx); + } + + pub(super) fn delete_next_word( + &mut self, + _: &DeleteToNextWordEnd, + window: &mut Window, + cx: &mut Context, + ) { + if self.text.len() == 0 && self.selected_range.is_empty() { + cx.emit(InputEvent::DeleteEmpty); + return; + } + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + + let offset = self.next_end_of_word(); + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(self.cursor()..offset))), + "", + window, + cx, + ); + self.pause_blink_cursor(cx); + } + + pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { + if M::handle_context_menu_action(self, Box::new(action.clone()), window, cx) { + return; + } + + // Clear inline completion on enter (user chose not to accept it) + if M::has_inline_completion(self) { + M::clear_inline_completion(self, cx); + } + + // In multi-line mode with `submit_on_enter` enabled, a plain `Enter` + // (without Shift) is treated as submit: propagate the action and emit + // PressEnter without inserting a newline. `Shift+Enter` still inserts + // a newline. + let insert_newline = self.is_multi_line() && (!self.submit_on_enter || action.shift); + + if insert_newline { + // Get current line indent + let indent = if self.is_code_editor() { + self.indent_of_next_line() + } else { + "".to_string() + }; + + // Add newline and indent + let new_line_text = format!("\n{}", indent); + self.replace_text_in_range_silent(None, &new_line_text, window, cx); + self.pause_blink_cursor(cx); + } else { + // Single line input or submit-on-enter: just emit the event + // (e.g.: in a dialog to confirm, or a chat textarea to send). + self.undo_manager.break_transaction_coalescing(); + cx.propagate(); + } + + cx.emit(InputEvent::PressEnter { + secondary: action.secondary, + shift: action.shift, + }); + } + + pub fn clean(&mut self, window: &mut Window, cx: &mut Context) { + self.replace_text("", window, cx); + self.selected_range = (0..0).into(); + self.scroll_to(0, None, cx); + } + + pub(super) fn escape(&mut self, action: &Escape, window: &mut Window, cx: &mut Context) { + if M::handle_context_menu_action(self, Box::new(action.clone()), window, cx) { + return; + } + + // Clear inline completion on escape + if M::has_inline_completion(self) { + M::clear_inline_completion(self, cx); + return; // Consume the escape, don't propagate + } + + if self.ime_marked_range.is_some() { + self.unmark_text(window, cx); + } + + if self.clean_on_escape { + return self.clean(window, cx); + } + + cx.propagate(); + } + + /// Show the right-click context menu as a native OS menu. + pub(crate) fn handle_right_click_menu( + &mut self, + position: Point, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + if self.disabled { + return; + } + if crate::GlobalState::is_in_deferred_context(cx) { + return; + } + + if !self.selected_range.contains(offset) { + self.move_to(offset, None, cx); + } + + if self.is_code_editor() { + M::on_hover_definition(self, offset, window, cx); + } + + if let Some(handler) = self.context_menu_handler.clone() { + let capabilities = self.context_menu_capabilities(); + cx.defer_in(window, move |_, window, cx| { + handler(NativeMenu::new(), capabilities, position, window, cx); + }); + } + } + + pub(super) fn on_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + // Input has its own text selection; suppress the window-level text + // selection (Root) so it does not start a drag from here. + crate::global_state::GlobalState::suppress_text_selection(cx); + + // Clear inline completion on any mouse interaction + M::clear_inline_completion(self, cx); + + // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) + // Clear the marked range. + if let Some(ime_marked_range) = &self.ime_marked_range { + if ime_marked_range.len() == 0 { + self.ime_marked_range = None; + } + } + + self.selecting = true; + let offset = self.index_for_mouse_position(event.position); + + if M::on_click(self, event, offset, window, cx) { + return; + } + + // Triple click to select line + if event.button == MouseButton::Left && event.click_count >= 3 { + self.select_line(offset, window, cx); + return; + } + + // Double click to select word + if event.button == MouseButton::Left && event.click_count == 2 { + self.select_word(offset, window, cx); + return; + } + + // Show Mouse context menu + if event.button == MouseButton::Right { + if self.enable_context_menu { + if !self.selected_range.contains(offset) { + self.move_to(offset, None, cx); + } + self.pending_context_menu = Some((event.position, offset)); + } + return; + } + + if event.modifiers.shift { + self.select_to(offset, cx); + } else { + self.move_to(offset, None, cx) + } + } + + pub(super) fn on_mouse_up( + &mut self, + event: &MouseUpEvent, + window: &mut Window, + cx: &mut Context, + ) { + if event.button == MouseButton::Right { + if let Some((position, offset)) = self.pending_context_menu.take() { + self.handle_right_click_menu(position, offset, window, cx); + } + } + if self.selected_range.is_empty() { + self.selection_reversed = false; + } + self.selecting = false; + self.selected_word_range = None; + self.auto_scroll.stop(); + } + + pub(super) fn on_mouse_move( + &mut self, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + // Check if mouse is within bounds + let within_bounds = self + .last_bounds + .as_ref() + .map(|bounds| bounds.contains(&event.position)) + .unwrap_or(false); + + if !within_bounds { + // Clear hover when mouse leaves the input + M::clear_hover_state(self, cx); + return; + } + + // Show diagnostic popover on mouse move + let offset = self.index_for_mouse_position(event.position); + M::on_mouse_move(self, offset, event, window, cx); + + if self.is_code_editor() { + if let Some(diagnostic) = self + .mode + .diagnostics() + .and_then(|set| set.for_offset(offset)) + { + self.diagnostic_popover = Some(Rc::new(diagnostic.clone())); + cx.notify(); + } else { + self.diagnostic_popover = None; + } + } + } + + pub(super) fn on_scroll_wheel( + &mut self, + event: &ScrollWheelEvent, + window: &mut Window, + cx: &mut Context, + ) { + let line_height = self + .last_layout + .as_ref() + .map(|layout| layout.line_height) + .unwrap_or(window.line_height()); + let delta = event.delta.pixel_delta(line_height); + + let old_offset = self.scroll_handle.offset(); + self.update_scroll_offset(Some(old_offset + delta), cx); + + // Only stop propagation if the offset actually changed + if self.scroll_handle.offset() != old_offset { + cx.stop_propagation(); + } + + self.diagnostic_popover = None; + } + + pub(super) fn update_scroll_offset( + &mut self, + offset: Option>, + cx: &mut Context, + ) { + let mut offset = offset.unwrap_or(self.scroll_handle.offset()); + // In addition to left alignment, a cursor position will be reserved on the right side + let safe_x_offset = if self.text_align == TextAlign::Left { + px(0.) + } else { + -CURSOR_WIDTH + }; + + let safe_y_range = + (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.); + let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width + safe_x_offset) + .min(safe_x_offset)..px(0.); + + offset.y = if self.is_single_line() { + px(0.) + } else { + offset.y.clamp(safe_y_range.start, safe_y_range.end) + }; + offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); + self.scroll_handle.set_offset(offset); + cx.notify(); + } + + /// Scroll to make the given offset visible. + /// + /// If `direction` is Some, will keep edges at the same side. + pub(crate) fn scroll_to( + &mut self, + offset: usize, + direction: Option, + cx: &mut Context, + ) { + let Some(last_layout) = self.last_layout.as_ref() else { + return; + }; + let Some(bounds) = self.last_bounds.as_ref() else { + return; + }; + + let mut scroll_offset = self.scroll_handle.offset(); + let was_offset = scroll_offset; + let line_height = last_layout.line_height; + + let point = self.text.offset_to_point(offset); + + let row = point.row; + + // Calculate row offset by multiplying the number of lines before it with the line height + let mut row_offset_y = line_height * self.display_map.buffer_line_to_display_row(row); + + // For Right alignment use 0 margin: the cursor indicator is clamped inside bounds + // in layout_cursor, so shifting the text here would cause a first-click visual jump. + let safety_margin = match last_layout.text_align { + TextAlign::Left => RIGHT_MARGIN, + TextAlign::Right => px(0.), + TextAlign::Center => CURSOR_WIDTH, + }; + if let Some(line) = last_layout + .lines + .get(row.saturating_sub(last_layout.visible_range.start)) + { + // Check to scroll horizontally and soft wrap lines + if let Some(pos) = line.position_for_index(point.column, last_layout, false) { + let bounds_width = bounds.size.width - last_layout.line_number_width; + let col_offset_x = pos.x; + row_offset_y += pos.y; + if col_offset_x - safety_margin < -scroll_offset.x { + // If the position is out of the visible area, scroll to make it visible + scroll_offset.x = -col_offset_x + safety_margin; + } else if col_offset_x + safety_margin > -scroll_offset.x + bounds_width { + scroll_offset.x = -(col_offset_x - bounds_width + safety_margin); + } + } + } + + // Scroll the row into view. Use the same edge clearance helper as + // `TextElement::layout_cursor` so both scroll-into-view paths agree + // (a mismatch flickered on `Down` at end-of-buffer with a small + // `cursor_surrounding_lines` override). + let edge_height = if direction.is_some() && self.is_code_editor() { + super::element::cursor_surrounding_padding( + self.mode.is_auto_grow(), + self.cursor_surrounding_lines, + last_layout.visible_range.len(), + line_height, + ) + } else { + line_height + }; + if row_offset_y - edge_height + line_height < -scroll_offset.y { + // Scroll up + scroll_offset.y = -row_offset_y + edge_height - line_height; + } else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height { + // Scroll down + scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height); + } + + // Avoid necessary scroll, when it was already in the correct position. + if direction == Some(MoveDirection::Up) { + scroll_offset.y = scroll_offset.y.max(was_offset.y); + } else if direction == Some(MoveDirection::Down) { + scroll_offset.y = scroll_offset.y.min(was_offset.y); + } + + // Clamp the deferred target into the same safe range that + // `update_scroll_offset` enforces on persist, so paint never shows an + // over-scrolled frame before the post-paint clamp pulls it back. + let safe_y_min = (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.)); + scroll_offset.x = scroll_offset.x.min(px(0.)); + scroll_offset.y = scroll_offset.y.clamp(safe_y_min, px(0.)); + self.deferred_scroll_offset = Some(scroll_offset); + cx.notify(); + } + + pub(super) fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _: &mut Context, + ) { + window.show_character_palette(); + } + + pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + return; + } + + let selected_text = self.text.slice(self.selected_range).to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); + } + + pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + return; + } + + let selected_text = self.text.slice(self.selected_range).to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); + + self.undo_manager.pending_intent = Some(EditIntent::Atomic); + self.replace_text_in_range_silent(None, "", window, cx); + } + + pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(clipboard) = cx.read_from_clipboard() { + let new_text = clipboard.text().unwrap_or_default(); + self.undo_manager.pending_intent = Some(EditIntent::Atomic); + self.replace_text_in_range_silent(None, &new_text, window, cx); + self.scroll_to(self.cursor(), None, cx); + } + } + + fn push_history( + &mut self, + text: &Rope, + range: &Range, + new_text: &str, + requested_intent: Option, + selection_before: Selection, + selection_after: Option, + ) { + if self.undo_manager.is_ignoring() { + return; + } + + let range = + text.clip_offset(range.start, Bias::Left)..text.clip_offset(range.end, Bias::Right); + let old_text = text.slice(range.clone()).to_string(); + let new_range = range.start..range.start + new_text.len(); + + let intent = requested_intent.unwrap_or_else(|| { + if range.is_empty() + && old_text.is_empty() + && !new_text.is_empty() + && !new_text.contains(['\n', '\r']) + { + EditIntent::Typing + } else { + EditIntent::Atomic + } + }); + + let selection_before = match intent { + EditIntent::Backspace => Selection::new(range.end, range.end), + EditIntent::DeleteForward => Selection::new(range.start, range.start), + EditIntent::Typing | EditIntent::Atomic => selection_before, + }; + let selection_after = + selection_after.unwrap_or_else(|| Selection::new(new_range.end, new_range.end)); + + self.undo_manager.record_transaction( + Change::new( + range, + &old_text, + new_range, + new_text, + selection_before, + selection_after, + ), + intent, + ); + } + + pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { + self.undo_manager.set_ignoring(true); + if let Some(changes) = self.undo_manager.undo() { + let selection = changes.last().unwrap().selection_before; + for change in &changes { + let range_utf16 = self.range_to_utf16(&change.new_range.into()); + self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); + } + self.selected_range = selection; + } + self.undo_manager.set_ignoring(false); + } + + pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { + self.undo_manager.set_ignoring(true); + if let Some(changes) = self.undo_manager.redo() { + let selection = changes.last().unwrap().selection_after; + for change in &changes { + let range_utf16 = self.range_to_utf16(&change.old_range.into()); + self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); + } + self.selected_range = selection; + } + self.undo_manager.set_ignoring(false); + } + + /// Get byte offset of the cursor. + /// + /// The offset is the UTF-8 offset. + pub fn cursor(&self) -> usize { + if let Some(ime_marked_range) = &self.ime_marked_range { + return ime_marked_range.end; + } + + if self.selection_reversed { + self.selected_range.start + } else { + self.selected_range.end + } + } + + /// Visible row range in the last laid-out viewport, `None` before first layout. + pub fn visible_row_range(&self) -> Option> { + self.last_layout.as_ref().map(|l| l.visible_range.clone()) + } + + /// Current scroll offset of the editor viewport. + pub fn scroll_offset(&self) -> gpui::Point { + self.scroll_handle.offset() + } + + /// Set scroll offset of the editor viewport. + /// + /// The offset will be clamped to the valid range, and applied after the next layout. + pub fn set_scroll_offset(&mut self, offset: gpui::Point, cx: &mut Context) { + self.deferred_scroll_offset = Some(offset); + cx.notify(); + } + + /// Laid-out line height; `None` before first layout. + pub fn line_height(&self) -> Option { + self.last_layout.as_ref().map(|l| l.line_height) + } + + /// Returns the current selection as a byte range into the text. + /// + /// The range is empty (`start == end`) when no text is selected; in + /// that case the offset equals `cursor()`. Byte offsets are measured + /// in the underlying rope's byte units. + pub fn selected_range(&self) -> std::ops::Range { + self.selected_range.into() + } + + pub fn select_all(&mut self, _: &mut Window, cx: &mut Context) { + self.undo_manager.break_transaction_coalescing(); + self.selected_range = (0..self.text.len()).into(); + cx.notify(); + } + + /// Set the selected range using UTF-8 byte offsets. + /// + /// Non-empty ranges expand to character boundaries. Empty ranges remain empty and are + /// clipped to the preceding character boundary. + pub fn set_selected_range(&mut self, range: Range, cx: &mut Context) { + let end_bias = if range.start == range.end { + Bias::Left + } else { + Bias::Right + }; + let start = self.text.clip_offset(range.start, Bias::Left); + let end = self.text.clip_offset(range.end, end_bias); + + self.move_to(start, None, cx); + self.selection_reversed = false; + self.selected_word_range = None; + self.select_to(end, cx); + } + + pub(crate) fn index_for_mouse_position(&self, position: Point) -> usize { + // If the text is empty, always return 0 + if self.text.len() == 0 { + return 0; + } + + let (Some(bounds), Some(last_layout)) = + (self.last_bounds.as_ref(), self.last_layout.as_ref()) + else { + return 0; + }; + + let line_height = last_layout.line_height; + let line_number_width = last_layout.line_number_width; + + // TIP: About the IBeam cursor + // + // If cursor style is IBeam, the mouse mouse position is in the middle of the cursor (This is special in OS) + + // The position is relative to the bounds of the text input + // + // bounds.origin: + // + // - included the input padding. + // - included the scroll offset. + let inner_position = position - bounds.origin - point(line_number_width, px(0.)); + + let mut y_offset = last_layout.visible_top; + + // Traverse visible buffer lines (compact, no hidden entries) + for (vi, (line_layout, _buffer_line)) in last_layout + .lines + .iter() + .zip(last_layout.visible_buffer_lines.iter()) + .enumerate() + { + let line_start_offset = last_layout.visible_line_byte_offsets[vi]; + + // Calculate line origin for this display row + let line_origin = point(px(0.), y_offset); + let pos = inner_position - line_origin; + + // Return offset by use closest_index_for_x if is single line mode. + if self.is_single_line() { + let local_index = line_layout.closest_index_for_x(pos.x, last_layout); + let index = line_start_offset + local_index; + return if self.masked { + self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) + } else { + index.min(self.text.len()) + }; + } + + // Check if mouse is in this line's bounds + if let Some(local_index) = line_layout.closest_index_for_position(pos, last_layout) { + let index = line_start_offset + local_index; + return if self.masked { + self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) + } else { + index.min(self.text.len()) + }; + } else if pos.y < px(0.) { + // Mouse is above this line, return start of this line + return if self.masked { + self.text + .char_index_to_offset(line_start_offset / MASK_CHAR.len_utf8()) + } else { + line_start_offset + }; + } + + y_offset += line_layout.size(line_height).height; + } + + // Mouse is below all visible lines, return end of text + self.text.len() + } + + /// Returns a y offsetted point for the line origin. + /// Select the text from the current cursor position to the given offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. + pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { + M::clear_inline_completion(self, cx); + + let offset = offset.clamp(0, self.text.len()); + if self.selection_reversed { + self.selected_range.start = offset + } else { + self.selected_range.end = offset + }; + + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = (self.selected_range.end..self.selected_range.start).into(); + } + + // Ensure keep word selected range + if let Some(word_range) = self.selected_word_range.as_ref() { + if self.selected_range.start > word_range.start { + self.selected_range.start = word_range.start; + } + if self.selected_range.end < word_range.end { + self.selected_range.end = word_range.end; + } + } + if self.selected_range.is_empty() { + self.update_preferred_column(); + } + cx.notify() + } + + /// Unselects the currently selected text. + pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { + self.undo_manager.break_transaction_coalescing(); + let offset = self.cursor(); + self.selected_range = (offset..offset).into(); + cx.notify() + } + + #[inline] + pub(super) fn offset_from_utf16(&self, offset: usize) -> usize { + self.text.offset_utf16_to_offset(offset) + } + + #[inline] + pub(super) fn offset_to_utf16(&self, offset: usize) -> usize { + self.text.offset_to_offset_utf16(offset) + } + + #[inline] + pub(crate) fn range_to_utf16(&self, range: &Range) -> Range { + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) + } + + #[inline] + pub(super) fn range_from_utf16(&self, range_utf16: &Range) -> Range { + self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) + } + + /// If offset falls on a hidden (folded) line, clamp backward to the end of + /// the fold header line (last visible position before the fold). + fn clamp_offset_to_visible_backward(&self, offset: usize) -> usize { + let line = self.text.offset_to_point(offset).row; + if self.display_map.is_buffer_line_hidden(line) { + for fold in self.display_map.folded_ranges() { + if line > fold.start_line && line <= fold.end_line { + return self.text.line_end_offset(fold.start_line); + } + } + } + offset + } + + /// If offset falls on a hidden (folded) line, clamp forward to the start of + /// the fold end line (first visible position after the fold). + fn clamp_offset_to_visible_forward(&self, offset: usize) -> usize { + let line = self.text.offset_to_point(offset).row; + if self.display_map.is_buffer_line_hidden(line) { + for fold in self.display_map.folded_ranges() { + if line > fold.start_line && line <= fold.end_line { + return self.text.line_start_offset(fold.end_line); + } + } + } + offset + } + + pub(super) fn previous_boundary(&self, offset: usize) -> usize { + let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left); + if let Some(ch) = self.text.char_at(offset) { + if ch == '\r' { + offset -= 1; + } + } + + self.clamp_offset_to_visible_backward(offset) + } + + pub(super) fn next_boundary(&self, offset: usize) -> usize { + let mut offset = self.text.clip_offset(offset + 1, Bias::Right); + if let Some(ch) = self.text.char_at(offset) { + if ch == '\r' { + offset += 1; + } + } + + self.clamp_offset_to_visible_forward(offset) + } + + /// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible. + pub(crate) fn show_cursor(&self, window: &Window, cx: &App) -> bool { + (self.focus_handle.is_focused(window) || M::is_context_menu_open(self, cx)) + && !self.disabled + && self.blink_cursor.read(cx).visible() + && window.is_window_active() + } + + fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { + self.blink_cursor.update(cx, |cursor, cx| { + cursor.start(cx); + }); + cx.emit(InputEvent::Focus); + } + + fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { + if M::is_context_menu_open(self, cx) { + return; + } + + self.undo_manager.break_transaction_coalescing(); + + // NOTE: Do not cancel select, when blur. + // Because maybe user want to copy the selected text by AppMenuBar (will take focus handle). + + M::clear_hover_state(self, cx); + self.diagnostic_popover = None; + M::clear_inline_completion(self, cx); + self.blink_cursor.update(cx, |cursor, cx| { + cursor.stop(cx); + }); + self.clamp_number_value(window, cx); + cx.emit(InputEvent::Blur); + cx.notify(); + } + + /// Clamp the number value to the `min`/`max` range, used on blur. + /// + /// Out-of-range values are allowed while typing (e.g. `1` is an + /// intermediate state of `15` when min is 10), and clamped on blur. + fn clamp_number_value(&mut self, window: &mut Window, cx: &mut Context) { + if !self.is_single_line() { + return; + } + if !matches!(self.mask_pattern, MaskPattern::Number { .. }) { + return; + } + if self.number_min.is_none() && self.number_max.is_none() { + return; + } + + let Ok(value) = self.unmask_value().parse::() else { + return; + }; + + let clamped = match (self.number_min, self.number_max) { + (Some(min), _) if value < min => min, + (_, Some(max)) if value > max => max, + _ => return, + }; + + // The clamped value must pass the `pattern`/`validate` check, + // otherwise keep the value as is. + let new_text = clamped.to_string(); + if !self.is_valid_input(&new_text, cx) { + return; + } + + let range = self.range_to_utf16(&(0..self.text.len())); + self.replace_text_in_range_silent(Some(range), &new_text, window, cx); + } + + pub(super) fn pause_blink_cursor(&mut self, cx: &mut Context) { + self.blink_cursor.update(cx, |cursor, cx| { + cursor.pause(cx); + }); + } + + pub(super) fn on_key_down(&mut self, _: &KeyDownEvent, _: &mut Window, cx: &mut Context) { + self.pause_blink_cursor(cx); + } + + pub(super) fn on_drag_move( + &mut self, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self.text.len() == 0 { + return; + } + + if self.last_layout.is_none() { + return; + } + + if !self.focus_handle.is_focused(window) { + return; + } + + if !self.selecting { + return; + } + + self.auto_scroll.last_drag_position = Some(event.position); + let offset = self.index_for_mouse_position(event.position); + self.select_to(offset, cx); + + if !self.is_single_line() { + let delta = AutoScroll::compute_delta(event.position.y, self.input_bounds); + // Input's ScrollHandle uses negative-y-is-down; negate the positive-towards-bottom delta. + let scroll_delta = delta.map(|d| -d); + self.auto_scroll.set(scroll_delta, cx, |delta, state, cx| { + let current = state.scroll_handle.offset(); + state.update_scroll_offset(Some(point(current.x, current.y + delta)), cx); + if let Some(pos) = state.auto_scroll.last_drag_position { + let offset = state.index_for_mouse_position(pos); + state.select_to(offset, cx); + } + }); + } + } + + /// Normalize the inserted text before applying it to the input. + /// + /// For number inputs (with [`MaskPattern::Number`]), this converts + /// full-width number characters into their ASCII equivalents, + /// e.g. `12。5` -> `12.5`. + fn normalize_input<'a>(&self, new_text: &'a str) -> Cow<'a, str> { + let normalized = if matches!(self.mask_pattern, MaskPattern::Number { .. }) { + normalize_number_input(new_text) + } else { + Cow::Borrowed(new_text) + }; + + if self.is_single_line() && normalized.contains(['\n', '\r']) { + Cow::Owned(normalized.replace(['\n', '\r'], "")) + } else { + normalized + } + } + + pub(crate) fn is_valid_input(&self, new_text: &str, cx: &mut Context) -> bool { + if new_text.is_empty() { + return true; + } + + if let Some(validate) = &self.validate { + if !validate(new_text, cx) { + return false; + } + } + + if !self.mask_pattern.is_valid(new_text) { + return false; + } + + let Some(pattern) = &self.pattern else { + return true; + }; + + pattern.is_match(new_text) + } + + /// Set the mask pattern for formatting the input text. + /// + /// The pattern can contain: + /// - 9: Any digit or dot + /// - A: Any letter + /// - *: Any character + /// - Other characters will be treated as literal mask characters + /// + /// Example: "(999)999-999" for phone numbers + pub fn mask_pattern(mut self, pattern: impl Into) -> Self { + self.mask_pattern = pattern.into(); + self.mask_pattern_set = true; + if let Some(placeholder) = self.mask_pattern.placeholder() { + self.placeholder = placeholder.into(); + } + self + } + + pub fn set_mask_pattern( + &mut self, + pattern: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.mask_pattern = pattern.into(); + self.mask_pattern_set = true; + if let Some(placeholder) = self.mask_pattern.placeholder() { + self.placeholder = placeholder.into(); + } + cx.notify(); + } + + /// Apply the default numeric mask unless the caller explicitly selected a mask. + pub fn ensure_number_mask(&mut self) { + if self.mask_pattern_set { + return; + } + self.mask_pattern = MaskPattern::Number { + separator: None, + fraction: None, + }; + } + + pub(super) fn set_input_bounds(&mut self, new_bounds: Bounds, cx: &mut Context) { + let wrap_width_changed = self.input_bounds.size.width != new_bounds.size.width; + self.input_bounds = new_bounds; + + // Update display_map wrap_width if changed. + if let Some(last_layout) = self.last_layout.as_ref() { + if wrap_width_changed { + let wrap_width = if !self.soft_wrap { + // None to disable wrapping (will use Pixels::MAX) + None + } else { + last_layout.wrap_width + }; + + self.display_map.on_layout_changed(wrap_width, cx); + if self.is_multi_line() { + self.mode.update_auto_grow(&self.display_map); + } + cx.notify(); + } + } + } + + /// Return the selected portion of the text, borrowed out of the [`Rope`] + /// the state owns. + /// + /// See [`Self::selected_value`] when an owned string is wanted. + pub fn selected_text(&self) -> RopeSlice<'_> { + let range_utf16 = self.range_to_utf16(&self.selected_range.into()); + let range = self.range_from_utf16(&range_utf16); + self.text.slice(range) + } + + /// Return the rendered bounds for a UTF-8 byte range in the current input contents. + /// + /// Returns `None` when the requested range is not currently laid out or visible. + pub fn range_to_bounds(&self, range: &Range) -> Option> { + let Some(last_layout) = self.last_layout.as_ref() else { + return None; + }; + + let Some(last_bounds) = self.last_bounds else { + return None; + }; + + let (_, _, start_pos) = self.line_and_position_for_offset(range.start); + let (_, _, end_pos) = self.line_and_position_for_offset(range.end); + + let Some(start_pos) = start_pos else { + return None; + }; + let Some(end_pos) = end_pos else { + return None; + }; + + Some(Bounds::from_corners( + last_bounds.origin + start_pos, + last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), + )) + } + + /// Replace text in range in silent. + /// + /// This will not trigger any UI interaction, such as auto-completion. + pub(crate) fn replace_text_in_range_silent( + &mut self, + range_utf16: Option>, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + self.silent_replace_text = true; + self.replace_text_in_range(range_utf16, new_text, window, cx); + self.silent_replace_text = false; + } + + /// Update fold candidates from tree-sitter syntax tree (full extraction). + /// Used only on initial load or language changes. + fn update_fold_candidates(&mut self) { + if !self.mode.is_folding() { + return; + } + + let Some(highlighter_rc) = self.mode.highlighter() else { + return; + }; + + let highlighter = highlighter_rc.borrow(); + let Some(highlighter) = highlighter.as_ref() else { + return; + }; + + let fold_ranges = highlighter.fold_ranges(&self.text); + self.display_map.set_fold_candidates(fold_ranges); + } + + /// Incrementally update fold candidates after a text edit. + /// Only traverses the edited region of the syntax tree instead of the full tree. + fn update_fold_candidates_incremental(&mut self, edit_range: &Range, new_text: &str) { + if !self.mode.is_folding() { + return; + } + + let Some(highlighter_rc) = self.mode.highlighter() else { + return; + }; + + let highlighter = highlighter_rc.borrow(); + let Some(highlighter) = highlighter.as_ref() else { + return; + }; + + // The new byte range in the updated text after the edit + let new_end = edit_range.start + new_text.len(); + self.display_map.update_fold_candidates_for_edit( + |range, text| highlighter.fold_ranges_for_edit(range, text), + edit_range.start..new_end, + &self.text, + ); + } +} + +impl EntityInputHandler for InputBaseState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + adjusted_range.replace(self.range_to_utf16(&range)); + Some(self.text.slice(range).to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selected_range.into()), + reversed: false, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.ime_marked_range + .map(|range| self.range_to_utf16(&range.into())) + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.ime_marked_range = None; + self.undo_manager.commit_transaction(); + } + + /// Replace text in range. + /// + /// - If the new text is invalid, it will not be replaced. + /// - If `range_utf16` is not provided, the current selected range will be used. + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let requested_intent = self.undo_manager.pending_intent.take(); + if !self.is_editable() { + return; + } + let selection_before = self.selected_range; + + if self.blink_cursor.read(cx).visible() { + self.pause_blink_cursor(cx); + } + + // NOTE: The normalization keeps the UTF-16 length, but may change the + // UTF-8 byte length, so all the byte-offset calculations below must + // use the normalized text. + let new_text = self.normalize_input(new_text); + let new_text: &str = &new_text; + + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .or(self.ime_marked_range.map(|range| { + let range = self.range_to_utf16(&(range.start..range.end)); + self.range_from_utf16(&range) + })) + .unwrap_or(self.selected_range.into()); + + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); + + let mut new_offset = (range.start + new_text.len()).min(self.text.len()); + + // True if the mask has changed the text, e.g. regrouping the + // separators or completing a leading dot. + let mut mask_changed = false; + + if self.is_single_line() { + let pending_text = self.text.to_string(); + // Check if the new text is valid. + // + // Only reject the edit if the old text was valid, to avoid + // trapping a pre-existing invalid text (e.g. a `default_value` + // that does not conform), the user can still edit to fix it. + if !self.is_valid_input(&pending_text, cx) + && self.is_valid_input(&old_text.to_string(), cx) + { + self.text = old_text; + return; + } + + if !self.mask_pattern.is_none() { + let mask_text = self.mask_pattern.mask(&pending_text); + mask_changed = mask_text.as_str() != pending_text; + self.text = Rope::from(mask_text.as_str()); + let new_text_len = + (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); + new_offset = (range.start + new_text_len).min(mask_text.len()); + } + } + + if mask_changed { + // Masking rewrites the whole document, so ranges recorded against + // the old text no longer point at anything. + M::reset_annotations(self); + } else { + M::adjust_annotations(self, &range, new_text.len()); + } + if mask_changed { + // A segment-based history entry no longer matches the masked + // document, record a whole-document change instead, so that + // undo/redo can restore the text exactly. + self.push_history( + &old_text, + &(0..old_text.len()), + &self.text.to_string(), + Some(EditIntent::Atomic), + selection_before, + Some(Selection::new(new_offset, new_offset)), + ); + } else { + self.push_history( + &old_text, + &range, + &new_text, + requested_intent, + selection_before, + None, + ); + } + if let Some(diagnostics) = self.mode.diagnostics_mut() { + diagnostics.reset(&self.text) + } + // Adjust folds before updating wrap map: remove overlapping folds and shift others + self.display_map + .adjust_folds_for_edit(&old_text, &range, new_text); + self.display_map + .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); + + self.mode.update_highlighter::( + super::mode::HighlighterUpdate { + selected_range: &range, + old_text: &old_text, + new_text: &self.text, + change_text: &new_text, + force: true, + }, + window, + cx, + ); + + self.update_fold_candidates_incremental(&range, new_text); + M::refresh_language_features(self, window, cx); + self.selected_range = (new_offset..new_offset).into(); + self.ime_marked_range.take(); + self.update_preferred_column(); + self.update_search(cx); + if self.is_multi_line() { + self.mode.update_auto_grow(&self.display_map); + } + if !self.silent_replace_text { + M::on_text_typed(self, &range, &new_text, window, cx); + } + if self.emit_events { + cx.emit(InputEvent::Change); + } + cx.notify(); + } + + /// Mark text is the IME temporary insert on typing. + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let requested_intent = self.undo_manager.pending_intent.take(); + if !self.is_editable() { + return; + } + let selection_before = self.selected_range; + + let starts_composition = self.ime_marked_range.is_none(); + if starts_composition { + self.undo_manager.begin_transaction(); + } + + M::reset_language_features(self); + + // See the same NOTE in `replace_text_in_range`. + let new_text = self.normalize_input(new_text); + let new_text: &str = &new_text; + + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .or(self.ime_marked_range.map(|range| { + let range = self.range_to_utf16(&(range.start..range.end)); + self.range_from_utf16(&range) + })) + .unwrap_or(self.selected_range.into()); + + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); + + if self.is_single_line() { + let pending_text = self.text.to_string(); + // See the same NOTE in `replace_text_in_range`. + if !self.is_valid_input(&pending_text, cx) + && self.is_valid_input(&old_text.to_string(), cx) + { + self.text = old_text; + if starts_composition { + self.undo_manager.commit_transaction(); + } + return; + } + } + + M::adjust_annotations(self, &range, new_text.len()); + if let Some(diagnostics) = self.mode.diagnostics_mut() { + diagnostics.reset(&self.text) + } + // Adjust folds before updating wrap map: remove overlapping folds and shift others + self.display_map + .adjust_folds_for_edit(&old_text, &range, new_text); + self.display_map + .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); + + self.mode.update_highlighter::( + super::mode::HighlighterUpdate { + selected_range: &range, + old_text: &old_text, + new_text: &self.text, + change_text: &new_text, + force: true, + }, + window, + cx, + ); + + self.update_fold_candidates_incremental(&range, new_text); + M::refresh_language_features(self, window, cx); + if new_text.is_empty() { + // Cancel selection, when cancel IME input. + self.selected_range = (range.start..range.start).into(); + self.ime_marked_range = None; + } else { + self.ime_marked_range = Some((range.start..range.start + new_text.len()).into()); + self.selected_range = new_selected_range_utf16 + .as_ref() + .map(|range_utf16| { + let new_text = Rope::from(new_text); + range.start + new_text.offset_utf16_to_offset(range_utf16.start) + ..range.start + new_text.offset_utf16_to_offset(range_utf16.end) + }) + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()) + .into(); + } + if self.is_multi_line() { + self.mode.update_auto_grow(&self.display_map); + } + self.push_history( + &old_text, + &range, + new_text, + requested_intent, + selection_before, + Some(self.selected_range), + ); + if new_text.is_empty() { + self.undo_manager.commit_transaction(); + } + cx.notify(); + } + + /// Used to position IME candidates. + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + let last_layout = self.last_layout.as_ref()?; + let line_height = last_layout.line_height; + let line_number_width = last_layout.line_number_width; + let range = self.range_from_utf16(&range_utf16); + + let mut start_origin = None; + let mut end_origin = None; + let line_number_origin = point(line_number_width, px(0.)); + let mut y_offset = last_layout.visible_top; + + for (vi, line) in last_layout.lines.iter().enumerate() { + if start_origin.is_some() && end_origin.is_some() { + break; + } + + let index_offset = last_layout.visible_line_byte_offsets[vi]; + + if start_origin.is_none() { + if let Some(p) = line.position_for_index( + range.start.saturating_sub(index_offset), + last_layout, + false, + ) { + start_origin = Some(p + point(px(0.), y_offset)); + } + } + + if end_origin.is_none() { + if let Some(p) = line.position_for_index( + range.end.saturating_sub(index_offset), + last_layout, + false, + ) { + end_origin = Some(p + point(px(0.), y_offset)); + } + } + + y_offset += line.size(line_height).height; + } + + let start_origin = start_origin.unwrap_or_default(); + let mut end_origin = end_origin.unwrap_or_default(); + // Ensure at same line. + end_origin.y = start_origin.y; + + Some(Bounds::from_corners( + bounds.origin + line_number_origin + start_origin, + // + line_height for show IME panel under the cursor line. + bounds.origin + line_number_origin + point(end_origin.x, end_origin.y + line_height), + )) + } + + fn character_index_for_point( + &mut self, + point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let last_layout = self.last_layout.as_ref()?; + let line_point = self.last_bounds?.localize(&point)?; + + for (vi, line) in last_layout.lines.iter().enumerate() { + let offset = last_layout.visible_line_byte_offsets[vi]; + if let Some(utf8_index) = line.index_for_position(line_point, last_layout) { + return Some(self.offset_to_utf16(offset + utf8_index)); + } + } + + None + } +} + +impl Focusable for InputBaseState { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InputBaseState { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity(); + if self._pending_update { + self.mode.update_highlighter::( + super::mode::HighlighterUpdate { + selected_range: &(0..0), + old_text: &self.text, + new_text: &self.text, + change_text: "", + force: false, + }, + window, + cx, + ); + + self.update_fold_candidates(); + M::refresh_language_features(self, window, cx); + self._pending_update = false; + } + + let element = div() + .id("input-state") + .key_context(CONTEXT) + .track_focus(&self.focus_handle) + .when(self.is_editable(), |this| { + this.on_action(window.listener_for(&entity, InputBaseState::backspace)) + .on_action(window.listener_for(&entity, InputBaseState::delete)) + .on_action( + window.listener_for(&entity, InputBaseState::delete_to_beginning_of_line), + ) + .on_action(window.listener_for(&entity, InputBaseState::delete_to_end_of_line)) + .on_action(window.listener_for(&entity, InputBaseState::delete_previous_word)) + .on_action(window.listener_for(&entity, InputBaseState::delete_next_word)) + .on_action(window.listener_for(&entity, InputBaseState::enter)) + .on_action(window.listener_for(&entity, InputBaseState::escape)) + .on_action(window.listener_for(&entity, InputBaseState::paste)) + .on_action(window.listener_for(&entity, InputBaseState::cut)) + .on_action(window.listener_for(&entity, InputBaseState::undo)) + .on_action(window.listener_for(&entity, InputBaseState::redo)) + .when(self.is_multi_line(), |this| { + this.on_action(window.listener_for(&entity, InputBaseState::indent_inline)) + .on_action(window.listener_for(&entity, InputBaseState::outdent_inline)) + .on_action(window.listener_for(&entity, InputBaseState::indent_block)) + .on_action(window.listener_for(&entity, InputBaseState::outdent_block)) + }) + }) + .on_action(window.listener_for(&entity, InputBaseState::left)) + .on_action(window.listener_for(&entity, InputBaseState::right)) + .on_action(window.listener_for(&entity, InputBaseState::select_left)) + .on_action(window.listener_for(&entity, InputBaseState::select_right)) + .when(self.is_multi_line(), |this| { + this.on_action(window.listener_for(&entity, InputBaseState::up)) + .on_action(window.listener_for(&entity, InputBaseState::down)) + .on_action(window.listener_for(&entity, InputBaseState::select_up)) + .on_action(window.listener_for(&entity, InputBaseState::select_down)) + .on_action(window.listener_for(&entity, InputBaseState::page_up)) + .on_action(window.listener_for(&entity, InputBaseState::page_down)) + }) + .on_action(window.listener_for(&entity, InputBaseState::on_action_select_all)) + .on_action(window.listener_for(&entity, InputBaseState::select_to_start_of_line)) + .on_action(window.listener_for(&entity, InputBaseState::select_to_end_of_line)) + .on_action(window.listener_for(&entity, InputBaseState::select_to_previous_word)) + .on_action(window.listener_for(&entity, InputBaseState::select_to_next_word)) + .on_action(window.listener_for(&entity, InputBaseState::home)) + .on_action(window.listener_for(&entity, InputBaseState::end)) + .on_action(window.listener_for(&entity, InputBaseState::move_to_start)) + .on_action(window.listener_for(&entity, InputBaseState::move_to_end)) + .on_action(window.listener_for(&entity, InputBaseState::move_to_previous_word)) + .on_action(window.listener_for(&entity, InputBaseState::move_to_next_word)) + .on_action(window.listener_for(&entity, InputBaseState::select_to_start)) + .on_action(window.listener_for(&entity, InputBaseState::select_to_end)) + .on_action(window.listener_for(&entity, InputBaseState::show_character_palette)) + .on_action(window.listener_for(&entity, InputBaseState::copy)) + .on_action(window.listener_for(&entity, InputBaseState::on_action_search)) + .on_action(window.listener_for(&entity, InputBaseState::on_action_replace)) + .on_key_down(window.listener_for(&entity, InputBaseState::on_key_down)) + .on_mouse_down( + MouseButton::Left, + window.listener_for(&entity, InputBaseState::on_mouse_down), + ) + .on_mouse_down( + MouseButton::Right, + window.listener_for(&entity, InputBaseState::on_mouse_down), + ) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&entity, InputBaseState::on_mouse_up), + ) + .on_mouse_up( + MouseButton::Right, + window.listener_for(&entity, InputBaseState::on_mouse_up), + ) + .on_mouse_move(window.listener_for(&entity, InputBaseState::on_mouse_move)) + .on_scroll_wheel(window.listener_for(&entity, InputBaseState::on_scroll_wheel)) + .when(!self.disabled, |this| this.cursor_text()) + .flex_1() + .when(self.is_multi_line(), |this| this.h_full()) + .flex_grow_1() + .overflow_x_hidden() + .when(self.is_multi_line(), |this| { + this.pt(self.editor_paddings.top) + .pr(self.editor_paddings.right) + .pb(self.editor_paddings.bottom) + .pl(self.editor_paddings.left) + }) + .child(TextElement::new(entity.clone()).placeholder(self.placeholder.clone())) + .child(EditorScrollbar::new(entity.clone())); + + // Actions only one mode handles are registered by that mode, where + // `Self` is concrete enough to name its own entity type. + M::register_actions(element, &entity, window) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::theme::Theme; + use gpui::{TestAppContext, VisualTestContext}; + + use crate::input::{EditorMode, InputMode, TextareaMode}; + + struct TestRoot(Entity>); + + impl Render for TestRoot { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.0.clone()) + } + } + + struct InputView { + input: Entity>, + window_handle: gpui::WindowHandle>, + } + + /// Helper to open a state of one mode in a window for testing. + impl InputView { + fn build_with( + cx: &mut TestAppContext, + make: impl FnOnce(&mut Window, &mut Context>) -> InputBaseState + + 'static, + ) -> Self { + let mut input: Option>> = None; + + let window = cx.update(|cx| { + cx.open_window(Default::default(), |window, cx| { + // Set up the theme first + cx.set_global(Theme::default()); + // Initialize input keybindings + super::super::init(cx); + + input = Some(cx.new(|cx| make(window, cx))); + + cx.new(|_| TestRoot(input.clone().unwrap())) + }) + .unwrap() + }); + + Self { + input: input.clone().unwrap(), + window_handle: window, + } + } + } + + impl InputView { + /// An editor state, for the tests that exercise code-editor behavior. + fn new(cx: &mut TestAppContext) -> Self { + Self::build_editor(cx, |state| state) + } + + fn build_editor( + cx: &mut TestAppContext, + f: impl FnOnce(InputBaseState) -> InputBaseState + 'static, + ) -> Self { + Self::build_with(cx, move |window, cx| { + f(crate::input::EditorState::new(window, cx).language("sql")) + }) + } + } + + impl InputView { + fn build_textarea( + cx: &mut TestAppContext, + f: impl FnOnce(InputBaseState) -> InputBaseState + 'static, + ) -> Self { + Self::build_with(cx, move |window, cx| { + f(crate::input::TextareaState::new(window, cx)) + }) + } + } + + impl InputView { + /// A single-line state, the default these tests were written against. + fn build( + cx: &mut TestAppContext, + f: impl FnOnce(InputBaseState) -> InputBaseState + 'static, + ) -> Self { + Self::build_with(cx, move |window, cx| { + f(crate::input::InputState::new(window, cx)) + }) + } + } + + #[gpui::test] + fn context_menu_handler_is_deferred_and_respects_disabled(cx: &mut TestAppContext) { + use std::{cell::Cell, rc::Rc}; + cx.update(crate::init); + let input_view = InputView::new(cx); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + let calls = Rc::new(Cell::new(0usize)); + let items = Rc::new(Cell::new(0usize)); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + let calls2 = calls.clone(); + let items2 = items.clone(); + state.on_context_menu(Rc::new(move |menu, _, _, _, _| { + calls2.set(calls2.get() + 1); + items2.set(menu.items.len()); + })); + state.handle_right_click_menu(point(px(0.), px(0.)), 0, window, cx); + }) + }); + assert_eq!(calls.get(), 1); + assert_eq!(items.get(), 0); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.disabled = true; + state.handle_right_click_menu(point(px(0.), px(0.)), 0, window, cx); + }) + }); + assert_eq!(calls.get(), 1); + } + + #[gpui::test] + fn test_readonly_rejects_user_edits_only(cx: &mut TestAppContext) { + let input_view = InputView::new(cx); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("hello", window, cx); + state.set_readonly(true, cx); + }); + }); + + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert!(!state.is_editable()); + assert!(!state.is_replaceable()); + }); + }); + + // Typing (and IME) goes through the input handler, it must be rejected. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, " world", window, cx); + state.replace_and_mark_text_in_range(None, "あ", None, window, cx); + }); + }); + cx.update(|_, cx| { + input.read_with(cx, |state, _| assert_eq!(state.value(), "hello")); + }); + + // The programmatic APIs are not limited by the readonly mode. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.insert(" world", window, cx); + state.set_value("changed", window, cx); + }); + }); + cx.update(|_, cx| { + input.read_with(cx, |state, _| assert_eq!(state.value(), "changed")); + }); + + // And the user can edit again after leaving the readonly mode. + // The caret is at the start, because `set_value` has reset the selection. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_readonly(false, cx); + state.replace_text_in_range(None, "!", window, cx); + }); + }); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert!(state.is_editable()); + assert_eq!(state.value(), "!changed"); + }); + }); + } + + /// Regression test: `scroll_to` at end-of-buffer must produce a deferred + /// scroll target within the safe scroll range, so the painted frame + /// matches what `update_scroll_offset` persists (no jitter). A small + /// `cursor_surrounding_lines` override used to mismatch the hardcoded + /// 3-line edge clearance in `scroll_to`, overshooting `safe_y_min`. + #[gpui::test] + fn test_scroll_to_eob_does_not_overshoot_safe_range(cx: &mut TestAppContext) { + let input_view = InputView::new(cx); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // JetBrains-style: 1 trailing empty row + 1-line cursor surrounding. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_scroll_beyond_last_line(Some(1), window, cx); + state.set_cursor_surrounding_lines(Some(1), window, cx); + let text: String = (1..=50) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + state.set_value(text, window, cx); + }); + }); + cx.run_until_parked(); + + // Sanity: paint populated `scroll_size` and `input_bounds` — without + // these, `safe_y_min` below collapses to 0 and the assertion is vacuous. + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert!( + state.scroll_size.height > px(0.), + "scroll_size not populated by initial paint" + ); + assert!( + state.input_bounds.size.height > px(0.), + "input_bounds not populated by initial paint" + ); + }); + }); + + // Move cursor to end with downward direction — same code path as a + // `Down` keystroke at EOB. `scroll_to` runs synchronously inside + // `move_to`; inspect `deferred_scroll_offset` in the same closure + // before the next paint consumes and clears it. + cx.update(|_, cx| { + input.update(cx, |state, cx| { + let end = state.text.len(); + state.move_to(end, Some(MoveDirection::Down), cx); + + let deferred = state + .deferred_scroll_offset + .expect("scroll_to should populate deferred_scroll_offset"); + let safe_y_min = + (-state.scroll_size.height + state.input_bounds.size.height).min(px(0.)); + + assert!( + deferred.y >= safe_y_min, + "deferred_scroll_offset.y = {:?} below safe_y_min = {:?} \ + — paint would jitter (Bug C regression)", + deferred.y, + safe_y_min, + ); + }); + }); + } + + #[gpui::test] + fn test_number_step(cx: &mut TestAppContext) { + let input = InputView::build(cx, |state| state).input; + + cx.update(|cx| { + input.update(cx, |_state, cx| { + assert_eq!( + NumberStep::from(5.).value(123., StepAction::Increment, cx), + 5. + ); + + // The step can differ by direction at a boundary: at 1.0 it + // is 0.1 going down and 0.5 going up. + let step = NumberStep::by_value(|value, action, _cx| { + let below = match action { + StepAction::Increment => value < 1.0, + StepAction::Decrement => value <= 1.0, + }; + if below { 0.1 } else { 0.5 } + }); + assert_eq!(step.value(0.5, StepAction::Increment, cx), 0.1); + assert_eq!(step.value(1.0, StepAction::Increment, cx), 0.5); + assert_eq!(step.value(1.0, StepAction::Decrement, cx), 0.1); + assert_eq!(step.value(2.0, StepAction::Decrement, cx), 0.5); + }); + }); + } + + #[gpui::test] + fn test_number_input_normalization(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| { + state.mask_pattern(MaskPattern::Number { + separator: None, + fraction: None, + }) + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // Full-width digits and the ideographic full stop are normalized, + // and the cursor is at the end (in normalized bytes, not the + // original 12 bytes). + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "12。5", window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert_eq!(state.value(), "12.5"); + let cursor: Range = state.selected_range.into(); + assert_eq!(cursor, 4..4); + }); + }); + + // Non-numeric input is rejected. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "abc", window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert_eq!(state.value(), "12.5"); + }); + }); + + // A bare leading dot is kept as-is (normalized from the ideographic + // full stop), not completed to "0.", so it stays editable. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + let range = state.range_to_utf16(&(0..state.text.len())); + state.replace_text_in_range(Some(range), "。", window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert_eq!(state.value(), "."); + let cursor: Range = state.selected_range.into(); + assert_eq!(cursor, 1..1); + }); + }); + } + + #[gpui::test] + fn test_number_input_normalization_with_separator(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| { + state.mask_pattern(MaskPattern::Number { + separator: Some(','), + fraction: Some(2), + }) + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "1234", window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert_eq!(state.value(), "1,234"); + assert_eq!(state.unmask_value(), "1234"); + }); + }); + } + + #[gpui::test] + fn test_number_input_clamp_on_blur(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| { + state + .mask_pattern(MaskPattern::Number { + separator: None, + fraction: None, + }) + .min(10.) + .max(100.) + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // Out-of-range values are allowed while typing, and clamped on blur. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "1000", window, cx); + assert_eq!(state.value(), "1000"); + state.clamp_number_value(window, cx); + assert_eq!(state.value(), "100"); + + let range = state.range_to_utf16(&(0..state.text.len())); + state.replace_text_in_range(Some(range), "1", window, cx); + assert_eq!(state.value(), "1"); + state.clamp_number_value(window, cx); + assert_eq!(state.value(), "10"); + }); + }); + } + + #[gpui::test] + fn test_number_input_undo_with_mask(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| { + state.mask_pattern(MaskPattern::Number { + separator: Some(','), + fraction: None, + }) + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // When the mask changes the text (regrouping separators), a + // whole-document change is recorded, so undo/redo can restore it. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "1234", window, cx); + assert_eq!(state.value(), "1,234"); + state.replace_text_in_range(None, "5", window, cx); + assert_eq!(state.value(), "12,345"); + + // Each whole-document mask rewrite is an atomic undo step. + // Before the whole-document history fix, undo produced a + // corrupted value like "1,2344". + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "1,234"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "1,234"); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "12,345"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_coalesces_adjacent_typing_transactions(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.replace_text_in_range(None, "b", window, cx); + assert_eq!(state.value(), "ab"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_cursor_movement_splits_typing(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.replace_text_in_range(None, "b", window, cx); + state.left(&MoveLeft, window, cx); + state.replace_text_in_range(None, "x", window, cx); + assert_eq!(state.value(), "axb"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "ab"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_splits_backward_and_forward_delete(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("abcd", window, cx); + state.set_selected_range(2..2, cx); + state.backspace(&Backspace, window, cx); + state.delete(&Delete, window, cx); + assert_eq!(state.value(), "ad"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "acd"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abcd"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_coalesces_directional_character_deletes(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("abcd", window, cx); + state.backspace(&Backspace, window, cx); + state.backspace(&Backspace, window, cx); + assert_eq!(state.value(), "ab"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abcd"); + assert_eq!(state.selected_range(), 4..4); + + state.set_value("abcd", window, cx); + state.set_selected_range(1..1, cx); + state.delete(&Delete, window, cx); + state.delete(&Delete, window, cx); + assert_eq!(state.value(), "ad"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abcd"); + assert_eq!(state.selected_range(), 1..1); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_atomic_paste_isolated_from_typing(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string("P".to_string())); + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.paste(&Paste, window, cx); + state.replace_text_in_range(None, "b", window, cx); + assert_eq!(state.value(), "aPb"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "aP"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "a"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_programmatic_insert_is_atomic(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.insert("P", window, cx); + state.replace_text_in_range(None, "b", window, cx); + assert_eq!(state.value(), "aPb"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "aP"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "a"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_selection_round_trip_splits_typing(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.replace_text_in_range(None, "b", window, cx); + state.select_all(window, cx); + state.unselect(window, cx); + state.replace_text_in_range(None, "c", window, cx); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "ab"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_enter_is_atomic(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.enter( + &Enter { + secondary: false, + shift: false, + }, + window, + cx, + ); + state.replace_text_in_range(None, "b", window, cx); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "a\n"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "a"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_single_line_return_commits_the_typing_session(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + for part in ["a", "b", "c"] { + state.replace_text_in_range(None, part, window, cx); + } + state.enter( + &Enter { + secondary: false, + shift: false, + }, + window, + cx, + ); + for part in ["d", "e", "f"] { + state.replace_text_in_range(None, part, window, cx); + } + assert_eq!(state.value(), "abcdef"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abc"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_submit_on_enter_commits_the_textarea_session(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state.submit_on_enter(true)); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "before submit", window, cx); + state.enter( + &Enter { + secondary: false, + shift: false, + }, + window, + cx, + ); + state.replace_text_in_range(None, " after submit", window, cx); + assert_eq!(state.value(), "before submit after submit"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "before submit"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_blur_commits_the_typing_session(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "before blur", window, cx); + state.on_blur(window, cx); + state.on_focus(window, cx); + state.replace_text_in_range(None, " after focus", window, cx); + assert_eq!(state.value(), "before blur after focus"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "before blur"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_keeps_rapid_lines_in_distinct_transactions(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + let enter = Enter { + secondary: false, + shift: false, + }; + state.replace_text_in_range(None, "a", window, cx); + state.enter(&enter, window, cx); + state.replace_text_in_range(None, "b", window, cx); + state.enter(&enter, window, cx); + state.replace_text_in_range(None, "c", window, cx); + assert_eq!(state.value(), "a\nb\nc"); + + for expected in ["a\nb\n", "a\nb", "a\n", "a", ""] { + state.undo(&Undo, window, cx); + assert_eq!(state.value(), expected); + } + }); + }); + } + + #[gpui::test] + fn test_undo_manager_coalesces_long_unicode_typing_without_a_timer(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + let parts = [ + "The ", + "quick ", + "brown fox, ", + "你好,世界 ", + "🦀 jumps over 13 lazy dogs.", + ]; + let expected = parts.concat(); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + for part in parts { + state.replace_text_in_range(None, part, window, cx); + } + assert_eq!(state.value(), expected); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), expected); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_long_multiline_sequence_has_structural_boundaries( + cx: &mut TestAppContext, + ) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + let enter = Enter { + secondary: false, + shift: false, + }; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + for (index, line) in [ + "first line with punctuation!", + "第二行包含 Unicode 🦀", + "third line has several words", + ] + .into_iter() + .enumerate() + { + for chunk in line.split_inclusive(' ') { + state.replace_text_in_range(None, chunk, window, cx); + } + if index < 2 { + state.enter(&enter, window, cx); + } + } + + assert_eq!( + state.value(), + "first line with punctuation!\n第二行包含 Unicode 🦀\nthird line has several words" + ); + state.undo(&Undo, window, cx); + assert_eq!( + state.value(), + "first line with punctuation!\n第二行包含 Unicode 🦀\n" + ); + state.undo(&Undo, window, cx); + assert_eq!( + state.value(), + "first line with punctuation!\n第二行包含 Unicode 🦀" + ); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "first line with punctuation!\n"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_cut_and_repeated_pastes_are_distinct_transactions( + cx: &mut TestAppContext, + ) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "alpha beta gamma", window, cx); + state.set_selected_range(6..10, cx); + state.cut(&Cut, window, cx); + assert_eq!(state.value(), "alpha gamma"); + + state.paste(&Paste, window, cx); + state.paste(&Paste, window, cx); + assert_eq!(state.value(), "alpha betabeta gamma"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "alpha beta gamma"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "alpha gamma"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "alpha beta gamma"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_word_and_line_deletes_do_not_coalesce(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("one two three\nfour five", window, cx); + state.set_selected_range(13..13, cx); + state.delete_previous_word(&DeleteToPreviousWordStart, window, cx); + assert_eq!(state.value(), "one two \nfour five"); + state.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(state.value(), "one two four five"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "one two \nfour five"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "one two three\nfour five"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_multiline_replacement_is_one_atomic_transaction(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "before", window, cx); + state.set_selected_range(0..6, cx); + state.replace_text_in_range(None, "line one\nline two\n第三行", window, cx); + assert_eq!(state.value(), "line one\nline two\n第三行"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "before"); + assert_eq!(state.selected_range(), 0..6); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "line one\nline two\n第三行"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_composition_isolated_from_long_typing(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "prefix ", window, cx); + state.replace_and_mark_text_in_range(None, "n", None, window, cx); + state.replace_and_mark_text_in_range(None, "ni", None, window, cx); + state.replace_and_mark_text_in_range(None, "你", None, window, cx); + state.unmark_text(window, cx); + state.replace_text_in_range(None, " suffix", window, cx); + assert_eq!(state.value(), "prefix 你 suffix"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "prefix 你"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "prefix "); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_selected_replacement_is_atomic(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "abc", window, cx); + state.set_selected_range(1..2, cx); + state.replace_text_in_range(None, "X", window, cx); + state.replace_text_in_range(None, "z", window, cx); + assert_eq!(state.value(), "aXzc"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "aXc"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abc"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_number_input_leading_dot_editable(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| { + state.mask_pattern(MaskPattern::Number { + separator: None, + fraction: None, + }) + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "1.2", window, cx); + + // Delete the integer part "1": the value keeps the leading dot + // (".2"), not completed to "0.2", so the digits before the dot + // stay editable. + let range = state.range_to_utf16(&(0..1)); + state.replace_text_in_range(Some(range), "", window, cx); + assert_eq!(state.value(), ".2"); + let cursor: Range = state.selected_range.into(); + assert_eq!(cursor, 0..0); + + // The user can type a new integer part. + state.replace_text_in_range(Some(0..0), "3", window, cx); + assert_eq!(state.value(), "3.2"); + }); + }); + } + + #[gpui::test] + fn test_number_input_escape_invalid_text(cx: &mut TestAppContext) { + // A pre-existing invalid text (e.g. a `default_value` that does not + // conform) must not trap the user, the edit is allowed to fix it. + let input_view = InputView::build(cx, |state| { + state + .mask_pattern(MaskPattern::Number { + separator: None, + fraction: None, + }) + .default_value("1,234") + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + // Delete the last char, the pending text "1,23" is still + // invalid, but the edit is allowed since the old text was + // already invalid. + let range = state.range_to_utf16(&(4..5)); + state.replace_text_in_range(Some(range), "", window, cx); + assert_eq!(state.value(), "1,23"); + + // Once the text becomes valid, the validation works as usual. + let range = state.range_to_utf16(&(1..2)); + state.replace_text_in_range(Some(range), "", window, cx); + assert_eq!(state.value(), "123"); + state.replace_text_in_range(None, "a", window, cx); + assert_eq!(state.value(), "123"); + }); + }); + } + + /// After `set_value` on a single-line input the caret sits at the end (like + /// HTML ``), yet the view is scrolled back to the start so a long + /// value shows its beginning instead of its tail. + #[gpui::test] + fn test_set_value_single_line_caret_at_end_view_at_start(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // Long enough to overflow any reasonable single-line input width. + let value = format!("https://example.com/v1/users?{}", "x=1&".repeat(120)); + let len = value.len(); + + // Right after `set_value`, before the next paint consumes the deferred + // offset: caret is at the end, and the view is forced back to the start. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value(value.clone(), window, cx); + + assert_eq!( + state.selected_range, + Selection::new(len, len), + "single-line caret should be at the end after set_value" + ); + assert_eq!( + state.deferred_scroll_offset, + Some(point(px(0.), px(0.))), + "the view should be forced back to the start" + ); + }); + }); + + // After a paint, the steady-state view stays at the start (x == 0) even + // though the caret is at the far end. + cx.run_until_parked(); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert!( + state.scroll_size.width > state.input_bounds.size.width, + "value must overflow the input width or this test is vacuous" + ); + assert_eq!( + state.scroll_handle.offset().x, + px(0.), + "long value should display from its start, not its tail" + ); + }); + }); + } + + /// `replace_all` on a single-line input replaces the text, puts the + /// caret at the end, and — like `set_value` — snaps the view back to the + /// start so a long value shows its beginning instead of its tail. + #[gpui::test] + fn test_replace_all_single_line(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // Long enough to overflow any reasonable single-line input width. + let value = format!("https://example.com/v1/users?{}", "x=1&".repeat(120)); + let len = value.len(); + + // Right after `replace_all`, before the next paint consumes the + // deferred offset: caret is at the end, and the view is forced back + // to the start. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("hello", window, cx); + state.replace_all(value.clone(), window, cx); + assert_eq!(state.value(), value); + assert_eq!( + state.selected_range, + Selection::new(len, len), + "single-line caret should be at the end after replace_all" + ); + assert_eq!( + state.scroll_handle.offset(), + point(px(0.), px(0.)), + "the scroll offset should be reset to the start" + ); + assert_eq!( + state.deferred_scroll_offset, + Some(point(px(0.), px(0.))), + "single-line should set a deferred scroll offset to keep the start visible" + ); + }); + }); + + // After a paint, the steady-state view stays at the start (x == 0) + // even though the caret is at the far end. + cx.run_until_parked(); + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert!( + state.scroll_size.width > state.input_bounds.size.width, + "value must overflow the input width or this test is vacuous" + ); + assert_eq!( + state.scroll_handle.offset().x, + px(0.), + "long value should display from its start, not its tail" + ); + }); + }); + } + + #[gpui::test] + fn test_single_line_removes_newlines(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state.default_value("default\nvalue")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + assert_eq!(state.value(), "defaultvalue"); + + state.set_value("first\nsecond\r\nthird\rfourth", window, cx); + assert_eq!(state.value(), "firstsecondthirdfourth"); + + state.set_value("", window, cx); + state.insert("a\nb", window, cx); + assert_eq!(state.value(), "ab"); + }); + + cx.write_to_clipboard(ClipboardItem::new_string("a\r\nb\nc\rd".to_string())); + input.update(cx, |state, cx| { + state.set_value("", window, cx); + state.paste(&Paste, window, cx); + assert_eq!(state.value(), "abcd"); + }); + }); + + cx.run_until_parked(); + } + + /// `replace_all` on a multi-line (non-code-editor) input clears the + /// selection to `0..0` and resets the scroll offset, but does not set a + /// deferred scroll offset (single-line only). + #[gpui::test] + fn test_replace_all_multi_line(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("foo\nbar", window, cx); + state.replace_all("baz\nqux", window, cx); + assert_eq!(state.value(), "baz\nqux"); + assert_eq!( + state.selected_range, + Selection::new(0, 0), + "multi-line selection should be cleared after replace_all" + ); + assert_eq!( + state.scroll_handle.offset(), + point(px(0.), px(0.)), + "the scroll offset should be reset to the start" + ); + assert!( + state.deferred_scroll_offset.is_none(), + "multi-line should not set a deferred scroll offset" + ); + }); + }); + } + + /// Unlike `set_value`, `replace_all` records the change so the user can + /// undo it back to the previous text and redo to the new text. + #[gpui::test] + fn test_replace_all_preserves_undo_history(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + // Seed with a value and clear history so the baseline is clean. + state.set_value("first", window, cx); + assert!( + !state.undo_manager.has_undos(), + "history should be empty after set_value" + ); + + // replace_all records a single undoable change. + state.replace_all("second", window, cx); + assert_eq!(state.value(), "second"); + assert!( + state.undo_manager.has_undos(), + "replace_all should record an undo step" + ); + + // Undo restores the previous text. + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "first"); + + // Redo reapplies the replacement. + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "second"); + }); + }); + } + + /// `replace_all` on a code editor marks a pending update and resets LSP + /// state, so diagnostics/completions refresh against the new text. + #[gpui::test] + fn test_replace_all_code_editor(cx: &mut TestAppContext) { + let input_view = InputView::new(cx); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + // Plant a pending-update flag and some LSP state to verify reset. + state.set_value("select 1", window, cx); + state._pending_update = false; + + state.replace_all("select 2", window, cx); + assert_eq!(state.value(), "select 2"); + assert!( + state._pending_update, + "replace_all on a code editor should request a pending update" + ); + }); + }); + } + + #[gpui::test] + fn test_set_selected_range(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state.default_value("hello world")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|_, cx| { + input.update(cx, |s, cx| { + s.set_selected_range(0..5, cx); + assert_eq!(s.selected_range(), 0..5); + assert_eq!(s.selected_text().to_string(), "hello"); + + s.set_selected_range(6..11, cx); + assert_eq!(s.selected_text().to_string(), "world"); + + // clamped + collapsed + s.set_selected_range(100..100, cx); + assert_eq!(s.selected_range(), 11..11); + }); + }); + } + + #[gpui::test] + fn test_set_selected_range_clips_to_utf8_boundaries(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state.default_value("éx")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_selected_range(0..1, cx); + assert_eq!(state.selected_range(), 0..2); + state.copy(&Copy, window, cx); + + state.set_selected_range(1..1, cx); + assert_eq!(state.selected_range(), 0..0); + }); + }); + } + + #[gpui::test] + fn test_ime_selection_is_relative_to_replacement_start(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state.default_value("你好 ")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_selected_range(7..7, cx); + state.replace_and_mark_text_in_range(None, "s", Some(1..1), window, cx); + state.replace_and_mark_text_in_range(None, "sh", Some(2..2), window, cx); + + assert_eq!(state.value(), "你好 sh"); + assert_eq!(state.selected_range(), 9..9); + assert_eq!(state.ime_marked_range, Some((7..9).into())); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_composition_is_one_undo_group(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("a", window, cx); + state.replace_and_mark_text_in_range(None, "s", None, window, cx); + state.replace_and_mark_text_in_range(None, "sh", None, window, cx); + state.replace_text_in_range(None, "是", window, cx); + assert_eq!(state.value(), "a是"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "a"); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "a是"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_composition_cancel_leaves_no_entry(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("a", window, cx); + state.replace_and_mark_text_in_range(None, "s", None, window, cx); + state.replace_and_mark_text_in_range(None, "", None, window, cx); + + assert_eq!(state.value(), "a"); + assert!(!state.undo_manager.has_undos()); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_selection_restored_by_undo_and_redo(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("abc", window, cx); + state.set_selected_range(1..2, cx); + state.replace_text_in_range(None, "X", window, cx); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abc"); + assert_eq!(state.selected_range(), 1..2); + + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "aXc"); + assert_eq!(state.selected_range(), 2..2); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_forward_delete_restores_cursor(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("abc", window, cx); + state.set_selected_range(1..1, cx); + state.delete(&Delete, window, cx); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abc"); + assert_eq!(state.selected_range(), 1..1); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_selection_movement_preserves_redo(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "ab", window, cx); + state.undo(&Undo, window, cx); + state.set_selected_range(0..0, cx); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "ab"); + + state.undo(&Undo, window, cx); + state.replace_text_in_range(None, "x", window, cx); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "x"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_noop_edit_preserves_redo(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.undo(&Undo, window, cx); + state.backspace(&Backspace, window, cx); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "a"); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_noop_edit_breaks_coalescing_without_clearing_history( + cx: &mut TestAppContext, + ) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "alpha", window, cx); + state.replace_text_in_range(None, "", window, cx); + state.replace_text_in_range(None, "beta", window, cx); + assert_eq!(state.value(), "alphabeta"); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "alpha"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); + }); + }); + } + + #[gpui::test] + fn test_undo_manager_masked_redo_restores_actual_cursor(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| { + state.mask_pattern(MaskPattern::Number { + separator: Some(','), + fraction: None, + }) + }); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("12345", window, cx); + state.set_selected_range(2..2, cx); + state.replace_text_in_range(None, "9", window, cx); + let selection_after_edit = state.selected_range(); + assert_ne!(selection_after_edit.end, state.value().len()); + + state.undo(&Undo, window, cx); + state.redo(&Redo, window, cx); + assert_eq!(state.selected_range(), selection_after_edit); + }); + }); + } + + /// Losing focus hides the hover popover but keeps the decorations. + /// + /// Both used to be dropped by one call, so clicking away threw away + /// decorations the application had installed and never asked to remove. + #[gpui::test] + fn test_blur_keeps_decorations(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("select 1", window, cx); + let _collection = state.create_decorations_collection( + vec![crate::input::TextDecoration::new( + 0..6, + gpui::HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }, + )], + cx, + ); + state.present_hover( + 0..6, + lsp_types::Hover { + contents: lsp_types::HoverContents::Scalar( + lsp_types::MarkedString::String("docs".into()), + ), + range: None, + }, + cx, + ); + assert!(state.hover_popover().is_some()); + + state.on_blur(window, cx); + + assert!( + state.hover_popover().is_none(), + "blur should hide the hover popover" + ); + let decorations = state.extras.decoration_layers(); + assert!( + decorations.iter().any(|layer| !layer.is_empty()), + "blur must not discard decorations" + ); + }); + }); + } + + /// The mode marker is the only source of truth for the kind of input. + /// + /// An auto-growing textarea capped at one row used to report itself as + /// single-line, because the answer was derived from the row counts. + #[gpui::test] + fn test_kind_does_not_follow_the_row_count(cx: &mut TestAppContext) { + let view = InputView::build_textarea(cx, |state| state.auto_grow(1, 1)); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + view.input.read_with(&mut cx, |state, _| { + assert!(state.is_multi_line()); + assert!(!state.is_single_line()); + assert!(!state.is_code_editor()); + }); + } + + /// Soft wrap is on by default, for every mode that can wrap. + /// + /// The default lives in the shared constructor, where a mode-specific + /// `new` can silently fail to restore it; this pins it down. + #[gpui::test] + fn test_soft_wrap_is_enabled_by_default(cx: &mut TestAppContext) { + let textarea = InputView::build_textarea(cx, |state| state); + let mut textarea_cx = VisualTestContext::from_window(textarea.window_handle.into(), cx); + textarea + .input + .read_with(&mut textarea_cx, |state, _| assert!(state.soft_wrap)); + + let editor = InputView::::new(cx); + let mut editor_cx = VisualTestContext::from_window(editor.window_handle.into(), cx); + editor + .input + .read_with(&mut editor_cx, |state, _| assert!(state.soft_wrap)); + } +} + +/// Methods that only a single-line input offers. +impl InputBaseState { + /// Create a single-line text input state. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + Self::new_in_mode(window, cx) + } + + /// Set a custom step function of the [`super::NumberInput`]. + /// + /// The `f` receives the current value and the [`StepAction`], and returns + /// the step to apply, so the step can vary with the value. + /// + /// # Example + /// + /// ```ignore + /// // At the boundary 1.0 the step is 0.1 going down and 0.5 going up. + /// InputState::new(window, cx).step_by(|value, action, _cx| match action { + /// StepAction::Increment => if value < 1.0 { 0.1 } else { 0.5 }, + /// StepAction::Decrement => if value <= 1.0 { 0.1 } else { 0.5 }, + /// }) + /// ``` + pub fn step_by(mut self, f: impl Fn(f64, StepAction, &mut App) -> f64 + 'static) -> Self { + self.number_step = Some(NumberStep::by_value(f)); + self + } + + /// Set with password masked state. + pub fn masked(mut self, masked: bool) -> Self { + self.masked = masked; + self + } + + /// Set the password masked state of the input field. + pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { + self.masked = masked; + cx.notify(); + } + + /// Set the regular expression pattern of the input field. + pub fn pattern(mut self, pattern: regex::Regex) -> Self { + self.pattern = Some(pattern); + self + } + + /// Set the regular expression pattern of the input field with reference. + pub fn set_pattern( + &mut self, + pattern: regex::Regex, + _window: &mut Window, + _cx: &mut Context, + ) { + self.pattern = Some(pattern); + } + + /// Set the validation function of the input field. + pub fn validate(mut self, f: impl Fn(&str, &mut App) -> bool + 'static) -> Self { + self.validate = Some(Box::new(f)); + self + } + + pub fn set_validator( + &mut self, + validate: impl Fn(&str, &mut App) -> bool + 'static, + _cx: &mut Context, + ) { + self.validate = Some(Box::new(validate)); + } + + /// Set the step value of the [`super::NumberInput`] for increment/decrement. + /// + /// If any of `step`, `min`, `max` is set, the [`super::NumberInput`] will + /// update the value internally (step by `step`, default 1, clamp to the + /// `min`/`max` range and emit [`InputEvent::Change`]) instead of emitting + /// [`super::NumberInputEvent::Step`]. + /// + /// See also [`Self::step_by`] to calculate the step value + /// based on the current value. + pub fn step(mut self, step: impl Into) -> Self { + self.number_step = Some(step.into()); + self + } + + /// Set the minimum value of the [`super::NumberInput`]. + /// + /// The value will be clamped to the minimum value on stepping and on + /// blur (only if the clamped value passes the `pattern`/`validate` check). + /// See also [`Self::step`]. + pub fn min(mut self, min: f64) -> Self { + self.number_min = Some(min); + self + } + + /// Set the maximum value of the [`super::NumberInput`]. + /// + /// The value will be clamped to the maximum value on stepping and on + /// blur (only if the clamped value passes the `pattern`/`validate` check). + /// See also [`Self::step`]. + pub fn max(mut self, max: f64) -> Self { + self.number_max = Some(max); + self + } + + /// Update the step value after construction, `None` to fall back to + /// emitting [`super::NumberInputEvent::Step`] (if `min`, `max` are unset). + /// + /// See [`Self::step`] and [`Self::step_by`]. + pub fn set_step( + &mut self, + step: impl Into>, + _: &mut Window, + _: &mut Context, + ) { + self.number_step = step.into(); + } + + /// Update the minimum value after construction. See [`Self::min`]. + pub fn set_min(&mut self, min: Option, _: &mut Window, _: &mut Context) { + self.number_min = min; + } + + /// Update the maximum value after construction. See [`Self::max`]. + pub fn set_max(&mut self, max: Option, _: &mut Window, _: &mut Context) { + self.number_max = max; + } + + /// Set true to show spinner at the input right. + pub fn set_loading(&mut self, loading: bool, _: &mut Window, cx: &mut Context) { + self.loading = loading; + cx.notify(); + } +} + +/// Methods shared by the two multi-line modes, and reachable on neither a +/// single-line input nor anything else. +impl InputBaseState { + /// Set this input is searchable, default is false (Default true for Code Editor). + #[doc(hidden)] + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; + self + } + + pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { + self.searchable = searchable; + cx.notify(); + } + + /// Set the soft wrap mode, default is true. + #[doc(hidden)] + pub fn soft_wrap(mut self, wrap: bool) -> Self { + self.soft_wrap = wrap; + self + } + + /// Update the soft wrap mode, default is true. + pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context) { + self.soft_wrap = wrap; + if wrap { + let wrap_width = self + .last_layout + .as_ref() + .and_then(|b| b.wrap_width) + .unwrap_or(self.input_bounds.size.width); + + self.display_map.on_layout_changed(Some(wrap_width), cx); + + // Reset scroll to left 0 + let mut offset = self.scroll_handle.offset(); + offset.x = px(0.); + self.scroll_handle.set_offset(offset); + } else { + self.display_map.on_layout_changed(None, cx); + } + cx.notify(); + } + + /// Set how soft-wrapped continuation lines are indented, default is [`WrappingIndent::Same`] + #[doc(hidden)] + pub fn wrapping_indent(mut self, wrapping_indent: WrappingIndent) -> Self { + self.wrapping_indent = wrapping_indent; + self + } + + /// Update how soft-wrapped continuation lines are indented. + pub fn set_wrapping_indent( + &mut self, + wrapping_indent: WrappingIndent, + _: &mut Window, + cx: &mut Context, + ) { + self.wrapping_indent = wrapping_indent; + self.display_map.set_wrapping_indent(wrapping_indent, cx); + cx.notify(); + } +} + +/// Methods that only ordinary multi-line text offers. +impl InputBaseState { + /// Create a multi-line text state. + /// + /// Being multi-line is carried by the mode, not by the layout, so the + /// default plain-text layout needs no adjustment here. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + Self::new_in_mode(window, cx) + } + + pub fn set_auto_grow(&mut self, min_rows: usize, max_rows: usize, cx: &mut Context) { + self.mode = LayoutMode::auto_grow(min_rows, max_rows.max(min_rows)); + cx.notify(); + } + + /// Set the number of rows for the multi-line Textarea. + /// + /// This is only used when `multi_line` is set to true. + /// + /// default: 2 + #[doc(hidden)] + pub fn rows(mut self, rows: usize) -> Self { + match &mut self.mode { + LayoutMode::PlainText { rows: r, .. } | LayoutMode::CodeEditor { rows: r, .. } => { + *r = rows + } + LayoutMode::AutoGrow { + max_rows: max_r, + rows: r, + .. + } => { + *r = rows; + *max_r = rows; + } + } + self + } + + pub fn set_rows(&mut self, rows: usize, cx: &mut Context) { + match &mut self.mode { + LayoutMode::PlainText { rows: value, .. } + | LayoutMode::CodeEditor { rows: value, .. } => *value = rows, + LayoutMode::AutoGrow { + rows: value, + max_rows, + .. + } => { + *value = rows; + *max_rows = rows; + } + } + cx.notify(); + } + + /// Grow with the content from `min_rows` through `max_rows`. + pub fn auto_grow(mut self, min_rows: usize, max_rows: usize) -> Self { + self.mode = LayoutMode::auto_grow(min_rows, max_rows); + self + } +} + +/// Methods that only a source-code editor offers. +impl InputBaseState { + /// Create a source-code editor state. + /// + /// Default options: line numbers on, tab size 2 with soft tabs, indent + /// guides on, multi-line, and search enabled. Set the language for syntax + /// highlighting with [`Self::language`]; without one the text is shown + /// unhighlighted. + /// + /// The editor aims at simple code editing or display, not at being a + /// full-featured code editor. It offers syntax highlighting, auto indent, + /// line numbers, and handles large text up to about 50K lines. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let mut state = Self::new_in_mode(window, cx); + state.mode = LayoutMode::code_editor(); + state.searchable = true; + state + } + + /// Set the language to highlight, e.g. `"rust"`. + /// + /// See [`Self::set_highlighter`] to change it after construction. + pub fn language(mut self, language: impl Into) -> Self { + if let LayoutMode::CodeEditor { + language: l, + highlighter, + .. + } = &mut self.mode + { + *l = language.into(); + *highlighter.borrow_mut() = None; + } + self + } + + /// Set enable/disable code folding. + /// + /// Default: true + #[doc(hidden)] + pub fn folding(mut self, folding: bool) -> Self { + if let LayoutMode::CodeEditor { folding: f, .. } = &mut self.mode { + *f = folding; + } + self + } + + /// Set code folding at runtime. + /// + /// When disabling, all existing folds are cleared. + pub fn set_folding(&mut self, folding: bool, _: &mut Window, cx: &mut Context) { + if let LayoutMode::CodeEditor { folding: f, .. } = &mut self.mode { + *f = folding; + } + if !folding { + self.display_map.clear_folds(); + } + cx.notify(); + } + + /// Set enable/disable line number. + #[doc(hidden)] + pub fn line_number(mut self, line_number: bool) -> Self { + if let LayoutMode::CodeEditor { line_number: l, .. } = &mut self.mode { + *l = line_number; + } + self + } + + /// Set line number. + pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context) { + if let LayoutMode::CodeEditor { line_number: l, .. } = &mut self.mode { + *l = line_number; + } + cx.notify(); + } +} diff --git a/vendor/gpui-base/src/input/base/undo_manager.rs b/vendor/gpui-base/src/input/base/undo_manager.rs new file mode 100644 index 00000000..15d89ed8 --- /dev/null +++ b/vendor/gpui-base/src/input/base/undo_manager.rs @@ -0,0 +1,263 @@ +use crate::input::change::Change; + +const MAX_UNDO_TRANSACTIONS: usize = 1000; +const MAX_CHANGES_PER_TRANSACTION: usize = 1000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EditIntent { + Typing, + Backspace, + DeleteForward, + Atomic, +} + +#[derive(Debug)] +struct UndoTransaction { + intent: EditIntent, + changes: Vec, +} + +/// Coordinates undo and redo as explicit editing transactions. +/// +/// Each edit first creates a transaction. Compatible adjacent transactions +/// may then coalesce until an explicit boundary is encountered. Callers that +/// perform one logical edit through several callbacks (currently IME +/// composition) bracket those changes with `begin_transaction` and +/// `commit_transaction`. +#[derive(Debug)] +pub(crate) struct UndoManager { + undo_transactions: Vec, + redo_transactions: Vec, + ignoring: bool, + transaction_open: bool, + pending_change: Option, + pub(crate) pending_intent: Option, + coalescing_boundary: bool, +} + +impl UndoManager { + pub(super) fn new() -> Self { + Self { + undo_transactions: Vec::new(), + redo_transactions: Vec::new(), + ignoring: false, + transaction_open: false, + pending_change: None, + pending_intent: None, + coalescing_boundary: false, + } + } + + pub(super) fn record_transaction(&mut self, change: Change, intent: EditIntent) { + if self.ignoring { + return; + } + if change.old_range == change.new_range && change.old_text == change.new_text { + self.break_transaction_coalescing(); + return; + } + + if self.transaction_open { + if let Some(pending) = self.pending_change.as_mut() { + pending.new_range = change.new_range; + pending.new_text = change.new_text; + pending.selection_after = change.selection_after; + } else { + self.pending_change = Some(change); + } + } else { + self.push_transaction(change, intent); + } + } + + pub(super) fn begin_transaction(&mut self) { + if self.transaction_open { + return; + } + self.transaction_open = true; + self.pending_change = None; + } + + pub(super) fn commit_transaction(&mut self) { + if !self.transaction_open { + return; + } + self.transaction_open = false; + if let Some(change) = self.pending_change.take() + && (change.old_range != change.new_range || change.old_text != change.new_text) + { + self.push_transaction(change, EditIntent::Atomic); + } + } + + fn push_transaction(&mut self, change: Change, intent: EditIntent) { + self.redo_transactions.clear(); + let can_coalesce = !self.coalescing_boundary + && intent != EditIntent::Atomic + && self.undo_transactions.last().is_some_and(|previous| { + previous.intent == intent + && previous.changes.len() < MAX_CHANGES_PER_TRANSACTION + && previous + .changes + .last() + .is_some_and(|last| is_adjacent(intent, last, &change)) + }); + + if can_coalesce { + self.undo_transactions + .last_mut() + .expect("coalescing requires a previous transaction") + .changes + .push(change); + return; + } + + if self.undo_transactions.len() >= MAX_UNDO_TRANSACTIONS { + self.undo_transactions.remove(0); + } + self.undo_transactions.push(UndoTransaction { + intent, + changes: vec![change], + }); + self.coalescing_boundary = intent == EditIntent::Atomic; + } + + pub(super) fn break_transaction_coalescing(&mut self) { + self.commit_transaction(); + self.coalescing_boundary = true; + } + + pub(super) fn is_ignoring(&self) -> bool { + self.ignoring + } + + pub(super) fn set_ignoring(&mut self, ignoring: bool) { + self.ignoring = ignoring; + if ignoring { + self.commit_transaction(); + } + } + + pub(super) fn clear(&mut self) { + self.undo_transactions.clear(); + self.redo_transactions.clear(); + self.transaction_open = false; + self.pending_change = None; + self.pending_intent = None; + self.coalescing_boundary = false; + } + + pub(super) fn undo(&mut self) -> Option> { + self.commit_transaction(); + let transaction = self.undo_transactions.pop()?; + let changes = transaction.changes.iter().rev().cloned().collect(); + self.redo_transactions.push(transaction); + self.coalescing_boundary = true; + Some(changes) + } + + pub(super) fn redo(&mut self) -> Option> { + self.commit_transaction(); + let transaction = self.redo_transactions.pop()?; + let changes = transaction.changes.clone(); + self.undo_transactions.push(transaction); + self.coalescing_boundary = true; + Some(changes) + } + + #[cfg(test)] + pub(super) fn has_undos(&self) -> bool { + !self.undo_transactions.is_empty() + } +} + +fn is_adjacent(intent: EditIntent, previous: &Change, current: &Change) -> bool { + match intent { + EditIntent::Typing => { + previous.old_range.is_empty() + && current.old_range.is_empty() + && !previous.new_text.contains(['\n', '\r']) + && !current.new_text.contains(['\n', '\r']) + && previous.new_range.end == current.old_range.start + } + EditIntent::Backspace => { + previous.new_text.is_empty() + && current.new_text.is_empty() + && current.old_range.end == previous.old_range.start + } + EditIntent::DeleteForward => { + previous.new_text.is_empty() + && current.new_text.is_empty() + && current.old_range.start == previous.old_range.start + } + EditIntent::Atomic => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Selection; + + fn typing_change(offset: usize, text: &str) -> Change { + let end = offset + text.len(); + Change::new( + offset..offset, + "", + offset..end, + text, + Selection::new(offset, offset), + Selection::new(end, end), + ) + } + + #[test] + fn adjacent_typing_transactions_coalesce() { + let mut manager = UndoManager::new(); + manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); + manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); + + assert_eq!(manager.undo().unwrap().len(), 2); + assert!(manager.undo().is_none()); + } + + #[test] + fn explicit_transaction_collects_multiple_changes() { + let mut manager = UndoManager::new(); + manager.begin_transaction(); + manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); + manager.record_transaction(typing_change(0, "ab"), EditIntent::Typing); + manager.commit_transaction(); + + let transaction = manager.undo().unwrap(); + assert_eq!(transaction.len(), 1); + assert_eq!(transaction[0].new_text, "ab"); + } + + #[test] + fn limits_the_number_of_retained_transactions() { + let mut manager = UndoManager::new(); + + for offset in 0..1_100 { + manager.record_transaction(typing_change(offset, "a"), EditIntent::Atomic); + } + + for _ in 0..MAX_UNDO_TRANSACTIONS { + assert!(manager.undo().is_some()); + } + assert!(manager.undo().is_none()); + } + + #[test] + fn splits_a_coalesced_transaction_before_its_change_list_grows_too_large() { + let mut manager = UndoManager::new(); + + for offset in 0..1_100 { + manager.record_transaction(typing_change(offset, "a"), EditIntent::Typing); + } + + assert_eq!(manager.undo().unwrap().len(), 100); + assert_eq!(manager.undo().unwrap().len(), MAX_CHANGES_PER_TRANSACTION); + assert!(manager.undo().is_none()); + } +} diff --git a/vendor/gpui-base/src/input/editor/decorations.rs b/vendor/gpui-base/src/input/editor/decorations.rs new file mode 100644 index 00000000..dc4b393c --- /dev/null +++ b/vendor/gpui-base/src/input/editor/decorations.rs @@ -0,0 +1,336 @@ +use crate::input::EditorMode; +use std::ops::Range; + +use gpui::{App, Context, HighlightStyle, WeakEntity}; +use ropey::Rope; +use sum_tree::Bias; + +use super::{InputBaseState, RopeExt as _}; + +/// A presentation style applied to a UTF-8 byte range in an input. +/// +/// This is the GPUI [`HighlightStyle`] counterpart of Monaco's +/// [`IModelDeltaDecoration`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IModelDeltaDecoration.html). +#[derive(Clone, Debug, PartialEq)] +pub struct TextDecoration { + pub range: Range, + pub style: HighlightStyle, +} + +impl TextDecoration { + /// Create a text decoration from a UTF-8 byte range and a GPUI style. + pub fn new(range: Range, style: HighlightStyle) -> Self { + Self { range, style } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct TextDecorationCollectionId(usize); + +/// An independently managed collection of [`TextDecoration`]s. +/// +/// This is the GPUI Component counterpart of Monaco's +/// [`IEditorDecorationsCollection`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html). +#[derive(Clone, Debug)] +pub struct TextDecorationCollection { + state: WeakEntity>, + id: TextDecorationCollectionId, +} + +impl TextDecorationCollection { + /// Replace all decorations in this collection. + /// + /// This corresponds to Monaco's + /// [`IEditorDecorationsCollection.set`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#set). + pub fn set(&self, decorations: Vec, cx: &mut App) { + let _ = self.state.update(cx, |state, cx| { + let decorations = normalize(&state.text, decorations); + if state.extras.decorations.set(self.id, decorations) { + cx.notify(); + } + }); + } + + /// Add decorations to this collection. + /// + /// This corresponds to Monaco's + /// [`IEditorDecorationsCollection.append`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#append). + pub fn append(&self, decorations: Vec, cx: &mut App) { + let _ = self.state.update(cx, |state, cx| { + let decorations = normalize(&state.text, decorations); + if state.extras.decorations.append(self.id, decorations) { + cx.notify(); + } + }); + } + + /// Remove all decorations from this collection. + /// + /// This corresponds to Monaco's + /// [`IEditorDecorationsCollection.clear`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#clear). + pub fn clear(&self, cx: &mut App) { + self.set(Vec::new(), cx); + } + + /// Return the UTF-8 byte ranges in this collection. + /// + /// This corresponds to Monaco's + /// [`IEditorDecorationsCollection.getRanges`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.IEditorDecorationsCollection.html#getRanges). + pub fn get_ranges(&self, cx: &App) -> Vec> { + self.state + .read_with(cx, |state, _| { + state + .extras + .decorations + .get(self.id) + .unwrap_or_default() + .iter() + .map(|decoration| decoration.range.clone()) + .collect() + }) + .unwrap_or_default() + } +} + +#[derive(Default)] +pub(crate) struct DecorationCollections { + entries: Vec<(TextDecorationCollectionId, Vec)>, +} + +impl DecorationCollections { + fn create(&mut self, decorations: Vec) -> TextDecorationCollectionId { + let id = TextDecorationCollectionId(self.entries.len()); + self.entries.push((id, decorations)); + id + } + + fn set(&mut self, id: TextDecorationCollectionId, decorations: Vec) -> bool { + let Some((_, current)) = self + .entries + .iter_mut() + .find(|(entry_id, _)| *entry_id == id) + else { + return false; + }; + *current = decorations; + true + } + + fn append(&mut self, id: TextDecorationCollectionId, decorations: Vec) -> bool { + let Some((_, current)) = self + .entries + .iter_mut() + .find(|(entry_id, _)| *entry_id == id) + else { + return false; + }; + current.extend(decorations); + true + } + + fn get(&self, id: TextDecorationCollectionId) -> Option<&[TextDecoration]> { + self.entries + .iter() + .find(|(entry_id, _)| *entry_id == id) + .map(|(_, decorations)| decorations.as_slice()) + } + + pub(super) fn adjust_for_edit(&mut self, edited_range: &Range, inserted_len: usize) { + for (_, decorations) in &mut self.entries { + decorations.retain_mut(|decoration| { + decoration.range = + adjust_range_for_edit(&decoration.range, edited_range, inserted_len); + !decoration.range.is_empty() + }); + } + } + + pub(super) fn clear(&mut self) { + for (_, decorations) in &mut self.entries { + decorations.clear(); + } + } + + pub(super) fn iter(&self) -> impl Iterator { + self.entries + .iter() + .map(|(_, decorations)| decorations.as_slice()) + } +} + +fn adjust_range_for_edit( + range: &Range, + edited_range: &Range, + inserted_len: usize, +) -> Range { + let removed_len = edited_range.end.saturating_sub(edited_range.start); + let shift = |offset: usize| { + if inserted_len >= removed_len { + offset.saturating_add(inserted_len - removed_len) + } else { + offset.saturating_sub(removed_len - inserted_len) + } + }; + + if edited_range.is_empty() { + let start = if range.start < edited_range.start { + range.start + } else { + shift(range.start) + }; + let end = if range.end <= edited_range.start { + range.end + } else { + shift(range.end) + }; + return start..end; + } + + let inserted_end = edited_range.start + inserted_len; + let start = if range.start <= edited_range.start { + range.start + } else if range.start >= edited_range.end { + shift(range.start) + } else { + edited_range.start + }; + let end = if range.end <= edited_range.start { + range.end + } else if range.end >= edited_range.end { + shift(range.end) + } else { + inserted_end + }; + start..end +} + +fn normalize(text: &Rope, decorations: Vec) -> Vec { + decorations + .into_iter() + .filter_map(|decoration| { + let range = text.clip_offset(decoration.range.start, Bias::Left) + ..text.clip_offset(decoration.range.end, Bias::Right); + (!range.is_empty()).then_some(TextDecoration { + range, + style: decoration.style, + }) + }) + .collect() +} + +impl InputBaseState { + /// Create an independently managed collection of text decorations. + /// + /// This follows Monaco's + /// [`createDecorationsCollection`](https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor_editor_api.editor.ICodeEditor.html#createDecorationsCollection) + /// ownership model. Ranges use UTF-8 byte offsets into [`Self::value`]. + /// + /// Decoration ranges follow text edits and do not need to be set again + /// after each change. Insertions at a range boundary do not expand the + /// range, matching Monaco's + /// [`NeverGrowsWhenTypingAtEdges`](https://microsoft.github.io/monaco-editor/typedoc/enums/editor_editor_api.editor.TrackedRangeStickiness.html#NeverGrowsWhenTypingAtEdges) + /// behavior. Decorations are not rendered while the input is masked. + /// Collections live until their [`InputBaseState`] is dropped. + /// + /// Collections are layered in insertion order; the first collection wins + /// when overlapping decorations set the same [`HighlightStyle`] property. + /// Callers should avoid conflicting overlaps within one collection. + pub fn create_decorations_collection( + &mut self, + decorations: Vec, + cx: &mut Context, + ) -> TextDecorationCollection { + let decorations = normalize(&self.text, decorations); + let id = self.extras.decorations.create(decorations); + cx.notify(); + TextDecorationCollection { + state: cx.entity().downgrade(), + id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collections_are_independent_and_ranges_are_clipped() { + let text = Rope::from("héllo"); + let first_style = HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }; + let second_style = HighlightStyle { + background_color: Some(gpui::red()), + ..Default::default() + }; + let mut collections = DecorationCollections::default(); + + let first = collections.create(normalize( + &text, + vec![TextDecoration::new(2..4, first_style)], + )); + let second = collections.create(normalize( + &text, + vec![TextDecoration::new(5..100, second_style)], + )); + + assert_ne!(first, second); + assert_eq!( + collections.get(first), + Some(&[TextDecoration::new(1..4, first_style)][..]) + ); + assert_eq!( + collections.get(second), + Some(&[TextDecoration::new(5..6, second_style)][..]) + ); + + assert!(collections.append(first, vec![TextDecoration::new(4..5, second_style)])); + assert_eq!( + collections.get(first), + Some( + &[ + TextDecoration::new(1..4, first_style), + TextDecoration::new(4..5, second_style), + ][..] + ) + ); + + assert!(collections.set(first, Vec::new())); + assert_eq!(collections.get(first), Some(&[][..])); + assert_eq!( + collections.get(second), + Some(&[TextDecoration::new(5..6, second_style)][..]) + ); + } + + #[test] + fn decoration_ranges_follow_text_edits() { + let style = HighlightStyle::default(); + let mut collections = DecorationCollections::default(); + let collection = collections.create(vec![TextDecoration::new(2..6, style)]); + + collections.adjust_for_edit(&(0..0), 2); + assert_eq!( + collections.get(collection), + Some(&[TextDecoration::new(4..8, style)][..]) + ); + + collections.adjust_for_edit(&(6..6), 2); + assert_eq!( + collections.get(collection), + Some(&[TextDecoration::new(4..10, style)][..]) + ); + + collections.adjust_for_edit(&(4..10), 3); + assert_eq!( + collections.get(collection), + Some(&[TextDecoration::new(4..7, style)][..]) + ); + + assert_eq!(adjust_range_for_edit(&(2..6), &(2..2), 2), 4..8); + assert_eq!(adjust_range_for_edit(&(2..6), &(6..6), 2), 2..6); + assert_eq!(adjust_range_for_edit(&(2..6), &(2..6), 3), 2..5); + } +} diff --git a/vendor/gpui-component/src/highlighter/diagnostics.rs b/vendor/gpui-base/src/input/editor/diagnostics.rs similarity index 76% rename from vendor/gpui-component/src/highlighter/diagnostics.rs rename to vendor/gpui-base/src/input/editor/diagnostics.rs index 56d0cabd..bb728099 100644 --- a/vendor/gpui-component/src/highlighter/diagnostics.rs +++ b/vendor/gpui-base/src/input/editor/diagnostics.rs @@ -1,20 +1,16 @@ use std::{ cmp::Ordering, ops::{Deref, Range}, - usize, }; -use gpui::{px, App, HighlightStyle, Hsla, SharedString, UnderlineStyle}; +use gpui::SharedString; use ropey::Rope; use sum_tree::{Bias, SeekTarget, SumTree}; -use crate::{ - input::{Position, RopeExt as _}, - ActiveTheme, -}; +use crate::input::{Position, RopeExt as _}; pub type DiagnosticRelatedInformation = lsp_types::DiagnosticRelatedInformation; -pub type CodeDescription = lsp_types::CodeDescription; +pub(super) type CodeDescription = lsp_types::CodeDescription; pub type RelatedInformation = lsp_types::DiagnosticRelatedInformation; pub type DiagnosticTag = lsp_types::DiagnosticTag; @@ -98,60 +94,6 @@ impl From for DiagnosticSeverity { } } -impl DiagnosticSeverity { - pub(crate) fn bg(&self, cx: &App) -> Hsla { - let theme = &cx.theme().highlight_theme; - - match self { - Self::Error => theme.style.status.error_background(cx), - Self::Warning => theme.style.status.warning_background(cx), - Self::Info => theme.style.status.info_background(cx), - Self::Hint => theme.style.status.hint_background(cx), - } - } - - pub(crate) fn fg(&self, cx: &App) -> Hsla { - let theme = &cx.theme().highlight_theme; - - match self { - Self::Error => theme.style.status.error(cx), - Self::Warning => theme.style.status.warning(cx), - Self::Info => theme.style.status.info(cx), - Self::Hint => theme.style.status.hint(cx), - } - } - - pub(crate) fn border(&self, cx: &App) -> Hsla { - let theme = &cx.theme().highlight_theme; - match self { - Self::Error => theme.style.status.error_border(cx), - Self::Warning => theme.style.status.warning_border(cx), - Self::Info => theme.style.status.info_border(cx), - Self::Hint => theme.style.status.hint_border(cx), - } - } - - pub(crate) fn highlight_style(&self, cx: &App) -> HighlightStyle { - let theme = &cx.theme().highlight_theme; - - let color = match self { - Self::Error => Some(theme.style.status.error(cx)), - Self::Warning => Some(theme.style.status.warning(cx)), - Self::Info => Some(theme.style.status.info(cx)), - Self::Hint => Some(theme.style.status.hint(cx)), - }; - - let mut style = HighlightStyle::default(); - style.underline = Some(UnderlineStyle { - color: color, - thickness: px(1.), - wavy: true, - }); - - style - } -} - impl Diagnostic { pub fn new(range: Range>, message: impl Into) -> Self { Self { @@ -178,7 +120,7 @@ impl Diagnostic { } #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub(crate) struct DiagnosticEntry { +pub struct DiagnosticEntry { /// The byte range of the diagnostic in the rope. pub range: Range, pub diagnostic: Diagnostic, @@ -295,7 +237,7 @@ impl DiagnosticSet { self.diagnostics.is_empty() } - pub(crate) fn range(&self, range: Range) -> impl Iterator { + pub fn range(&self, range: Range) -> impl Iterator { let mut cursor = self.diagnostics.cursor::(&()); cursor.seek(&range.start, Bias::Left); std::iter::from_fn(move || { @@ -309,30 +251,12 @@ impl DiagnosticSet { }) } - pub(crate) fn for_offset(&self, offset: usize) -> Option<&DiagnosticEntry> { + pub fn for_offset(&self, offset: usize) -> Option<&DiagnosticEntry> { self.range(offset..offset + 1).next() } - pub(crate) fn styles_for_range( - &self, - range: &Range, - cx: &App, - ) -> Vec<(Range, HighlightStyle)> { - if self.diagnostics.is_empty() { - return vec![]; - } - - let mut styles = vec![]; - for entry in self.range(range.clone()) { - let range = entry.range.clone(); - styles.push((range, entry.diagnostic.severity.highlight_style(cx))); - } - - styles - } - #[allow(unused)] - pub(crate) fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator { self.diagnostics.iter() } } diff --git a/vendor/gpui-base/src/input/editor/display_map/README.md b/vendor/gpui-base/src/input/editor/display_map/README.md new file mode 100644 index 00000000..9c715365 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/README.md @@ -0,0 +1,46 @@ +# Display Mapping System + +Layered coordinate conversion and code folding for Editor/Input. + +## Architecture + +``` +Buffer (Rope) Logical text + ↓ +WrapMap Soft-wrapping (buffer_line ↔ wrap_row) + ↓ +FoldMap Fold projection (wrap_row ↔ display_row) + ↓ +DisplayMap Public facade (BufferPos ↔ DisplayPos) +``` + +## Coordinate Systems + +| Type | Fields | Scope | Description | +|------|--------|-------|-------------| +| `BufferPos` | `{ line, col }` | public | Logical line/column in Rope | +| `WrapPos` | `{ row, col }` | internal | Visual row after soft-wrapping | +| `DisplayPos` | `{ row, col }` | public | Final visible row after folding | + +## Modules + +### `DisplayMap` — Public facade + +- `buffer_pos_to_display_pos()` / `display_pos_to_buffer_pos()` +- `set_fold_candidates()`, `toggle_fold()`, `is_folded_at()`, `clear_folds()` +- `on_text_changed()`, `on_layout_changed()`, `set_font()` +- `adjust_folds_for_edit()` — incremental fold/candidate line-delta adjustment +- `update_fold_candidates_for_edit()` — region-scoped candidate extraction after edits + +### `WrapMap` — Soft-wrapping layer + +Built on `TextWrapper`. Provides buffer ↔ wrap coordinate mapping with prefix sum cache for O(1) line lookups. + +### `FoldMap` — Fold projection layer + +Maintains `visible_wrap_rows` and reverse mapping. When no folds are active, uses identity mapping (wrap_row == display_row) without Vec allocation. + +### `FoldRange` / `folding` — Fold extraction + +- `extract_fold_ranges(tree)` — full tree traversal (initial load only) +- `extract_fold_ranges_in_range(tree, byte_range)` — region-scoped traversal for edits, skips subtrees outside range diff --git a/vendor/gpui-base/src/input/editor/display_map/display_map.rs b/vendor/gpui-base/src/input/editor/display_map/display_map.rs new file mode 100644 index 00000000..6df91230 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/display_map.rs @@ -0,0 +1,352 @@ +/// DisplayMap: Public facade for Editor/Input display mapping. +/// +/// This combines WrapMap and FoldMap to provide a unified API: +/// - BufferPoint ↔ DisplayPoint conversion +/// - Fold management (candidates, toggle, query) +/// - Automatic projection updates on text/layout changes +use std::ops::Range; + +use gpui::{App, Font, Pixels}; +use ropey::Rope; + +use super::fold_map::FoldMap; +use super::folding::FoldRange; +pub use super::text_wrapper::WrappingIndent; +use super::text_wrapper::{LineItem, WrapDisplayPoint}; +use super::wrap_map::WrapMap; +use super::{BufferPoint, DisplayPoint}; +use crate::input::Point as TreeSitterPoint; +use crate::input::display_map::WrapPoint; +use crate::input::rope_ext::RopeExt as _; + +/// DisplayMap is the main interface for Editor/Input coordinate mapping. +/// +/// It manages the two-layer projection: +/// 1. Buffer → Wrap (soft-wrapping) +/// 2. Wrap → Display (folding) +/// +/// Editor/Input only needs to work with BufferPoint and DisplayPoint. +pub struct DisplayMap { + wrap_map: WrapMap, + fold_map: FoldMap, +} + +impl DisplayMap { + pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + wrap_map: WrapMap::new(font, font_size, wrap_width), + fold_map: FoldMap::new(), + } + } + + // ==================== Core Coordinate Mapping ==================== + + /// Convert buffer position to display position + pub fn buffer_pos_to_display_pos(&self, pos: BufferPoint) -> DisplayPoint { + // Buffer → Wrap + let wrap_pos = self.wrap_map.buffer_pos_to_wrap_pos(pos); + + // Wrap → Display + if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_pos.row) { + DisplayPoint::new(display_row, wrap_pos.col) + } else { + // Cursor is in a folded region, find nearest visible row + let display_row = self.fold_map.nearest_visible_display_row(wrap_pos.row); + DisplayPoint::new(display_row, 0) // Column 0 at fold boundary + } + } + + /// Convert display position to buffer position + pub fn display_pos_to_buffer_pos(&self, pos: DisplayPoint) -> BufferPoint { + // Display → Wrap + let wrap_row = self.fold_map.display_row_to_wrap_row(pos.row).unwrap_or(0); + + // Wrap → Buffer + let wrap_pos = WrapPoint::new(wrap_row, pos.col); + self.wrap_map.wrap_pos_to_buffer_pos(wrap_pos) + } + + /// Get total number of visible display rows + #[inline] + pub fn display_row_count(&self) -> usize { + self.fold_map.display_row_count() + } + + /// Get the buffer line for a given display row + pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize { + // Display → Wrap + let wrap_row = self + .fold_map + .display_row_to_wrap_row(display_row) + .unwrap_or(0); + + // Wrap → Buffer line + self.wrap_map.wrap_row_to_buffer_line(wrap_row) + } + + /// Get the display row range for a buffer line: [start, end) + /// Returns None if the buffer line is completely hidden + pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option> { + // Buffer line → Wrap row range + let wrap_row_range = self.wrap_map.buffer_line_to_wrap_row_range(line); + + // Find first and last visible display rows in this range + let mut first_display_row = None; + let mut last_display_row = None; + + for wrap_row in wrap_row_range { + if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_row) { + if first_display_row.is_none() { + first_display_row = Some(display_row); + } + last_display_row = Some(display_row); + } + } + + if let (Some(start), Some(end)) = (first_display_row, last_display_row) { + Some(start..end + 1) + } else { + None // Completely folded + } + } + + /// Check if a buffer line is completely hidden + #[inline] + pub fn is_buffer_line_hidden(&self, line: usize) -> bool { + self.buffer_line_to_display_row_range(line).is_none() + } + + /// First display row of a buffer line. If the line is fully folded, returns the + /// nearest visible display row. + pub fn buffer_line_to_display_row(&self, line: usize) -> usize { + match self.buffer_line_to_display_row_range(line) { + Some(range) => range.start, + None => { + let wrap_row = self.wrap_map.buffer_line_to_first_wrap_row(line); + self.fold_map.nearest_visible_display_row(wrap_row) + } + } + } + + /// Set fold candidates (from tree-sitter/LSP) + pub fn set_fold_candidates(&mut self, candidates: Vec) { + self.fold_map.set_candidates(candidates); + self.rebuild_fold_projection(); + } + + /// Set a fold at the given start_line (must be in candidates) + pub fn set_folded(&mut self, start_line: usize, folded: bool) { + self.fold_map.set_folded(start_line, folded); + self.rebuild_fold_projection(); + } + + /// Toggle fold at the given start_line + pub fn toggle_fold(&mut self, start_line: usize) { + self.fold_map.toggle_fold(start_line); + self.rebuild_fold_projection(); + } + + /// Check if a line is currently folded + #[inline] + pub fn is_folded_at(&self, start_line: usize) -> bool { + self.fold_map.is_folded_at(start_line) + } + + /// Check if a line is a fold candidate + #[inline] + pub fn is_fold_candidate(&self, start_line: usize) -> bool { + self.fold_map.is_fold_candidate(start_line) + } + + /// Get all currently folded ranges + #[inline] + pub fn folded_ranges(&self) -> &[FoldRange] { + self.fold_map.folded_ranges() + } + + /// Clear all folds + pub fn clear_folds(&mut self) { + self.fold_map.clear_folds(); + self.rebuild_fold_projection(); + } + + // ==================== Text and Layout Updates ==================== + + /// Adjust folds and candidates for a text edit before updating the wrap map. + /// + /// Must be called with the OLD text (before replacement) and the edit range/new_text + /// so we can compute which old lines were affected. + pub fn adjust_folds_for_edit(&mut self, old_text: &Rope, range: &Range, new_text: &str) { + if self.fold_map.folded_ranges().is_empty() && self.fold_map.fold_candidates().is_empty() { + return; + } + + let edit_start_line = old_text.offset_to_point(range.start).row; + let edit_end_line = old_text.offset_to_point(range.end.min(old_text.len())).row; + + let old_lines_in_range = edit_end_line.saturating_sub(edit_start_line); + let new_lines_in_range = new_text.chars().filter(|c| *c == '\n').count(); + let line_delta = new_lines_in_range as isize - old_lines_in_range as isize; + + self.fold_map + .adjust_folds_for_edit(edit_start_line, edit_end_line, line_delta); + } + + /// Incrementally update fold candidates after a text edit. + /// + /// Extracts new fold candidates only within the edited byte range + /// and merges them with existing (already adjusted) candidates. + pub fn update_fold_candidates_for_edit( + &mut self, + extract_fold_ranges: impl FnOnce(Range, &Rope) -> Vec, + edit_byte_range: Range, + new_text: &Rope, + ) { + let new_start_line = new_text.offset_to_point(edit_byte_range.start).row; + let new_end_line = new_text + .offset_to_point(edit_byte_range.end.min(new_text.len())) + .row; + + let new_candidates = extract_fold_ranges(edit_byte_range, new_text); + self.fold_map + .merge_candidates_for_edit(new_start_line, new_end_line, new_candidates); + } + + /// Update text (incremental or full) + pub fn on_text_changed( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + cx: &mut App, + ) { + self.wrap_map + .on_text_changed(changed_text, range, new_text, cx); + self.rebuild_fold_projection(); + } + + /// Update layout parameters (wrap width or font) + pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { + self.wrap_map.on_layout_changed(wrap_width, cx); + self.rebuild_fold_projection(); + } + + /// Set the wrapping indent for continuation lines. + pub fn set_wrapping_indent(&mut self, wrapping_indent: WrappingIndent, cx: &mut App) { + self.wrap_map.set_wrapping_indent(wrapping_indent, cx); + self.rebuild_fold_projection(); + } + + /// Set font parameters + pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + self.wrap_map.set_font(font, font_size, cx); + self.rebuild_fold_projection(); + } + + /// Ensure text is prepared (initializes wrapper if needed) + pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) { + let did_initialize = self.wrap_map.ensure_text_prepared(text, cx); + if did_initialize { + self.rebuild_fold_projection(); + } + } + + /// Initialize with text + pub fn set_text(&mut self, text: &Rope, cx: &mut App) { + self.wrap_map.set_text(text, cx); + self.rebuild_fold_projection(); + } + + // ==================== Internal Helpers ==================== + + /// Rebuild fold projection after wrap_map or fold state changes + /// Only rebuilds if there are actually folded ranges + fn rebuild_fold_projection(&mut self) { + if !self.fold_map.folded_ranges().is_empty() { + self.fold_map.rebuild(&self.wrap_map); + } else { + // No active folds: identity mapping (wrap_row == display_row). + // Just update cached count so query methods work without Vec allocation. + self.fold_map + .mark_dirty_with_wrap_count(self.wrap_map.wrap_row_count()); + } + } + + // ==================== Wrap Display Point Operations ==================== + + /// Convert byte offset to wrap display point (with soft wrap info). + #[inline] + pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint { + self.wrap_map.wrapper().offset_to_display_point(offset) + } + + /// Convert wrap display point to byte offset. + #[inline] + pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { + self.wrap_map.wrapper().display_point_to_offset(point) + } + + /// Convert wrap display point to TreeSitterPoint (buffer line/col). + #[inline] + pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { + self.wrap_map.wrapper().display_point_to_point(point) + } + + /// Convert a wrap row to a display row (skipping folded rows). + /// Returns None if the wrap row is folded. + #[inline] + pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option { + self.fold_map.wrap_row_to_display_row(wrap_row) + } + + /// Find the nearest visible display row for a given wrap row. + #[inline] + pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize { + self.fold_map.nearest_visible_display_row(wrap_row) + } + + /// Convert a display row to a wrap row. + #[inline] + pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option { + self.fold_map.display_row_to_wrap_row(display_row) + } + + /// Get the longest row index (by byte length). + #[inline] + pub(crate) fn longest_row(&self) -> usize { + self.wrap_map.wrapper().longest_row() + } + + // ==================== Access Methods ==================== + + /// Get the line item by buffer row index. + #[inline] + pub(crate) fn line(&self, row: usize) -> Option<&LineItem> { + self.wrap_map.line(row) + } + + /// Get the rope text + #[inline] + pub fn text(&self) -> &Rope { + self.wrap_map.text() + } + + /// Calculate how many wrap rows of a buffer line are visible (not folded) + #[inline] + pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize { + self.wrap_map + .visible_wrap_row_count_for_line(line, &self.fold_map) + } + + /// Get the wrap row count (before folding) + #[inline] + pub fn wrap_row_count(&self) -> usize { + self.wrap_map.wrap_row_count() + } + + /// Get the buffer line count (logical lines) + #[inline] + pub fn buffer_line_count(&self) -> usize { + self.wrap_map.buffer_line_count() + } +} diff --git a/vendor/gpui-base/src/input/editor/display_map/fold_map.rs b/vendor/gpui-base/src/input/editor/display_map/fold_map.rs new file mode 100644 index 00000000..b5501f0c --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/fold_map.rs @@ -0,0 +1,343 @@ +/// FoldMap: Folding projection layer (Wrap rows → Display rows). +/// +/// This module manages code folding by: +/// - Filtering out wrap rows that belong to folded regions +/// - Maintaining bidirectional mapping: wrap_row ↔ display_row +/// - Handling fold state changes and rebuilding the projection +use super::folding::FoldRange; +use super::wrap_map::WrapMap; + +/// FoldMap projects wrap rows to display rows by hiding folded regions. +pub(super) struct FoldMap { + /// Mapping: display_row → wrap_row + /// index = display_row, value = actual wrap_row + visible_wrap_rows: Vec, + + /// Reverse mapping: wrap_row → display_row + /// index = wrap_row, value = Some(display_row) if visible, None if folded + wrap_row_to_display_row: Vec>, + + /// Candidate fold ranges (from tree-sitter/LSP) + /// Sorted by start_line, unique start_line + candidates: Vec, + + /// Currently folded ranges + /// Subset of candidates, sorted by start_line + folded: Vec, + + /// Flag indicating if the fold projection needs rebuilding + /// Used for lazy evaluation to avoid expensive rebuilds on every text change + needs_rebuild: bool, + + /// Cached wrap_row_count from last rebuild + /// Used to detect if WrapMap changed and rebuild is needed + cached_wrap_row_count: usize, +} + +impl FoldMap { + pub(super) fn new() -> Self { + Self { + visible_wrap_rows: Vec::new(), + wrap_row_to_display_row: Vec::new(), + candidates: Vec::new(), + folded: Vec::new(), + needs_rebuild: true, + cached_wrap_row_count: 0, + } + } + + /// Update cached wrap_row_count without full rebuild. + /// Used when no folds are active (identity mapping assumed). + pub(super) fn mark_dirty_with_wrap_count(&mut self, wrap_row_count: usize) { + self.needs_rebuild = true; + self.cached_wrap_row_count = wrap_row_count; + } + + /// Get total number of visible display rows + pub(super) fn display_row_count(&self) -> usize { + if self.folded.is_empty() { + return self.cached_wrap_row_count; + } + self.visible_wrap_rows.len() + } + + /// Convert wrap_row to display_row + /// Returns None if the wrap_row is hidden by folding + pub(super) fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option { + if self.folded.is_empty() { + return if wrap_row < self.cached_wrap_row_count { + Some(wrap_row) + } else { + None + }; + } + self.wrap_row_to_display_row + .get(wrap_row) + .copied() + .flatten() + } + + /// Convert display_row to wrap_row + pub(super) fn display_row_to_wrap_row(&self, display_row: usize) -> Option { + if self.folded.is_empty() { + return if display_row < self.cached_wrap_row_count { + Some(display_row) + } else { + None + }; + } + self.visible_wrap_rows.get(display_row).copied() + } + + /// Find the nearest visible display_row for a given wrap_row + pub(super) fn nearest_visible_display_row(&self, wrap_row: usize) -> usize { + if self.folded.is_empty() { + return wrap_row.min(self.cached_wrap_row_count.saturating_sub(1)); + } + + if let Some(dr) = self.wrap_row_to_display_row(wrap_row) { + return dr; + } + + match self.visible_wrap_rows.binary_search(&wrap_row) { + Ok(idx) => idx, + Err(insert_pos) => insert_pos.saturating_sub(1), + } + } + + /// Set fold candidates (from tree-sitter/LSP), full replacement. + pub(super) fn set_candidates(&mut self, mut candidates: Vec) { + // Sort and deduplicate by start_line + candidates.sort_by_key(|r| r.start_line); + candidates.dedup_by_key(|r| r.start_line); + self.candidates = candidates; + + // Remove any folded ranges that are no longer in candidates + self.folded.retain(|fold| { + self.candidates + .iter() + .any(|c| c.start_line == fold.start_line) + }); + } + + /// Merge new candidates extracted from an edited region into existing candidates. + /// + /// Replaces candidates within [edit_start_line, edit_end_line] with `new_candidates`, + /// keeping candidates outside the edit range intact. + pub(super) fn merge_candidates_for_edit( + &mut self, + edit_start_line: usize, + edit_end_line: usize, + new_candidates: Vec, + ) { + // Remove old candidates within the edit range (already done by adjust_folds_for_edit) + // But do it again in case adjust wasn't called or range differs + self.candidates + .retain(|c| c.start_line < edit_start_line || c.start_line > edit_end_line); + + // Add new candidates + self.candidates.extend(new_candidates); + self.candidates.sort_by_key(|r| r.start_line); + self.candidates.dedup_by_key(|r| r.start_line); + } + + /// Set a fold at the given start_line (must be in candidates) + pub(super) fn set_folded(&mut self, start_line: usize, folded: bool) { + if folded { + // Find the candidate range for this start_line + if let Some(candidate) = self.candidates.iter().find(|c| c.start_line == start_line) { + // Add to folded if not already present + if !self.folded.iter().any(|f| f.start_line == start_line) { + self.folded.push(*candidate); + self.folded.sort_by_key(|r| r.start_line); + self.needs_rebuild = true; + } + } + } else { + // Remove from folded + self.folded.retain(|f| f.start_line != start_line); + self.needs_rebuild = true; + } + } + + /// Toggle fold at the given start_line + pub(super) fn toggle_fold(&mut self, start_line: usize) { + let is_folded = self.is_folded_at(start_line); + self.set_folded(start_line, !is_folded); + } + + /// Check if a line is currently folded + pub(super) fn is_folded_at(&self, start_line: usize) -> bool { + self.folded.iter().any(|f| f.start_line == start_line) + } + + /// Check if a line is a fold candidate + pub(super) fn is_fold_candidate(&self, start_line: usize) -> bool { + self.candidates.iter().any(|c| c.start_line == start_line) + } + + /// Get all fold candidates + #[inline] + pub(super) fn fold_candidates(&self) -> &[FoldRange] { + &self.candidates + } + + /// Get all currently folded ranges + #[inline] + pub(super) fn folded_ranges(&self) -> &[FoldRange] { + &self.folded + } + + /// Clear all folds + #[inline] + pub(super) fn clear_folds(&mut self) { + self.folded.clear(); + } + + /// Adjust folds and candidates after a text edit. + /// + /// - Folds/candidates overlapping the edited line range are removed + /// - Folds/candidates after the edit are shifted by line_delta + /// + /// This avoids expensive full tree traversal on every keystroke. + pub(super) fn adjust_folds_for_edit( + &mut self, + edit_start_line: usize, + edit_end_line: usize, + line_delta: isize, + ) { + // Adjust folded ranges + if !self.folded.is_empty() { + self.folded.retain(|fold| { + !(fold.start_line <= edit_end_line && fold.end_line >= edit_start_line) + }); + + if line_delta != 0 { + for fold in &mut self.folded { + if fold.start_line > edit_end_line { + fold.start_line = (fold.start_line as isize + line_delta).max(0) as usize; + fold.end_line = (fold.end_line as isize + line_delta).max(0) as usize; + } + } + } + } + + // Adjust candidates the same way + if !self.candidates.is_empty() { + self.candidates + .retain(|c| !(c.start_line <= edit_end_line && c.end_line >= edit_start_line)); + + if line_delta != 0 { + for c in &mut self.candidates { + if c.start_line > edit_end_line { + c.start_line = (c.start_line as isize + line_delta).max(0) as usize; + c.end_line = (c.end_line as isize + line_delta).max(0) as usize; + } + } + } + } + + self.needs_rebuild = true; + } + + /// Rebuild the fold mapping after wrap_map or fold state changes + /// + /// This is the core algorithm that projects wrap rows to display rows. + pub(super) fn rebuild(&mut self, wrap_map: &WrapMap) { + let wrap_row_count = wrap_map.wrap_row_count(); + + // Performance optimization: skip rebuild if nothing changed + if !self.needs_rebuild && wrap_row_count == self.cached_wrap_row_count { + return; + } + + self.cached_wrap_row_count = wrap_row_count; + + self.visible_wrap_rows.clear(); + self.wrap_row_to_display_row = vec![None; wrap_row_count]; + + if self.folded.is_empty() { + // Fast path: no folds, all wrap rows are visible + self.visible_wrap_rows = (0..wrap_row_count).collect(); + for (display_row, &wrap_row) in self.visible_wrap_rows.iter().enumerate() { + self.wrap_row_to_display_row[wrap_row] = Some(display_row); + } + self.needs_rebuild = false; + return; + } + + // Build set of hidden wrap_row ranges from folded buffer lines + let mut hidden_ranges = Vec::new(); + for fold in &self.folded { + // Hide wrap rows from (start_line + 1) to (end_line - 1) (inclusive) + // Both the first line and last line of the fold remain visible + let hide_start_line = fold.start_line + 1; + let hide_end_line = fold.end_line.saturating_sub(1); + + if hide_start_line > hide_end_line { + continue; // No middle lines to hide (0 or 1 lines between start and end) + } + + // Get wrap_row ranges for the hidden buffer lines + let start_wrap_row = wrap_map.buffer_line_to_first_wrap_row(hide_start_line); + let end_wrap_row = if hide_end_line + 1 < wrap_map.buffer_line_count() { + wrap_map.buffer_line_to_first_wrap_row(hide_end_line + 1) + } else { + wrap_row_count + }; + + if start_wrap_row < end_wrap_row { + hidden_ranges.push(start_wrap_row..end_wrap_row); + } + } + + // Merge overlapping hidden ranges + hidden_ranges.sort_by_key(|r| r.start); + let mut merged_hidden = Vec::new(); + for range in hidden_ranges { + if let Some(last) = merged_hidden.last_mut() { + if range.start <= *last { + // Overlapping or adjacent, merge + *last = (*last).max(range.end); + } else { + merged_hidden.push(range.start); + merged_hidden.push(range.end); + } + } else { + merged_hidden.push(range.start); + merged_hidden.push(range.end); + } + } + + // Scan all wrap rows and filter out hidden ones + let mut display_row = 0; + let mut hidden_iter = merged_hidden.chunks_exact(2); + let mut current_hidden = hidden_iter.next(); + + for wrap_row in 0..wrap_row_count { + // Check if wrap_row is in current hidden range + let is_hidden = if let Some(&[start, end]) = current_hidden { + if wrap_row >= end { + current_hidden = hidden_iter.next(); + if let Some(&[new_start, new_end]) = current_hidden { + wrap_row >= new_start && wrap_row < new_end + } else { + false + } + } else { + wrap_row >= start && wrap_row < end + } + } else { + false + }; + + if !is_hidden { + self.visible_wrap_rows.push(wrap_row); + self.wrap_row_to_display_row[wrap_row] = Some(display_row); + display_row += 1; + } + } + + self.needs_rebuild = false; + } +} diff --git a/vendor/gpui-base/src/input/editor/display_map/folding.rs b/vendor/gpui-base/src/input/editor/display_map/folding.rs new file mode 100644 index 00000000..54d4b88c --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/folding.rs @@ -0,0 +1,19 @@ +/// A foldable line range supplied by an editor highlighter or another source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FoldRange { + pub start_line: usize, + pub end_line: usize, +} + +impl FoldRange { + pub fn new(start_line: usize, end_line: usize) -> Self { + assert!( + start_line <= end_line, + "fold start_line must be <= end_line" + ); + Self { + start_line, + end_line, + } + } +} diff --git a/vendor/gpui-base/src/input/editor/display_map/mod.rs b/vendor/gpui-base/src/input/editor/display_map/mod.rs new file mode 100644 index 00000000..b5afe681 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/mod.rs @@ -0,0 +1,66 @@ +/// Display mapping system for Editor/Input. +/// +/// This module implements a layered display mapping architecture: +/// - **WrapMap**: Handles soft-wrapping (buffer → wrap rows) +/// - **FoldMap**: Handles folding (wrap rows → display rows) +/// - **DisplayMap**: Public facade for Editor/Input +/// +/// The goal is to provide a clean, unified API where Editor only needs to know +/// about `BufferPoint ↔ DisplayPoint` mapping, without worrying about internal wrap/fold complexity. +mod display_map; +mod fold_map; +mod folding; +mod text_wrapper; +mod wrap_map; + +// Re-export public API +pub use self::display_map::{DisplayMap, WrappingIndent}; +pub(crate) use self::text_wrapper::LineLayout; + +// Re-export FoldRange and extract_fold_ranges +pub use folding::FoldRange; + +/// Position in the buffer (logical text). +/// +/// - `line`: 0-based logical line number (split by `\n`) +/// - `col`: 0-based column offset (byte offset) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BufferPoint { + pub line: usize, + pub col: usize, +} + +impl BufferPoint { + pub fn new(line: usize, col: usize) -> Self { + Self { line, col } + } +} + +/// Position after soft-wrapping but before folding (internal). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) struct WrapPoint { + pub row: usize, + pub col: usize, +} + +impl WrapPoint { + pub(super) fn new(row: usize, col: usize) -> Self { + Self { row, col } + } +} + +/// Final display position (after soft-wrapping and folding). +/// +/// - `row`: 0-based display row (final visible row) +/// - `col`: 0-based display column +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DisplayPoint { + pub row: usize, + pub col: usize, +} + +impl DisplayPoint { + pub fn new(row: usize, col: usize) -> Self { + Self { row, col } + } +} diff --git a/vendor/gpui-base/src/input/editor/display_map/text_wrapper.rs b/vendor/gpui-base/src/input/editor/display_map/text_wrapper.rs new file mode 100644 index 00000000..768f754c --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/text_wrapper.rs @@ -0,0 +1,1363 @@ +use gpui::Half; +use std::borrow::Cow; +use std::ops::Range; + +use gpui::{ + App, Font, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, size, +}; +use ropey::Rope; +use smallvec::SmallVec; +use sum_tree::{Bias, Dimensions, SumTree}; + +use crate::input::{ + Point as TreeSitterPoint, RopeExt, + layout::{LastLayout, WhitespaceIndicators}, +}; + +/// Controls how soft-wrapped continuation lines are indented. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WrappingIndent { + /// Continuation lines start flush-left at the full editor width. + None, + /// Continuation lines keep the same indentation as the first line. + #[default] + Same, +} + +/// A line with soft wrapped lines info. +#[derive(Debug, Clone)] +pub(crate) struct LineItem { + /// The byte length of the line, without the end `\n`. + len: usize, + /// Number of leading characters of the line reserved as indentation for continuation wrapped + /// lines, when [`WrappingIndent::Same`] is used. + /// + /// Zero when [`WrappingIndent::None`] is used or the line is not wrapped. + pub(crate) indent: u32, + /// The soft wrapped lines relative byte range (0..len) of this line (Include first line). + /// + /// Not contains the line end `\n`. + pub(crate) wrapped_lines: SmallVec<[Range; 1]>, +} + +impl LineItem { + /// Get the bytes length of this line. + #[inline] + pub(crate) fn len(&self) -> usize { + self.len + } + + /// Get number of soft wrapped lines of this line (include the first line). + #[inline] + pub(crate) fn lines_len(&self) -> usize { + self.wrapped_lines.len() + } +} + +/// Summary of a subtree of [`LineItem`]s, maintained incrementally by the [`SumTree`]. +#[derive(Debug, Clone)] +pub(crate) struct LineSummary { + /// Number of buffer lines. + buffer_rows: usize, + /// Number of wrap rows (sum of each line's `lines_len()`). + wrap_rows: usize, + /// Sum of byte lengths of the buffer lines (without the trailing `\n`). + bytes: usize, + /// Byte length of the longest line in this subtree. + max_line_len: usize, + /// Buffer row (relative to this subtree) of the first line achieving `max_line_len`. + longest_row: usize, +} + +impl sum_tree::Summary for LineSummary { + type Context<'a> = &'a (); + + fn zero(_: &()) -> Self { + LineSummary { + buffer_rows: 0, + wrap_rows: 0, + bytes: 0, + max_line_len: 0, + longest_row: 0, + } + } + + fn add_summary(&mut self, other: &Self, _: &()) { + // Keep the leftmost row that achieves a strictly greater length + if other.max_line_len > self.max_line_len { + self.longest_row = self.buffer_rows + other.longest_row; + self.max_line_len = other.max_line_len; + } + self.buffer_rows += other.buffer_rows; + self.wrap_rows += other.wrap_rows; + self.bytes += other.bytes; + } +} + +impl sum_tree::Item for LineItem { + type Summary = LineSummary; + + fn summary(&self, _: &()) -> LineSummary { + LineSummary { + buffer_rows: 1, + wrap_rows: self.lines_len(), + bytes: self.len(), + max_line_len: self.len(), + longest_row: 0, + } + } +} + +/// Cursor dimension counting buffer rows. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct BufferRows(pub usize); + +impl<'a> sum_tree::Dimension<'a, LineSummary> for BufferRows { + fn zero(_: &()) -> Self { + BufferRows(0) + } + + fn add_summary(&mut self, summary: &'a LineSummary, _: &()) { + self.0 += summary.buffer_rows; + } +} + +/// Cursor dimension counting wrap rows. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct WrapRows(pub usize); + +impl<'a> sum_tree::Dimension<'a, LineSummary> for WrapRows { + fn zero(_: &()) -> Self { + WrapRows(0) + } + + fn add_summary(&mut self, summary: &'a LineSummary, _: &()) { + self.0 += summary.wrap_rows; + } +} + +/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor. +/// +/// After use lines to calculate the scroll size of the Editor. +pub(crate) struct TextWrapper { + text: Rope, + font: Font, + font_size: Pixels, + /// If is none, it means the text is not wrapped + wrap_width: Option, + wrapping_indent: WrappingIndent, + /// The lines by split \n + pub(crate) lines: SumTree, + + _initialized: bool, +} + +#[allow(unused)] +impl TextWrapper { + pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + text: Rope::new(), + font, + font_size, + wrap_width, + wrapping_indent: WrappingIndent::default(), + lines: SumTree::new(&()), + _initialized: false, + } + } + + #[inline] + pub(crate) fn set_default_text(&mut self, text: &Rope) { + self.text = text.clone(); + } + + /// Get reference to the rope text. + #[inline] + pub(crate) fn text(&self) -> &Rope { + &self.text + } + + /// Get the total number of lines including wrapped lines. + #[inline] + pub(crate) fn len(&self) -> usize { + self.lines.summary().wrap_rows + } + + /// Get the total number of buffer lines. + #[inline] + pub(crate) fn lines_count(&self) -> usize { + self.lines.summary().buffer_rows + } + + /// Get the 0-based row index of the longest line (by byte length). + #[inline] + pub(crate) fn longest_row(&self) -> usize { + self.lines.summary().longest_row + } + + /// Get the line item by buffer row index. + #[inline] + pub(crate) fn line(&self, row: usize) -> Option<&LineItem> { + let mut cursor = self.lines.cursor::(&()); + cursor.seek(&BufferRows(row), Bias::Right); + cursor.item() + } + + /// Iterate buffer lines in order. + #[inline] + pub(crate) fn iter_lines(&self) -> impl Iterator { + self.lines.iter() + } + + /// First wrap row of buffer line `row`. Returns the total wrap row count if `row` is + /// out of range. + pub(crate) fn buffer_line_to_first_wrap_row(&self, row: usize) -> usize { + let mut cursor = self.lines.cursor::>(&()); + cursor.seek(&BufferRows(row), Bias::Right); + cursor.start().1.0 + } + + /// Wrap row range of buffer line `row`. + pub(crate) fn buffer_line_to_wrap_row_range(&self, row: usize) -> Range { + let mut cursor = self.lines.cursor::>(&()); + cursor.seek(&BufferRows(row), Bias::Right); + let start = cursor.start().1.0; + let len = cursor.item().map(|l| l.lines_len()).unwrap_or(0); + start..start + len + } + + /// Buffer line containing wrap row `wrap_row`, clamped to the last line. + pub(crate) fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize { + let mut cursor = self.lines.cursor::>(&()); + cursor.seek(&WrapRows(wrap_row), Bias::Right); + match cursor.item() { + Some(_) => cursor.start().1.0, + None => self.lines_count().saturating_sub(1), + } + } + + pub(crate) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { + if wrap_width == self.wrap_width { + return; + } + + self.wrap_width = wrap_width; + self.update_all(&self.text.clone(), cx); + } + + pub(crate) fn set_wrapping_indent(&mut self, wrapping_indent: WrappingIndent, cx: &mut App) { + if wrapping_indent == self.wrapping_indent { + return; + } + + self.wrapping_indent = wrapping_indent; + self.update_all(&self.text.clone(), cx); + } + + pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + if self.font.eq(&font) && self.font_size == font_size { + return; + } + + self.font = font; + self.font_size = font_size; + self.update_all(&self.text.clone(), cx); + } + + pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool { + if self._initialized { + return false; + } + self._initialized = true; + self.update_all(text, cx); + true + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + /// + /// - `changed_text`: The text [`Rope`] that has changed. + /// - `range`: The `selected_range` before change. + /// - `new_text`: The inserted text. + /// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same. + /// - `cx`: The application context. + pub(crate) fn update( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + cx: &mut App, + ) { + let mut line_wrapper = cx + .text_system() + .line_wrapper(self.font.clone(), self.font_size); + self._update( + changed_text, + range, + new_text, + &mut |line_str, wrap_width| { + line_wrapper + .wrap_line(&[LineFragment::text(line_str)], wrap_width) + .collect() + }, + ); + } + + fn _update( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + wrap_line: &mut F, + ) where + F: FnMut(&str, Pixels) -> Vec, + { + // Remove the old changed lines. + let buffer_line_count = self.lines_count(); + let start_row = self.text.offset_to_point(range.start).row; + let start_row = start_row.min(buffer_line_count.saturating_sub(1)); + let end_row = self.text.offset_to_point(range.end).row; + let end_row = end_row.min(buffer_line_count.saturating_sub(1)); + + // To add the new lines. + let new_start_row = changed_text.offset_to_point(range.start).row; + let new_end_row = changed_text + .offset_to_point(range.start + new_text.len()) + .row; + + let mut new_lines = Vec::with_capacity(new_end_row.saturating_sub(new_start_row) + 1); + let wrap_width = self.wrap_width; + + // line not contains `\n`. + for row in new_start_row..=new_end_row { + let line = changed_text.slice_line(row); + let mut wrapped_lines = SmallVec::<[Range; 1]>::new(); + let mut prev_boundary_ix = 0; + let mut indent_chars = 0; + + // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap + if let Some(wrap_width) = wrap_width { + // Borrowed for lines within a single rope chunk. + let line_str: Cow = line.into(); + match self.wrapping_indent { + WrappingIndent::Same => { + // Here only have wrapped line, if there is no wrap meet, the `line_wraps` + // result will empty. + for boundary in wrap_line(&line_str, wrap_width) { + wrapped_lines.push(prev_boundary_ix..boundary.ix); + prev_boundary_ix = boundary.ix; + indent_chars = boundary.next_indent; + } + } + WrappingIndent::None => { + // The first visual line keeps the line's leading indentation, so it is + // wrapped as is. + let boundaries = wrap_line(&line_str, wrap_width); + if let Some(first_ix) = boundaries.first().map(|b| b.ix) { + wrapped_lines.push(prev_boundary_ix..first_ix); + prev_boundary_ix = first_ix; + + for boundary in wrap_line(&line_str[first_ix..], wrap_width) { + let ix = first_ix + boundary.ix; + wrapped_lines.push(prev_boundary_ix..ix); + prev_boundary_ix = ix; + } + } + } + } + } + + // Reset of the line + if prev_boundary_ix < line.len() || prev_boundary_ix == 0 { + wrapped_lines.push(prev_boundary_ix..line.len()); + } + + new_lines.push(LineItem { + len: line.len(), + indent: indent_chars, + wrapped_lines, + }); + } + + if self.lines.is_empty() { + self.lines = SumTree::from_iter(new_lines, &()); + } else { + let mut cursor = self.lines.cursor::(&()); + let mut new_tree = cursor.slice(&BufferRows(start_row), Bias::Right); + // Skip the replaced rows + cursor.seek_forward(&BufferRows(end_row + 1), Bias::Right); + new_tree.extend(new_lines, &()); + // Untouched rows after the edit + new_tree.append(cursor.suffix(), &()); + drop(cursor); + self.lines = new_tree; + } + + self.text = changed_text.clone(); + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + fn update_all(&mut self, text: &Rope, cx: &mut App) { + self.update(text, &(0..text.len()), &text, cx); + } + + /// Return display point (with soft wrap) from the given byte offset in the text. + /// + /// Panics if the `offset` is out of bounds. + pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint { + let row = self.text.offset_to_point(offset).row; + let start = self.text.line_start_offset(row); + + // Seek to buffer row + let mut cursor = self.lines.cursor::>(&()); + cursor.seek(&BufferRows(row), Bias::Right); + let wrapped_row = cursor.start().1.0; + let Some(line) = cursor.item() else { + return WrapDisplayPoint::new(wrapped_row, 0, 0); + }; + + let local_offset = offset.saturating_sub(start); + for (ix, range) in line.wrapped_lines.iter().enumerate() { + if range.contains(&local_offset) { + return WrapDisplayPoint::new( + wrapped_row + ix, + ix, + local_offset.saturating_sub(range.start), + ); + } + } + + // Otherwise return the eof of the line. + let last_range = line.wrapped_lines.last().unwrap_or(&(0..0)); + let ix = line.lines_len().saturating_sub(1); + return WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len()); + } + + /// Return byte offset in the text from the given display point (with soft wrap). + /// + /// Panics if the `point.row` is out of bounds. + pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { + // Seek to wrap row `point.row` + let mut cursor = self.lines.cursor::>(&()); + cursor.seek(&WrapRows(point.row), Bias::Right); + let Some(line) = cursor.item() else { + return self.text.len(); + }; + let wrapped_row = cursor.start().0.0; + let row = cursor.start().1.0; + + let line_start = self.text.line_start_offset(row); + let local_row = point.row.saturating_sub(wrapped_row); + if let Some(range) = line.wrapped_lines.get(local_row) { + line_start + (range.start + point.column).min(range.end) + } else { + // If not found, return the end of the line. + line_start + line.len() + } + } + + pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { + let offset = self.display_point_to_offset(point); + self.text.offset_to_point(offset) + } + + pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint { + let offset = self.text.point_to_offset(point); + self.offset_to_display_point(offset) + } +} + +/// A display point within the soft-wrapped text. +/// +/// This represents a position in the text after soft-wrapping, +/// with an additional `local_row` field tracking the wrap line +/// within the original buffer line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WrapDisplayPoint { + /// The 0-based soft wrapped row index in the text. + pub row: usize, + /// The 0-based row index in local line (include first line). + /// + /// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored. + pub local_row: usize, + /// The 0-based column byte index in the display line (with soft wrap). + pub column: usize, +} + +impl WrapDisplayPoint { + pub(crate) fn new(row: usize, local_row: usize, column: usize) -> Self { + Self { + row, + local_row, + column, + } + } +} + +/// The layout info of a line with soft wrapped lines. +pub(crate) struct LineLayout { + /// Total bytes length of this line. + len: usize, + /// The soft wrapped lines of this line (Include the first line). + pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>, + /// Extra left offset applied to continuation wrapped lines, used to reserve the first line's + /// indentation when [`WrappingIndent::Same`] is used. + pub(crate) wrap_indent: Pixels, + pub(crate) longest_width: Pixels, + pub(crate) whitespace_indicators: Option, + /// Whitespace indicators: (line_index, x_position, is_tab) + pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>, +} + +impl LineLayout { + pub(crate) fn new() -> Self { + Self { + len: 0, + longest_width: px(0.), + wrapped_lines: SmallVec::new(), + wrap_indent: px(0.), + whitespace_chars: Vec::new(), + whitespace_indicators: None, + } + } + + /// Set the left offset reserved for continuation wrapped lines. + pub(crate) fn wrap_indent(mut self, wrap_indent: Pixels) -> Self { + self.wrap_indent = wrap_indent; + self + } + + /// The pixel indent applied to the given visual line, relative to the line's + /// leading text. Only continuation lines (index > 0) are indented. + #[inline] + fn line_indent(&self, line_index: usize) -> Pixels { + if line_index == 0 { + px(0.) + } else { + self.wrap_indent + } + } + + pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self { + self.set_wrapped_lines(wrapped_lines); + self + } + + pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) { + self.len = wrapped_lines.iter().map(|l| l.len).sum(); + let width = wrapped_lines + .iter() + .map(|l| l.width) + .max() + .unwrap_or_default(); + self.longest_width = width; + self.wrapped_lines = wrapped_lines; + } + + pub(crate) fn with_whitespaces(mut self, indicators: Option) -> Self { + self.whitespace_indicators = indicators; + let Some(indicators) = self.whitespace_indicators.as_ref() else { + return self; + }; + + let space_indicator_offset = indicators.space.width.half(); + + for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() { + for (relative_offset, c) in wrapped_line.text.char_indices() { + if matches!(c, ' ' | '\t') { + let is_tab = c == '\t'; + let start_x = wrapped_line.x_for_index(relative_offset); + let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8()); + // Center the indicator in the actual character's space + let x_position = if c == ' ' { + (start_x + end_x).half() - space_indicator_offset + } else { + start_x + }; + + self.whitespace_chars.push((line_index, x_position, is_tab)); + } + } + } + self + } + + #[inline] + pub(crate) fn len(&self) -> usize { + self.len + } + + /// Get the position (x, y) for the given index in this line layout. + /// + /// - The `offset` is a local byte index in this line layout. + /// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at + /// the end of the current visual line rather than the start of the next one. + /// - The return value is relative to the top-left corner of this line layout, start from (0, 0) + pub(crate) fn position_for_index( + &self, + offset: usize, + last_layout: &LastLayout, + line_end_affinity: bool, + ) -> Option> { + let mut acc_len = 0; + let mut offset_y = px(0.); + + let x_offset = last_layout.alignment_offset(self.longest_width); + + for (i, line) in self.wrapped_lines.iter().enumerate() { + let is_last = i + 1 == self.wrapped_lines.len(); + + let matches = if line.len == 0 { + // Empty visual lines still own their boundary offset. + offset == acc_len + } else if is_last || line_end_affinity { + // Inclusive: cursor can sit at end of this visual line. + offset >= acc_len && offset <= acc_len + line.len + } else { + // Exclusive: boundary offset belongs to the next visual line. + offset >= acc_len && offset < acc_len + line.len + }; + + if matches { + let x = line.x_for_index(offset.saturating_sub(acc_len)) + + x_offset + + self.line_indent(i); + return Some(point(x, offset_y)); + } + + // Always advance by actual line length. The last line gets +1 so the + // cursor can be placed after the final character. + acc_len += if is_last { line.len + 1 } else { line.len }; + offset_y += last_layout.line_height; + } + + None + } + + /// Get the closest index for the given x in this line layout. + pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize { + let mut acc_len = 0; + let x_offset = last_layout.alignment_offset(self.longest_width); + let x = x - x_offset; + + for (i, line) in self.wrapped_lines.iter().enumerate() { + let is_last = i + 1 == self.wrapped_lines.len(); + let line_indent = self.line_indent(i); + if x <= line_indent + line.width { + let mut ix = line.closest_index_for_x(x - line_indent); + if !is_last && ix == line.text.len() { + // For soft wrap line, we can't put the cursor at the end of the line. + let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); + ix = ix.saturating_sub(c_len); + } + + return acc_len + ix; + } + acc_len += line.text.len(); + } + + acc_len + } + + /// Get the index for the given position (x, y) in this line layout. + /// + /// The `pos` is relative to the top-left corner of this line layout, start from (0, 0) + /// The return value is a local byte index in this line layout, start from 0. + pub(crate) fn closest_index_for_position( + &self, + pos: Point, + last_layout: &LastLayout, + ) -> Option { + let mut offset = 0; + let mut line_top = px(0.); + let x_offset = last_layout.alignment_offset(self.longest_width); + for (i, line) in self.wrapped_lines.iter().enumerate() { + let is_last = i + 1 == self.wrapped_lines.len(); + let line_bottom = line_top + last_layout.line_height; + if pos.y >= line_top && pos.y < line_bottom { + let mut ix = line.closest_index_for_x(pos.x - x_offset - self.line_indent(i)); + if !is_last && ix == line.text.len() { + // For soft wrap line, we can't put the cursor at the end of the line. + let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); + ix = ix.saturating_sub(c_len); + } + return Some(offset + ix); + } + + offset += line.text.len(); + line_top = line_bottom; + } + + None + } + + pub(crate) fn index_for_position( + &self, + pos: Point, + last_layout: &LastLayout, + ) -> Option { + let mut offset = 0; + let mut line_top = px(0.); + let x_offset = last_layout.alignment_offset(self.longest_width); + for (i, line) in self.wrapped_lines.iter().enumerate() { + let line_bottom = line_top + last_layout.line_height; + if pos.y >= line_top && pos.y < line_bottom { + let ix = line.index_for_x(pos.x - x_offset - self.line_indent(i))?; + return Some(offset + ix); + } + + offset += line.text.len(); + line_top = line_bottom; + } + + None + } + + pub(crate) fn size(&self, line_height: Pixels) -> Size { + let width = self + .wrapped_lines + .iter() + .enumerate() + .map(|(ix, line)| line.width + self.line_indent(ix)) + .max() + .unwrap_or(self.longest_width); + size(width, self.wrapped_lines.len() * line_height) + } + + pub(crate) fn paint( + &self, + pos: Point, + line_height: Pixels, + text_align: TextAlign, + align_width: Option, + window: &mut Window, + cx: &mut App, + ) { + for (ix, line) in self.wrapped_lines.iter().enumerate() { + _ = line.paint( + pos + point(self.line_indent(ix), ix * line_height), + line_height, + text_align, + align_width, + window, + cx, + ); + } + + // Paint whitespace indicators + if let Some(indicators) = self.whitespace_indicators.as_ref() { + for (line_index, x_position, is_tab) in &self.whitespace_chars { + let invisible = if *is_tab { + indicators.tab.clone() + } else { + indicators.space.clone() + }; + + let origin = point( + pos.x + *x_position + self.line_indent(*line_index), + pos.y + *line_index as f32 * line_height, + ); + + _ = invisible.paint(origin, line_height, text_align, align_width, window, cx); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + + use gpui::{Boundary, FontFeatures, FontStyle, FontWeight, px}; + + #[test] + fn test_update() { + let font = gpui::Font { + family: "Arial".into(), + weight: FontWeight::default(), + style: FontStyle::Normal, + features: FontFeatures::default(), + fallbacks: None, + }; + + let mut wrapper = TextWrapper::new(font, px(14.), None); + let mut text = Rope::from( + "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。", + ); + + fn fake_wrap_line(_line: &str, _wrap_width: Pixels) -> Vec { + vec![] + } + + #[track_caller] + fn assert_wrapper_lines(text: &Rope, wrapper: &TextWrapper, expected_lines: &[&[&str]]) { + let mut actual_lines = vec![]; + let mut offset = 0; + for line in wrapper.iter_lines() { + actual_lines.push( + line.wrapped_lines + .iter() + .map(|range| text.slice(offset + range.start..offset + range.end)) + .collect::>(), + ); + // +1 \n + offset += line.len() + 1; + } + assert_eq!(actual_lines, expected_lines); + } + + wrapper._update(&text, &(0..text.len()), &text, &mut fake_wrap_line); + assert_eq!(wrapper.lines_count(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["Hello, 世界!\r"], + &["This is second line."], + &["This is third line."], + &["这里是第 4 行。"], + ], + ); + + // Add a new text to end + let range = text.len()..text.len(); + let new_text = "New text"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines_count(), 4); + assert_eq!(wrapper.lines_count(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["Hello, 世界!\r"], + &["This is second line."], + &["This is third line."], + &["这里是第 4 行。New text"], + ], + ); + + // Replace first line `Hello` to `AAA` + let range = 0..5; + let new_text = "AAA"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "AAA, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines_count(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["AAA, 世界!\r"], + &["This is second line."], + &["This is third line."], + &["这里是第 4 行。New text"], + ], + ); + + // Remove the second line + let start_offset = text.line_start_offset(1); + let end_offset = text.line_end_offset(1); + let range = start_offset..end_offset + 1; + text.replace(range.clone(), ""); + wrapper._update(&text, &range, &Rope::from(""), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "AAA, 世界!\r\nThis is third line.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines_count(), 3); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["AAA, 世界!\r"], + &["This is third line."], + &["这里是第 4 行。New text"], + ], + ); + + // Replace the first 2 lines to "This is a new line." + let range = text.line_start_offset(0)..text.line_end_offset(1) + 1; + let new_text = "This is a new line.\nThis is new line 2.\n"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a new line.\nThis is new line 2.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines_count(), 3); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["This is a new line."], + &["This is new line 2."], + &["这里是第 4 行。New text"], + ], + ); + + // Add a new line at the end + let range = text.len()..text.len(); + let new_text = "\nThis is a new line at the end."; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a new line.\nThis is new line 2.\n这里是第 4 行。New text\nThis is a new line at the end." + ); + assert_eq!(wrapper.lines_count(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["This is a new line."], + &["This is new line 2."], + &["这里是第 4 行。New text"], + &["This is a new line at the end."], + ], + ); + + // Add a new line at the beginning + let range = 0..0; + let new_text = "This is a new line at the beginning.\n"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a new line at the beginning.\nThis is a new line.\nThis is new line 2.\n这里是第 4 行。New text\nThis is a new line at the end." + ); + assert_eq!(wrapper.lines_count(), 5); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["This is a new line at the beginning."], + &["This is a new line."], + &["This is new line 2."], + &["这里是第 4 行。New text"], + &["This is a new line at the end."], + ], + ); + + // Remove all to at least one line in `lines`. + let range = 0..text.len(); + let new_text = ""; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!(text.to_string(), ""); + assert_eq!(wrapper.lines_count(), 1); + assert_eq!(wrapper.line(0).unwrap().wrapped_lines.as_slice(), [0..0]); + + // Test update_all + let range = 0..text.len(); + let new_text = "This is a full text.\nThis is a second line."; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &text, &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a full text.\nThis is a second line." + ); + assert_eq!(wrapper.lines_count(), 2); + } + + fn test_font() -> gpui::Font { + gpui::Font { + family: "Arial".into(), + weight: FontWeight::default(), + style: FontStyle::Normal, + features: FontFeatures::default(), + fallbacks: None, + } + } + + /// The longest-row summary stays exact when the previously-longest line is shrunk. + #[test] + fn test_longest_row_after_shrink() { + let mut wrapper = TextWrapper::new(test_font(), px(14.), None); + let mut text = Rope::from("aa\nthis is the longest line\nbb"); + wrapper._update(&text, &(0..text.len()), &text, &mut |_, _| vec![]); + assert_eq!(wrapper.longest_row(), 1); + + // Shrink line 1 so line 2-equivalent isn't longest. + // Make line 0 the longest now. + let start = text.line_start_offset(0); + let end = text.line_end_offset(0); + let range = start..end; + let new_text = "a very very long first line now"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut |_, _| vec![]); + assert_eq!(wrapper.longest_row(), 0); + } + + /// Editing the last line and deleting everything must keep the tree consistent. + #[test] + fn test_edit_last_line_and_full_delete() { + let mut wrapper = TextWrapper::new(test_font(), px(14.), None); + let mut text = Rope::from("one\ntwo\nthree"); + wrapper._update(&text, &(0..text.len()), &text, &mut |_, _| vec![]); + assert_eq!(wrapper.lines_count(), 3); + + // Replace the last line only. + let start = text.line_start_offset(2); + let range = start..text.len(); + let new_text = "THREE EDITED"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut |_, _| vec![]); + assert_eq!(wrapper.lines_count(), 3); + assert_eq!(wrapper.line(2).unwrap().len(), "THREE EDITED".len()); + + // Delete everything. + let range = 0..text.len(); + text.replace(range.clone(), ""); + wrapper._update(&text, &range, &Rope::from(""), &mut |_, _| vec![]); + assert_eq!(wrapper.lines_count(), 1); + assert_eq!(wrapper.len(), 1); + assert_eq!(wrapper.line(0).unwrap().wrapped_lines.as_slice(), [0..0]); + } + + #[test] + fn test_wrap_row_buffer_line_boundaries() { + let mut wrapper = TextWrapper::new(test_font(), px(14.), None); + wrapper.text = Rope::from("aa\nbbbb\nc"); + wrapper.lines = SumTree::from_iter( + vec![ + LineItem { + len: 2, + indent: 0, + wrapped_lines: smallvec::smallvec![0..2], + }, + LineItem { + len: 4, + indent: 0, + wrapped_lines: smallvec::smallvec![0..2, 2..4], + }, + LineItem { + len: 1, + indent: 0, + wrapped_lines: smallvec::smallvec![0..1], + }, + ], + &(), + ); + + assert_eq!(wrapper.lines_count(), 3); + assert_eq!(wrapper.len(), 4); + + assert_eq!(wrapper.buffer_line_to_first_wrap_row(0), 0); + assert_eq!(wrapper.buffer_line_to_first_wrap_row(1), 1); + assert_eq!(wrapper.buffer_line_to_first_wrap_row(2), 3); + assert_eq!(wrapper.buffer_line_to_first_wrap_row(3), 4); + + assert_eq!(wrapper.buffer_line_to_wrap_row_range(0), 0..1); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(1), 1..3); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(2), 3..4); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(3), 4..4); + + assert_eq!(wrapper.wrap_row_to_buffer_line(0), 0); + assert_eq!(wrapper.wrap_row_to_buffer_line(1), 1); + assert_eq!(wrapper.wrap_row_to_buffer_line(2), 1); + assert_eq!(wrapper.wrap_row_to_buffer_line(3), 2); + assert_eq!(wrapper.wrap_row_to_buffer_line(4), 2); + } + + #[test] + fn test_wrap_row_queries_after_incremental_splice() { + let mut wrapper = TextWrapper::new(test_font(), px(14.), Some(px(10.))); + let mut text = Rope::from("aa\nbbbb\nc"); + let mut fake_wrap_line = |line: &str, _wrap_width: Pixels| { + if line.len() > 2 { + vec![Boundary { + ix: 2, + next_indent: 0, + }] + } else { + vec![] + } + }; + + wrapper._update(&text, &(0..text.len()), &text, &mut fake_wrap_line); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(0), 0..1); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(1), 1..3); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(2), 3..4); + + let range = text.line_start_offset(1)..text.line_end_offset(1); + let new_text = "dd\neeee"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + + assert_eq!(wrapper.lines_count(), 4); + assert_eq!(wrapper.len(), 5); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(0), 0..1); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(1), 1..2); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(2), 2..4); + assert_eq!(wrapper.buffer_line_to_wrap_row_range(3), 4..5); + assert_eq!(wrapper.wrap_row_to_buffer_line(0), 0); + assert_eq!(wrapper.wrap_row_to_buffer_line(1), 1); + assert_eq!(wrapper.wrap_row_to_buffer_line(2), 2); + assert_eq!(wrapper.wrap_row_to_buffer_line(3), 2); + assert_eq!(wrapper.wrap_row_to_buffer_line(4), 3); + } + + #[test] + fn test_line_layout() { + let mut line_layout = LineLayout::new(); + + let line1 = ShapedLine::default().with_len(100); + let line2 = ShapedLine::default().with_len(50); + let wrapped_lines = smallvec::smallvec![line1, line2]; + line_layout.set_wrapped_lines(wrapped_lines); + assert_eq!(line_layout.len(), 150); + assert_eq!(line_layout.wrapped_lines.len(), 2); + } + + #[test] + fn test_position_for_index_prefers_first_leading_empty_visual_line() { + let mut line_layout = LineLayout::new(); + line_layout.set_wrapped_lines(smallvec::smallvec![ + ShapedLine::default(), + ShapedLine::default(), + ShapedLine::default().with_len(3), + ]); + + let last_layout = LastLayout { + visible_range: 0..1, + visible_buffer_lines: vec![0], + visible_line_byte_offsets: vec![0], + visible_top: px(0.), + visible_range_offset: 0..0, + lines: Rc::new(vec![]), + line_height: px(20.), + wrap_width: None, + wrapping_indent: WrappingIndent::default(), + line_number_width: px(0.), + cursor_bounds: None, + text_align: TextAlign::Left, + content_width: px(0.), + }; + + assert_eq!( + line_layout.position_for_index(0, &last_layout, false), + Some(point(px(0.), px(0.))) + ); + } + + #[test] + fn test_offset_to_display_point() { + let font = gpui::Font { + family: "Arial".into(), + weight: FontWeight::default(), + style: FontStyle::Normal, + features: FontFeatures::default(), + fallbacks: None, + }; + + let mut wrapper = TextWrapper::new(font, px(14.), None); + wrapper.text = Rope::from( + "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。", + ); + wrapper.lines = SumTree::from_iter( + vec![ + // range: 0..15 + LineItem { + len: Rope::from("Hello, 世界!\r").len(), + indent: 0, + wrapped_lines: smallvec::smallvec![0..15], + }, + // range: 16..36 + LineItem { + len: Rope::from("This is second line.\n").len(), + indent: 0, + wrapped_lines: smallvec::smallvec![0..10, 10..20], + }, + // range: 37..56 + LineItem { + len: Rope::from("This is third line.\n").len(), + indent: 0, + wrapped_lines: smallvec::smallvec![0..9, 9..15, 15..20], + }, + // range: 57..79 + LineItem { + len: Rope::from("这里是第 4 行。").len(), + indent: 0, + wrapped_lines: smallvec::smallvec![0..22], + }, + ], + &(), + ); + + assert_eq!( + wrapper.offset_to_display_point(12), + WrapDisplayPoint::new(0, 0, 12) + ); + assert_eq!( + wrapper.offset_to_display_point(15), + WrapDisplayPoint::new(0, 0, 15) + ); + + assert_eq!( + wrapper.offset_to_display_point(16), + WrapDisplayPoint::new(1, 0, 0) + ); + assert_eq!( + wrapper.offset_to_display_point(21), + WrapDisplayPoint::new(1, 0, 5) + ); + assert_eq!( + wrapper.offset_to_display_point(27), + WrapDisplayPoint::new(2, 1, 1) + ); + assert_eq!( + wrapper.offset_to_display_point(37), + WrapDisplayPoint::new(3, 0, 0) + ); + assert_eq!( + wrapper.offset_to_display_point(54), + WrapDisplayPoint::new(5, 2, 2) + ); + assert_eq!( + wrapper.offset_to_display_point(59), + WrapDisplayPoint::new(6, 0, 2) + ); + + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(6, 0, 2)), + 59 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(5, 2, 2)), + 54 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(3, 0, 0)), + 37 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(2, 1, 1)), + 27 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(1, 0, 5)), + 21 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(1, 0, 0)), + 16 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(0, 0, 15)), + 15 + ); + } + + #[test] + fn test_wrapping_indent_same_keeps_indent_reserved() { + let mut wrapper = TextWrapper::new(test_font(), px(14.0), Some(px(10.))); + wrapper.wrapping_indent = WrappingIndent::Same; + let text = Rope::from(" abcdefghijklmnopqrstuv"); + let mut fake_wrap_line = |line: &str, _wrap_width: Pixels| { + if line.starts_with(' ') { + vec![Boundary { + ix: 5, + next_indent: 2, + }] + } else { + let mut boundaries = vec![]; + let mut i = 8; + while i < line.len() { + boundaries.push(Boundary { + ix: i, + next_indent: 0, + }); + i += 8; + } + boundaries + } + }; + + wrapper._update(&text, &(0..text.len()), &text, &mut fake_wrap_line); + + let line = wrapper.line(0).unwrap(); + assert_eq!(line.indent, 2); + assert_eq!(line.wrapped_lines.as_slice(), [0..5, 5..24]); + } + + #[test] + fn test_wrapping_indent_none_continuation_lines_wrapped_at_full_width() { + let mut wrapper = TextWrapper::new(test_font(), px(14.0), Some(px(10.))); + wrapper.wrapping_indent = WrappingIndent::None; + let text = Rope::from(" abcdefghijklmnopqrstuv"); + let mut fake_wrap_line = |line: &str, _wrap_width: Pixels| { + if line.starts_with(' ') { + vec![Boundary { + ix: 5, + next_indent: 2, + }] + } else { + let mut boundaries = vec![]; + let mut i = 8; + while i < line.len() { + boundaries.push(Boundary { + ix: i, + next_indent: 0, + }); + i += 8; + } + boundaries + } + }; + + wrapper._update(&text, &(0..text.len()), &text, &mut fake_wrap_line); + + let line = wrapper.line(0).unwrap(); + assert_eq!(line.indent, 0); + assert_eq!(line.wrapped_lines.as_slice(), [0..5, 5..13, 13..21, 21..24]); + } + + #[test] + fn test_wrap_indent_offsets_continuation_lines() { + let mut line_layout = LineLayout::new(); + line_layout.set_wrapped_lines(smallvec::smallvec![ + ShapedLine::default().with_len(5), + ShapedLine::default().with_len(10), + ]); + + line_layout = line_layout.wrap_indent(px(20.0)); + + let last_layout = LastLayout { + visible_range: 0..1, + visible_buffer_lines: vec![0], + visible_line_byte_offsets: vec![0], + visible_top: px(0.), + visible_range_offset: 0..0, + lines: Rc::new(vec![]), + line_height: px(20.0), + wrap_width: Some(px(10.)), + wrapping_indent: WrappingIndent::Same, + line_number_width: px(0.), + cursor_bounds: None, + text_align: TextAlign::Left, + content_width: px(0.), + }; + + assert_eq!( + line_layout.position_for_index(0, &last_layout, false), + Some(point(px(0.), px(0.))), + ); + + assert_eq!( + line_layout.position_for_index(6, &last_layout, false), + Some(point(px(20.), px(20.))), + ) + } +} diff --git a/vendor/gpui-base/src/input/editor/display_map/wrap_map.rs b/vendor/gpui-base/src/input/editor/display_map/wrap_map.rs new file mode 100644 index 00000000..cf593eb4 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/display_map/wrap_map.rs @@ -0,0 +1,162 @@ +/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows). +/// +/// This module wraps the existing TextWrapper and provides: +/// - BufferPoint ↔ WrapPoint mapping +/// - Efficient buffer_line → wrap_row queries via prefix sum cache +/// - Incremental updates when text or layout changes +use std::ops::Range; + +use gpui::{App, Font, Pixels}; +use ropey::Rope; + +use super::fold_map::FoldMap; +use super::text_wrapper::{LineItem, TextWrapper, WrapDisplayPoint, WrappingIndent}; +use super::{BufferPoint, WrapPoint}; +use crate::input::RopeExt; + +/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping. +/// +/// Buffer line ↔ wrap row mapping is backed by the [`TextWrapper`]'s `SumTree`. +pub(super) struct WrapMap { + /// The underlying text wrapper (reuses existing implementation) + wrapper: TextWrapper, +} + +impl WrapMap { + pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + wrapper: TextWrapper::new(font, font_size, wrap_width), + } + } + + /// Get total number of wrap rows (visual rows after soft-wrapping) + #[inline] + pub(super) fn wrap_row_count(&self) -> usize { + self.wrapper.len() + } + + /// Get total number of buffer lines (logical lines) + #[inline] + pub(super) fn buffer_line_count(&self) -> usize { + self.wrapper.lines_count() + } + + /// Convert buffer position to wrap position + pub(super) fn buffer_pos_to_wrap_pos(&self, pos: BufferPoint) -> WrapPoint { + let BufferPoint { line, col } = pos; + + // Clamp to valid range + let line = line.min(self.buffer_line_count().saturating_sub(1)); + let line_item = self.wrapper.line(line); + + let col = if let Some(line_item) = line_item { + col.min(line_item.len()) + } else { + 0 + }; + + // Calculate offset in rope + let line_start_offset = self.wrapper.text().line_start_offset(line); + let offset = line_start_offset + col; + + // Use TextWrapper's existing conversion + let display_point = self.wrapper.offset_to_display_point(offset); + + WrapPoint::new(display_point.row, display_point.column) + } + + /// Convert wrap position to buffer position + pub(super) fn wrap_pos_to_buffer_pos(&self, pos: WrapPoint) -> BufferPoint { + let WrapPoint { row, col } = pos; + + // Clamp wrap_row to valid range + let row = row.min(self.wrap_row_count().saturating_sub(1)); + + // Use TextWrapper's existing conversion + let display_point = WrapDisplayPoint::new(row, 0, col); + let offset = self.wrapper.display_point_to_offset(display_point); + + // Convert offset to buffer position + let point = self.wrapper.text().offset_to_point(offset); + let line_start = self.wrapper.text().line_start_offset(point.row); + let col = offset.saturating_sub(line_start); + + BufferPoint::new(point.row, col) + } + + /// Get the buffer line for a given wrap row + pub(super) fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize { + self.wrapper.wrap_row_to_buffer_line(wrap_row) + } + + /// Get the first wrap row for a given buffer line + pub(super) fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize { + self.wrapper.buffer_line_to_first_wrap_row(line) + } + + /// Get the wrap row range for a buffer line: [start, end) + pub(super) fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range { + self.wrapper.buffer_line_to_wrap_row_range(line) + } + + /// Update text (incremental or full) + pub(super) fn on_text_changed( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + cx: &mut App, + ) { + self.wrapper.update(changed_text, range, new_text, cx); + } + + /// Update layout parameters (wrap width or font) + pub(super) fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { + self.wrapper.set_wrap_width(wrap_width, cx); + } + + /// Set the wrapping indent mode for continuation lines. + pub(super) fn set_wrapping_indent(&mut self, wrapping_indent: WrappingIndent, cx: &mut App) { + self.wrapper.set_wrapping_indent(wrapping_indent, cx); + } + + /// Set font parameters + pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + self.wrapper.set_font(font, font_size, cx); + } + + /// Ensure text is prepared (initializes wrapper if needed) + pub(super) fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool { + self.wrapper.prepare_if_need(text, cx) + } + + /// Initialize with text + pub(super) fn set_text(&mut self, text: &Rope, cx: &mut App) { + self.wrapper.set_default_text(text); + self.wrapper.prepare_if_need(text, cx); + } + + /// Get access to the underlying wrapper (for rendering/hit-testing) + pub(crate) fn wrapper(&self) -> &TextWrapper { + &self.wrapper + } + + /// Get the line item by buffer row index. + #[inline] + pub(crate) fn line(&self, row: usize) -> Option<&LineItem> { + self.wrapper.line(row) + } + + /// Get the rope text + pub(super) fn text(&self) -> &Rope { + self.wrapper.text() + } + + /// Calculate how many wrap rows of a buffer line are visible (not folded) + pub(super) fn visible_wrap_row_count_for_line(&self, line: usize, fold_map: &FoldMap) -> usize { + let wrap_range = self.buffer_line_to_wrap_row_range(line); + wrap_range + .filter(|&wr| fold_map.wrap_row_to_display_row(wr).is_some()) + .count() + } +} diff --git a/vendor/gpui-base/src/input/editor/highlighting.rs b/vendor/gpui-base/src/input/editor/highlighting.rs new file mode 100644 index 00000000..64d3e461 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/highlighting.rs @@ -0,0 +1,103 @@ +use std::{ops::Range, rc::Rc, sync::Arc}; + +use gpui::{AnyElement, Context, HighlightStyle, Hsla, SharedString, Window}; +use ropey::Rope; + +use super::{EditorState, FoldRange, InputEdit}; + +/// Resolves semantic highlight names into renderable GPUI styles. +/// +/// Base deliberately knows nothing about a concrete syntax theme. UI crates and +/// applications can provide any resolver, independently of their parser. +pub trait HighlightStyleResolver: Send + Sync { + fn style(&self, name: &str) -> Option; +} + +#[derive(Default)] +struct NoHighlightStyles; + +impl HighlightStyleResolver for NoHighlightStyles { + fn style(&self, _: &str) -> Option { + None + } +} + +/// Parser-independent syntax highlighting seam consumed by the Base editor. +/// +/// Implementations own parsing, incremental state, and language-specific +/// behavior. Base only asks for styled ranges and fold candidates. +pub trait InputHighlighter { + fn language(&self) -> SharedString; + + fn update( + &mut self, + edit: Option, + text: &Rope, + folding: bool, + window: &mut Window, + cx: &mut Context, + ); + + /// Return ordered, non-overlapping style runs that fully cover `range`. + /// Use [`HighlightStyle::default`] for text without a semantic style. + fn styles( + &self, + range: &Range, + resolver: &dyn HighlightStyleResolver, + ) -> Vec<(Range, HighlightStyle)>; + + fn fold_ranges(&self, text: &Rope) -> Vec; + + fn fold_ranges_for_edit(&self, range: Range, text: &Rope) -> Vec { + let _ = range; + self.fold_ranges(text) + } +} + +pub type InputHighlighterFactory = Rc Option>>; +pub type SharedHighlightStyleResolver = Arc; +pub type FoldIconRenderer = Rc AnyElement>; + +#[derive(Clone, Copy, Default)] +pub struct DiagnosticColors { + pub error: Hsla, + pub warning: Hsla, + pub info: Hsla, + pub hint: Hsla, +} + +/// Application-owned colors and highlight resolver consumed by editor painting. +#[derive(Clone)] +pub struct InputEditorStyle { + pub foreground: Hsla, + pub muted_foreground: Hsla, + pub background: Hsla, + pub border: Hsla, + pub selection: Hsla, + pub caret: Hsla, + pub diagnostics: DiagnosticColors, + pub highlight_styles: SharedHighlightStyleResolver, + pub editor_invisible: Option, + pub editor_active_line: Option, + pub editor_gutter_background: Option, + pub fold_icon_renderer: Option, +} + +impl Default for InputEditorStyle { + fn default() -> Self { + Self { + foreground: Hsla::default(), + muted_foreground: Hsla::default(), + background: Hsla::default(), + border: Hsla::default(), + selection: Hsla::default(), + caret: Hsla::default(), + diagnostics: DiagnosticColors::default(), + highlight_styles: Arc::new(NoHighlightStyles), + editor_invisible: None, + editor_active_line: None, + editor_gutter_background: None, + fold_icon_renderer: None, + } + } +} diff --git a/vendor/gpui-component/src/input/indent.rs b/vendor/gpui-base/src/input/editor/indent.rs similarity index 84% rename from vendor/gpui-component/src/input/indent.rs rename to vendor/gpui-base/src/input/editor/indent.rs index 489cdee7..3cb614fe 100644 --- a/vendor/gpui-component/src/input/indent.rs +++ b/vendor/gpui-base/src/input/editor/indent.rs @@ -1,17 +1,14 @@ +use crate::input::InputModeKind; +use crate::input::{ + Indent, IndentInline, InputBaseState, Outdent, OutdentInline, RopeExt, element::TextElement, + layout::LastLayout, mode::LayoutMode, +}; use gpui::{ Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString, TextRun, TextStyle, Window, point, px, }; use ropey::RopeSlice; -use crate::{ - RopeExt, - input::{ - Indent, IndentInline, InputState, LastLayout, Outdent, OutdentInline, element::TextElement, - mode::InputMode, - }, -}; - #[derive(Debug, Copy, Clone)] pub struct TabSize { /// Default is 2 @@ -48,30 +45,27 @@ impl TabSize { _ => break, } } - count } } -impl InputMode { +impl LayoutMode { + /// Whether this layout indents blocks. + /// + /// Callers gate this on the input being multi-line: indenting a one-line + /// text field has nothing to indent. #[inline] pub(super) fn is_indentable(&self) -> bool { - match self { - InputMode::PlainText { multi_line, .. } | InputMode::CodeEditor { multi_line, .. } => { - *multi_line - } - _ => false, - } + matches!( + self, + LayoutMode::PlainText { .. } | LayoutMode::CodeEditor { .. } + ) } #[inline] pub(super) fn has_indent_guides(&self) -> bool { match self { - InputMode::CodeEditor { - indent_guides, - multi_line, - .. - } => *indent_guides && *multi_line, + LayoutMode::CodeEditor { indent_guides, .. } => *indent_guides, _ => false, } } @@ -79,14 +73,14 @@ impl InputMode { #[inline] pub(super) fn tab_size(&self) -> TabSize { match self { - InputMode::PlainText { tab, .. } => *tab, - InputMode::CodeEditor { tab, .. } => *tab, + LayoutMode::PlainText { tab, .. } => *tab, + LayoutMode::CodeEditor { tab, .. } => *tab, _ => TabSize::default(), } } } -impl TextElement { +impl TextElement { /// Measure the indent width in pixels for given column count. fn measure_indent_width(&self, style: &TextStyle, column: usize, window: &Window) -> Pixels { let font_size = style.font_size.to_pixels(window.rem_size()); @@ -109,13 +103,13 @@ impl TextElement { pub(super) fn layout_indent_guides( &self, - state: &InputState, + state: &InputBaseState, bounds: &Bounds, last_layout: &LastLayout, text_style: &TextStyle, window: &mut Window, ) -> Option> { - if !state.mode.has_indent_guides() { + if !state.is_multi_line() || !state.mode.has_indent_guides() { return None; } @@ -124,16 +118,16 @@ impl TextElement { let tab_size = state.mode.tab_size(); let line_height = last_layout.line_height; - let visible_range = last_layout.visible_range.clone(); let mut builder = PathBuilder::stroke(px(1.)); let mut offset_y = last_layout.visible_top; let mut last_indents = vec![]; - for ix in visible_range { - let line = state.text.slice_line(ix); - let Some(line_layout) = last_layout.line(ix) else { - continue; - }; + for (&buffer_line, line_layout) in last_layout + .visible_buffer_lines + .iter() + .zip(last_layout.lines.iter()) + { + let line = state.text.slice_line(buffer_line); let mut current_indents = vec![]; if line.len() > 0 { let indent_count = tab_size.indent_count(&line); @@ -169,13 +163,12 @@ impl TextElement { } } -impl InputState { - /// Set whether to show indent guides in code editor mode, default is true. - /// - /// Only for [`InputMode::CodeEditor`] mode. +/// Indent guides are a code-editor affordance. +impl InputBaseState { + /// Set whether to show indent guides, default is true. + #[doc(hidden)] pub fn indent_guides(mut self, indent_guides: bool) -> Self { - debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line()); - if let InputMode::CodeEditor { + if let LayoutMode::CodeEditor { indent_guides: l, .. } = &mut self.mode { @@ -184,17 +177,14 @@ impl InputState { self } - /// Set indent guides in code editor mode. - /// - /// Only for [`InputMode::CodeEditor`] mode. + /// Set indent guides at runtime. pub fn set_indent_guides( &mut self, indent_guides: bool, _: &mut Window, cx: &mut Context, ) { - debug_assert!(self.mode.is_code_editor()); - if let InputMode::CodeEditor { + if let LayoutMode::CodeEditor { indent_guides: l, .. } = &mut self.mode { @@ -202,20 +192,9 @@ impl InputState { } cx.notify(); } +} - /// Set the tab size for the input. - /// - /// Only for [`InputMode::PlainText`] and [`InputMode::CodeEditor`] mode with multi_line. - pub fn tab_size(mut self, tab: TabSize) -> Self { - debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor()); - match &mut self.mode { - InputMode::PlainText { tab: t, .. } => *t = tab, - InputMode::CodeEditor { tab: t, .. } => *t = tab, - _ => {} - } - self - } - +impl InputBaseState { pub(super) fn indent_inline( &mut self, _: &IndentInline, @@ -223,7 +202,7 @@ impl InputState { cx: &mut Context, ) { // First, try to accept inline completion if present - if self.accept_inline_completion(window, cx) { + if M::accept_inline_completion(self, window, cx) { return; } self.indent(false, window, cx); @@ -252,7 +231,7 @@ impl InputState { } pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - if !self.mode.is_indentable() { + if !self.is_multi_line() || !self.mode.is_indentable() { cx.propagate(); return; }; @@ -310,7 +289,7 @@ impl InputState { } pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - if !self.mode.is_indentable() { + if !self.is_multi_line() || !self.mode.is_indentable() { cx.propagate(); return; }; @@ -430,3 +409,26 @@ mod tests { assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); } } + +/// Tab size only means something where there is more than one line to indent. +impl InputBaseState { + /// Set the tab size for the input. + #[doc(hidden)] + pub fn tab_size(mut self, tab: TabSize) -> Self { + match &mut self.mode { + LayoutMode::PlainText { tab: t, .. } => *t = tab, + LayoutMode::CodeEditor { tab: t, .. } => *t = tab, + _ => {} + } + self + } + + pub fn set_tab_size(&mut self, tab: TabSize, cx: &mut Context) { + match &mut self.mode { + LayoutMode::PlainText { tab: value, .. } + | LayoutMode::CodeEditor { tab: value, .. } => *value = tab, + _ => {} + } + cx.notify(); + } +} diff --git a/vendor/gpui-component/src/input/lsp/code_actions.rs b/vendor/gpui-base/src/input/editor/lsp/code_actions.rs similarity index 72% rename from vendor/gpui-component/src/input/lsp/code_actions.rs rename to vendor/gpui-base/src/input/editor/lsp/code_actions.rs index a5b7de6d..562088a9 100644 --- a/vendor/gpui-component/src/input/lsp/code_actions.rs +++ b/vendor/gpui-base/src/input/editor/lsp/code_actions.rs @@ -3,10 +3,7 @@ use gpui::{App, Context, Entity, SharedString, Task, Window}; use lsp_types::CodeAction; use std::ops::Range; -use crate::input::{ - popovers::{CodeActionItem, CodeActionMenu, ContextMenu}, - InputState, ToggleCodeActions, -}; +use crate::input::{EditorMode, EditorState, InputBaseState, ToggleCodeActions}; pub trait CodeActionProvider { /// The id for this CodeAction. @@ -19,7 +16,7 @@ pub trait CodeActionProvider { /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_codeAction fn code_actions( &self, - state: Entity, + state: Entity, range: Range, window: &mut Window, cx: &mut App, @@ -28,7 +25,7 @@ pub trait CodeActionProvider { /// Performs the specified code action. fn perform_code_action( &self, - state: Entity, + state: Entity, action: CodeAction, push_to_history: bool, window: &mut Window, @@ -36,7 +33,13 @@ pub trait CodeActionProvider { ) -> Task>; } -impl InputState { +#[derive(Clone, Debug)] +pub struct CodeActionItem { + pub provider_id: SharedString, + pub action: CodeAction, +} + +impl InputBaseState { pub(crate) fn on_action_toggle_code_actions( &mut self, _: &ToggleCodeActions, @@ -52,25 +55,11 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - let providers = self.lsp.code_action_providers.clone(); - let menu = match self.context_menu.as_ref() { - Some(ContextMenu::CodeAction(menu)) => Some(menu), - _ => None, - }; - - let menu = match menu { - Some(menu) => menu.clone(), - None => { - let menu = CodeActionMenu::new(cx.entity(), window, cx); - self.context_menu = Some(ContextMenu::CodeAction(menu.clone())); - menu - } - }; - + let providers = self.extras.lsp.code_action_providers.clone(); let range = self.selected_range.start..self.selected_range.end; let state = cx.entity(); - self._context_menu_task = cx.spawn_in(window, async move |editor, cx| { + self.extras.context_menu_task = cx.spawn_in(window, async move |editor, cx| { let mut provider_responses = vec![]; _ = cx.update(|window, cx| { for provider in providers { @@ -90,11 +79,11 @@ impl InputState { } if code_actions.is_empty() { - _ = menu.update(cx, |menu, cx| { - menu.hide(cx); + editor.update(cx, |editor, cx| { + editor.extras.context_menu_content.code_action.open = false; + editor.extras.context_menu_content.code_action.items.clear(); cx.notify(); - }); - + })?; return Ok(()); } editor @@ -103,9 +92,13 @@ impl InputState { return; } - _ = menu.update(cx, |menu, cx| { - menu.show(editor.cursor(), code_actions, window, cx); - }); + editor.extras.context_menu_content.code_action.items = code_actions; + editor.extras.context_menu_content.code_action.open = !editor + .extras + .context_menu_content + .code_action + .items + .is_empty(); cx.notify(); }) @@ -115,13 +108,13 @@ impl InputState { }); } - pub(crate) fn perform_code_action( + pub fn perform_code_action( &mut self, item: &CodeActionItem, window: &mut Window, cx: &mut Context, ) { - let providers = self.lsp.code_action_providers.clone(); + let providers = self.extras.lsp.code_action_providers.clone(); let Some(provider) = providers .iter() .find(|provider| provider.id() == item.provider_id) diff --git a/vendor/gpui-component/src/input/lsp/completions.rs b/vendor/gpui-base/src/input/editor/lsp/completions.rs similarity index 64% rename from vendor/gpui-component/src/input/lsp/completions.rs rename to vendor/gpui-base/src/input/editor/lsp/completions.rs index 7aa1756f..5e311312 100644 --- a/vendor/gpui-component/src/input/lsp/completions.rs +++ b/vendor/gpui-base/src/input/editor/lsp/completions.rs @@ -1,5 +1,6 @@ +use crate::input::EditorMode; use anyhow::Result; -use gpui::{Context, EntityInputHandler, Task, Window}; +use gpui::{App, Context, EntityInputHandler, Pixels, Task, Window, px}; use lsp_types::{ CompletionContext, CompletionItem, CompletionResponse, InlineCompletionContext, InlineCompletionItem, InlineCompletionResponse, InlineCompletionTriggerKind, @@ -8,14 +9,33 @@ use lsp_types::{ use ropey::Rope; use std::{cell::RefCell, ops::Range, rc::Rc, time::Duration}; -use crate::input::{ - InputState, - popovers::{CompletionMenu, ContextMenu}, -}; +use crate::input::InputBaseState; /// Default debounce duration for inline completions. const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300); +/// Display options for the LSP completion popover. +/// +/// Accessed through [`super::Lsp::completion_menu`] so embedders can tweak the +/// popover without growing the [`InputBaseState`] API. +#[derive(Debug, Clone, Copy)] +pub struct CompletionMenuOptions { + /// Maximum width of the popover. + /// + /// Defaults to 320 px, which is fine for most identifiers but can + /// truncate longer labels. Widen this when hosting an editor that + /// surfaces long completion labels. + pub max_width: Pixels, +} + +impl Default for CompletionMenuOptions { + fn default() -> Self { + Self { + max_width: px(320.), + } + } +} + /// A trait for providing code completions based on the current input state and context. pub trait CompletionProvider { /// Fetches completions based on the given byte offset. @@ -31,7 +51,7 @@ pub trait CompletionProvider { offset: usize, trigger: CompletionContext, window: &mut Window, - cx: &mut Context, + cx: &mut App, ) -> Task>; /// Fetches an inline completion suggestion for the given position. @@ -54,7 +74,7 @@ pub trait CompletionProvider { _offset: usize, _trigger: InlineCompletionContext, _window: &mut Window, - _cx: &mut Context, + _cx: &mut App, ) -> Task> { Task::ready(Ok(InlineCompletionResponse::Array(vec![]))) } @@ -71,7 +91,7 @@ pub trait CompletionProvider { &self, _completion_indices: Vec, _completions: Rc>>, - _: &mut Context, + _: &mut App, ) -> Task> { Task::ready(Ok(false)) } @@ -79,12 +99,7 @@ pub trait CompletionProvider { /// Determines if the completion should be triggered based on the given byte offset. /// /// This is called on the main thread. - fn is_completion_trigger( - &self, - offset: usize, - new_text: &str, - cx: &mut Context, - ) -> bool; + fn is_completion_trigger(&self, offset: usize, new_text: &str, cx: &mut App) -> bool; } pub(crate) struct InlineCompletion { @@ -103,7 +118,7 @@ impl Default for InlineCompletion { } } -impl InputState { +impl InputBaseState { pub(crate) fn handle_completion_trigger( &mut self, range: &Range, @@ -115,7 +130,7 @@ impl InputState { return; } - let Some(provider) = self.lsp.completion_provider.clone() else { + let Some(provider) = self.extras.lsp.completion_provider.clone() else { return; }; @@ -130,22 +145,12 @@ impl InputState { return; } - let menu = match self.context_menu.as_ref() { - Some(ContextMenu::Completion(menu)) => Some(menu), - _ => None, - }; - - // To create or get the existing completion menu. - let menu = match menu { - Some(menu) => menu.clone(), - None => { - let menu = CompletionMenu::new(cx.entity(), window, cx); - self.context_menu = Some(ContextMenu::Completion(menu.clone())); - menu - } - }; - - let start_offset = menu.read(cx).trigger_start_offset.unwrap_or(start); + let start_offset = self + .extras + .context_menu_content + .completion + .trigger_start_offset + .unwrap_or(start); if new_offset < start_offset { return; } @@ -159,9 +164,15 @@ impl InputState { ) .map(|s| s.trim().to_string()) .unwrap_or_default(); - _ = menu.update(cx, |menu, _| { - menu.update_query(start_offset, query.clone()); - }); + self.extras + .context_menu_content + .completion + .trigger_start_offset = Some(start_offset); + self.extras + .context_menu_content + .completion + .query + .clone_from(&query); let completion_context = CompletionContext { trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER, @@ -170,7 +181,7 @@ impl InputState { let provider_responses = provider.completions(&self.text, new_offset, completion_context, window, cx); - self._context_menu_task = cx.spawn_in(window, async move |editor, cx| { + self.extras.context_menu_task = cx.spawn_in(window, async move |editor, cx| { let mut completions: Vec = vec![]; if let Some(provider_responses) = provider_responses.await.ok() { match provider_responses { @@ -180,11 +191,12 @@ impl InputState { } if completions.is_empty() { - _ = menu.update(cx, |menu, cx| { - menu.hide(cx); + editor.update(cx, |editor, cx| { + editor.extras.context_menu_content.completion.open = false; + editor.extras.context_menu_content.completion.items.clear(); + editor.extras.context_menu_content.completion.bump(); cx.notify(); - }); - + })?; return Ok(()); } @@ -194,9 +206,14 @@ impl InputState { return; } - _ = menu.update(cx, |menu, cx| { - menu.show(new_offset, completions, window, cx); - }); + editor.extras.context_menu_content.completion.items = completions; + editor.extras.context_menu_content.completion.open = !editor + .extras + .context_menu_content + .completion + .items + .is_empty(); + editor.extras.context_menu_content.completion.bump(); cx.notify(); }) @@ -206,6 +223,51 @@ impl InputState { }); } + pub(crate) fn hide_context_menu(&mut self, cx: &mut Context) { + self.extras.context_menu_content.completion.open = false; + self.extras.context_menu_content.code_action.open = false; + self.extras.context_menu_task = Task::ready(Ok(())); + cx.notify(); + } + + pub(crate) fn is_context_menu_open(&self, _cx: &gpui::App) -> bool { + self.extras.context_menu_content.completion.open + || self.extras.context_menu_content.code_action.open + } + + pub(crate) fn handle_action_for_context_menu( + &mut self, + action: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let closes_overlay = + crate::input::Enter::is_primary(&*action) || action.partial_eq(&crate::input::Escape); + let kind = if self.extras.context_menu_content.completion.open { + Some(super::InputOverlayKind::Completion) + } else if self.extras.context_menu_content.code_action.open { + Some(super::InputOverlayKind::CodeAction) + } else { + None + }; + let Some((kind, handler)) = kind.zip(self.overlay_action_handler.clone()) else { + return false; + }; + let handled = handler(kind, action, window, cx); + if handled && closes_overlay { + match kind { + super::InputOverlayKind::Completion => { + self.extras.context_menu_content.completion.open = false + } + super::InputOverlayKind::CodeAction => { + self.extras.context_menu_content.code_action.open = false + } + } + cx.notify(); + } + handled + } + /// Schedule an inline completion request after debouncing. pub(crate) fn schedule_inline_completion( &mut self, @@ -215,17 +277,18 @@ impl InputState { // Clear any existing inline completion on text change self.clear_inline_completion(cx); - let Some(provider) = self.lsp.completion_provider.clone() else { + let Some(provider) = self.extras.lsp.completion_provider.clone() else { return; }; let offset = self.cursor(); let text = self.text.clone(); let debounce = provider.inline_completion_debounce(); + let background_executor = cx.background_executor().clone(); - self.inline_completion.task = cx.spawn_in(window, async move |editor, cx| { + self.extras.inline_completion.task = cx.spawn_in(window, async move |editor, cx| { // Debounce: wait before fetching to avoid unnecessary requests while typing - smol::Timer::after(debounce).await; + background_executor.timer(debounce).await; // Now fetch the inline completion after the debounce period let task = editor.update_in(cx, |editor, window, cx| { @@ -268,7 +331,7 @@ impl InputState { InlineCompletionResponse::Array(items) => items.into_iter().next(), InlineCompletionResponse::List(comp_list) => comp_list.items.into_iter().next(), } { - editor.inline_completion.item = Some(item); + editor.extras.inline_completion.item = Some(item); cx.notify(); } })?; @@ -280,12 +343,12 @@ impl InputState { /// Check if an inline completion suggestion is currently displayed. #[inline] pub(crate) fn has_inline_completion(&self) -> bool { - self.inline_completion.item.is_some() + self.extras.inline_completion.item.is_some() } /// Clear the inline completion suggestion. pub(crate) fn clear_inline_completion(&mut self, cx: &mut Context) { - self.inline_completion = InlineCompletion::default(); + self.extras.inline_completion = InlineCompletion::default(); cx.notify(); } @@ -296,7 +359,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) -> bool { - let Some(completion_item) = self.inline_completion.item.take() else { + let Some(completion_item) = self.extras.inline_completion.item.take() else { return false; }; diff --git a/vendor/gpui-component/src/input/lsp/definitions.rs b/vendor/gpui-base/src/input/editor/lsp/definitions.rs similarity index 64% rename from vendor/gpui-component/src/input/lsp/definitions.rs rename to vendor/gpui-base/src/input/editor/lsp/definitions.rs index 9536603c..a5d5c331 100644 --- a/vendor/gpui-component/src/input/lsp/definitions.rs +++ b/vendor/gpui-base/src/input/editor/lsp/definitions.rs @@ -5,10 +5,7 @@ use gpui::{ use ropey::Rope; use std::{ops::Range, rc::Rc}; -use crate::{ - ActiveTheme, - input::{GoToDefinition, InputState, RopeExt, element::TextElement}, -}; +use crate::input::{EditorMode, GoToDefinition, InputBaseState, RopeExt}; /// Definition provider /// @@ -63,18 +60,18 @@ impl HoverDefinition { } } -impl InputState { +impl InputBaseState { pub(crate) fn handle_hover_definition( &mut self, offset: usize, window: &mut Window, cx: &mut Context, ) { - let Some(provider) = self.lsp.definition_provider.clone() else { + let Some(provider) = self.extras.lsp.definition_provider.clone() else { return; }; - if self.hover_definition.is_same(offset) { + if self.extras.hover_definition.is_same(offset) { return; } @@ -82,12 +79,12 @@ impl InputState { let task = provider.definitions(&self.text, offset, window, cx); let mut symbol_range = self.text.word_range(offset).unwrap_or(offset..offset); let editor = cx.entity(); - self.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { + self.extras.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { let locations = task.await?; _ = editor.update(cx, |editor, cx| { if locations.is_empty() { - editor.hover_definition.clear(); + editor.extras.hover_definition.clear(); } else { if let Some(location) = locations.first() { if let Some(range) = location.origin_selection_range { @@ -98,6 +95,7 @@ impl InputState { } editor + .extras .hover_definition .update(symbol_range.clone(), locations.clone()); } @@ -111,17 +109,18 @@ impl InputState { pub(crate) fn on_action_go_to_definition( &mut self, _: &GoToDefinition, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { let offset = self.cursor(); - if let Some((symbol_range, locations)) = self.hover_definition.last_location.clone() { + if let Some((symbol_range, locations)) = self.extras.hover_definition.last_location.clone() + { if !(symbol_range.start..=symbol_range.end).contains(&offset) { return; } if let Some(location) = locations.first().cloned() { - self.go_to_definition(&location, cx); + self.go_to_definition(&location, window, cx); } } } @@ -131,25 +130,25 @@ impl InputState { &mut self, event: &MouseDownEvent, offset: usize, - _: &mut Window, - cx: &mut Context, + window: &mut Window, + cx: &mut Context>, ) -> bool { if !event.modifiers.secondary() { return false; } - if self.hover_definition.is_empty() { + if self.extras.hover_definition.is_empty() { return false; }; - if !self.hover_definition.is_same(offset) { + if !self.extras.hover_definition.is_same(offset) { return false; } - let Some(location) = self.hover_definition.locations.first().cloned() else { + let Some(location) = self.extras.hover_definition.locations.first().cloned() else { return false; }; - self.go_to_definition(&location, cx); + self.go_to_definition(&location, window, cx); true } @@ -157,18 +156,34 @@ impl InputState { pub(crate) fn go_to_definition( &mut self, location: &lsp_types::LocationLink, + window: &mut Window, cx: &mut Context, ) { - if location + let external = location .target_uri .scheme() .map(|s| s.as_str() == "https" || s.as_str() == "http") - == Some(true) - { + == Some(true); + + // Give the host a chance to show the document first (window/showDocument), + // e.g. to open virtual/external documents (stdlib docs) in an app window. + if let Some(handler) = self.extras.lsp.show_document.clone() { + let params = lsp_types::ShowDocumentParams { + uri: location.target_uri.clone(), + external: Some(external), + take_focus: Some(true), + selection: Some(location.target_selection_range), + }; + if handler(¶ms, window, cx) { + return; + } + } + + if external { cx.open_url(&location.target_uri.to_string()); } else { // Move to the location. - let target_range = location.target_range; + let target_range = location.target_selection_range; let start = self.text.position_to_offset(&target_range.start); let end = self.text.position_to_offset(&target_range.end); @@ -178,26 +193,17 @@ impl InputState { } } -impl TextElement { - pub(crate) fn layout_hover_definition( - &self, - cx: &App, - ) -> Option<(Range, HighlightStyle)> { - let editor = self.state.read(cx); - if !editor.mode.is_code_editor() { - return None; - } - - if editor.hover_definition.is_empty() { +/// These read the editor's own state; they sit here rather than on the element +/// because the element has nothing to do with the answer. +impl InputBaseState { + /// The range highlighted while Cmd-hovering a symbol, with its style. + pub(crate) fn hover_definition_style(&self) -> Option<(Range, HighlightStyle)> { + let editor = self; + if editor.extras.hover_definition.is_empty() { return None; }; - let mut highlight_style: HighlightStyle = cx - .theme() - .highlight_theme - .link_text - .map(|style| style.into()) - .unwrap_or_default(); + let mut highlight_style = editor.editor_style.highlight_styles.style("link_text")?; highlight_style.underline = Some(UnderlineStyle { thickness: px(1.), @@ -205,26 +211,20 @@ impl TextElement { }); Some(( - editor.hover_definition.symbol_range.clone(), + editor.extras.hover_definition.symbol_range.clone(), highlight_style, )) } - pub(crate) fn layout_hover_definition_hitbox( - &self, - editor: &InputState, - window: &mut Window, - _cx: &App, - ) -> Option { - if !editor.mode.is_code_editor() { - return None; - } - - if editor.hover_definition.is_empty() { + /// The hitbox that makes a Cmd-hovered symbol clickable. + pub(crate) fn hover_definition_hitbox(&self, window: &mut Window) -> Option { + let editor = self; + if editor.extras.hover_definition.is_empty() { return None; }; - let Some(bounds) = editor.range_to_bounds(&editor.hover_definition.symbol_range) else { + let Some(bounds) = editor.range_to_bounds(&editor.extras.hover_definition.symbol_range) + else { return None; }; diff --git a/vendor/gpui-base/src/input/editor/lsp/document_colors.rs b/vendor/gpui-base/src/input/editor/lsp/document_colors.rs new file mode 100644 index 00000000..e6a4c22e --- /dev/null +++ b/vendor/gpui-base/src/input/editor/lsp/document_colors.rs @@ -0,0 +1,179 @@ +use anyhow::Result; +use gpui::{App, Context, Hsla, Task, Window}; +use instant::Duration; +use lsp_types::ColorInformation; +use ropey::Rope; +use std::ops::Range; + +use crate::input::{EditorMode, InputBaseState, Lsp, RopeExt}; + +/// Maximum number of document colors accepted from a single provider response. +const MAX_DOCUMENT_COLORS: usize = 10_000; + +pub trait DocumentColorProvider { + /// Fetches document colors for the specified range. + /// + /// textDocument/documentColor + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentColor + fn document_colors( + &self, + _text: &Rope, + window: &mut Window, + cx: &mut App, + ) -> Task>>; +} + +fn document_colors_from_response( + colors: &[ColorInformation], +) -> Option> { + if colors.len() > MAX_DOCUMENT_COLORS { + return None; + } + + let mut document_colors = colors + .iter() + .map(|info| { + let color = gpui::Rgba { + r: info.color.red, + g: info.color.green, + b: info.color.blue, + a: info.color.alpha, + } + .into(); + + (info.range, color) + }) + .collect::>(); + document_colors.sort_by_key(|(range, _)| range.start); + + Some(document_colors) +} + +impl Lsp { + /// Get document colors that intersect with the visible range (0-based row). + /// + /// Returns non-empty byte ranges and colors. Ranges that become empty or + /// inverted after position conversion are ignored. + pub(crate) fn document_colors_for_range( + &self, + text: &Rope, + visible_range: &Range, + ) -> Vec<(Range, Hsla)> { + self.document_colors + .iter() + .filter_map(|(range, color)| { + if (range.start.line as usize) > visible_range.end + || (range.end.line as usize) < visible_range.start + { + return None; + } + + let start = text.position_to_offset(&range.start); + let end = text.position_to_offset(&range.end); + if start >= end { + return None; + } + + Some((start..end, *color)) + }) + .collect() + } + + pub(crate) fn update_document_colors( + &mut self, + text: &Rope, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(provider) = self.document_color_provider.as_ref() else { + return; + }; + + let provider = provider.clone(); + let text = text.clone(); + let input_state = cx.entity(); + + // debounce timer 100ms + self._document_color_task = cx.spawn_in(window, async move |_, cx| { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + + let task_result = cx + .update(|window, cx| provider.document_colors(&text, window, cx)) + .ok(); + + if let Some(task) = task_result { + if let Ok(colors) = task.await { + let _ = input_state.update(cx, |input_state, cx| { + let Some(document_colors) = document_colors_from_response(&colors) else { + return; + }; + + if document_colors != input_state.extras.lsp.document_colors { + input_state.extras.lsp.document_colors = document_colors; + cx.notify(); + } + }); + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{Position, Range as LspRange}; + + #[test] + fn test_document_colors_from_response_enforces_limit() { + let color = ColorInformation { + range: lsp_types::Range::new( + lsp_types::Position::new(0, 0), + lsp_types::Position::new(0, 1), + ), + color: lsp_types::Color { + red: 1., + green: 0., + blue: 0., + alpha: 1., + }, + }; + let colors = vec![color; MAX_DOCUMENT_COLORS + 1]; + + assert_eq!( + document_colors_from_response(&colors[..MAX_DOCUMENT_COLORS]) + .unwrap() + .len(), + MAX_DOCUMENT_COLORS + ); + assert!(document_colors_from_response(&colors).is_none()); + } + + #[test] + fn test_document_colors_for_range_ignores_empty_and_inverted_ranges() { + let text = Rope::from_str("01234567890123456789"); + let mut lsp = Lsp::default(); + lsp.document_colors.extend([ + ( + LspRange::new(Position::new(0, 10), Position::new(0, 5)), + gpui::red(), + ), + ( + LspRange::new(Position::new(0, 10), Position::new(0, 10)), + gpui::green(), + ), + ( + LspRange::new(Position::new(0, 5), Position::new(0, 10)), + gpui::blue(), + ), + ]); + + assert_eq!( + lsp.document_colors_for_range(&text, &(0..0)), + vec![(5..10, gpui::blue())] + ); + } +} diff --git a/vendor/gpui-base/src/input/editor/lsp/hover.rs b/vendor/gpui-base/src/input/editor/lsp/hover.rs new file mode 100644 index 00000000..4782e922 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/lsp/hover.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use gpui::{App, Context, MouseMoveEvent, Task, Window}; +use instant::Duration; +use ropey::Rope; + +use crate::input::{EditorMode, HoverPopoverState, InputBaseState, RopeExt}; + +/// Hover provider +/// +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover +pub trait HoverProvider { + /// textDocument/hover + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover + fn hover( + &self, + _text: &Rope, + _offset: usize, + _window: &mut Window, + _cx: &mut App, + ) -> Task>>; +} + +impl InputBaseState { + /// Handle hover trigger LSP request. + pub(super) fn handle_hover_popover( + &mut self, + offset: usize, + window: &mut Window, + cx: &mut Context>, + ) { + if self.selecting { + return; + } + + let Some(provider) = self.extras.lsp.hover_provider.clone() else { + return; + }; + + if let Some(hover_popover) = self.extras.hover_popover.as_ref() { + if hover_popover.symbol_range.contains(&offset) { + return; + } + } + + // Currently not implemented. + let task = provider.hover(&self.text, offset, window, cx); + let mut symbol_range = self.text.word_range(offset).unwrap_or(offset..offset); + let editor = cx.entity(); + let should_delay = self.extras.hover_popover.is_none(); + self.extras.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { + if should_delay { + cx.background_executor() + .timer(Duration::from_millis(150)) + .await; + } + + let result = task.await?; + + _ = editor.update(cx, |editor, cx| { + match result { + Some(hover) => { + if let Some(range) = hover.range { + let start = editor.text.position_to_offset(&range.start); + let end = editor.text.position_to_offset(&range.end); + symbol_range = start..end; + } + editor.extras.hover_popover = Some(HoverPopoverState { + symbol_range, + hover, + }); + } + None => { + editor.extras.hover_popover = None; + } + } + cx.notify(); + }); + + Ok(()) + }); + } + + pub(crate) fn handle_mouse_move( + &mut self, + offset: usize, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + if event.modifiers.secondary() { + self.handle_hover_definition(offset, window, cx); + } else { + self.extras.hover_definition.clear(); + self.handle_hover_popover(offset, window, cx); + } + cx.notify(); + } + + pub fn clear_hover_state(&mut self, cx: &mut Context) { + let changed = + !self.extras.hover_definition.is_empty() || self.extras.hover_popover.is_some(); + self.extras.hover_definition.clear(); + self.extras.hover_popover = None; + self.extras.lsp._hover_task = Task::ready(Ok(())); + if changed { + cx.notify(); + } + } +} diff --git a/vendor/gpui-base/src/input/editor/lsp/mod.rs b/vendor/gpui-base/src/input/editor/lsp/mod.rs new file mode 100644 index 00000000..aa5d8200 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/lsp/mod.rs @@ -0,0 +1,129 @@ +use anyhow::Result; +use gpui::{App, Context, Hsla, SharedString, Task, Window}; +use ropey::Rope; +use std::rc::Rc; + +use crate::input::{EditorMode, InputBaseState, RopeExt}; + +mod code_actions; +mod completions; +mod definitions; +mod document_colors; +mod hover; +mod overlay; +mod semantic_tokens; + +pub use code_actions::*; +pub use completions::*; +pub use definitions::*; +pub use document_colors::*; +pub use hover::*; +pub use overlay::*; +pub use semantic_tokens::*; + +/// Host hook to show a document when following an LSP location +/// (Go to Definition), modeled after the `window/showDocument` request. +/// +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#window_showDocument +/// +/// Called before the built-in behavior. Return `true` if the host has shown +/// the document (e.g. opened a docs window for a virtual/external URI); +/// return `false` to fall through to the default handling (`external` URIs +/// open in the browser, anything else jumps within the current document). +pub type ShowDocumentHandler = + Rc bool>; + +/// LSP ServerCapabilities +/// +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverCapabilities +pub struct Lsp { + /// The completion provider. + pub completion_provider: Option>, + /// The code action providers. + pub code_action_providers: Vec>, + /// The hover provider. + pub hover_provider: Option>, + /// The definition provider. + pub definition_provider: Option>, + /// The document color provider. + pub document_color_provider: Option>, + /// The range semantic tokens provider. + pub semantic_tokens_provider: Option>, + /// Optional host hook to show documents for Go to Definition locations, + /// following the `window/showDocument` request (see [`ShowDocumentHandler`]). + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#window_showDocument + pub show_document: Option, + + /// Display options for the completion popover. + pub completion_menu: CompletionMenuOptions, + + pub(crate) document_colors: Vec<(lsp_types::Range, Hsla)>, + /// Cached semantic tokens as absolute position ranges + theme token-type + /// names. Color is resolved from the name at paint time so theme switches + /// take effect without a refetch. + pub(crate) semantic_tokens: Vec<(lsp_types::Range, SharedString)>, + pub(crate) _hover_task: Task>, + pub(crate) _document_color_task: Task<()>, + pub(crate) _semantic_tokens_task: Task<()>, +} + +impl Default for Lsp { + fn default() -> Self { + Self { + completion_provider: None, + code_action_providers: vec![], + hover_provider: None, + definition_provider: None, + document_color_provider: None, + completion_menu: CompletionMenuOptions::default(), + semantic_tokens_provider: None, + show_document: None, + document_colors: vec![], + semantic_tokens: vec![], + _hover_task: Task::ready(Ok(())), + _document_color_task: Task::ready(()), + _semantic_tokens_task: Task::ready(()), + } + } +} + +impl Lsp { + /// Update the LSP when the text changes. + pub(crate) fn update( + &mut self, + text: &Rope, + window: &mut Window, + cx: &mut Context>, + ) { + self.update_document_colors(text, window, cx); + self.update_semantic_tokens(text, window, cx); + } + + /// Reset all LSP states. + pub(crate) fn reset(&mut self) { + self.document_colors.clear(); + self.semantic_tokens.clear(); + self._hover_task = Task::ready(Ok(())); + self._document_color_task = Task::ready(()); + self._semantic_tokens_task = Task::ready(()); + } +} + +impl InputBaseState { + /// Apply a list of [`lsp_types::TextEdit`] to mutate the text. + pub fn apply_lsp_edits( + &mut self, + text_edits: &Vec, + window: &mut Window, + cx: &mut Context, + ) { + for edit in text_edits { + let start = self.text.position_to_offset(&edit.range.start); + let end = self.text.position_to_offset(&edit.range.end); + + let range_utf16 = self.range_to_utf16(&(start..end)); + self.replace_text_in_range_silent(Some(range_utf16), &edit.new_text, window, cx); + } + } +} diff --git a/vendor/gpui-base/src/input/editor/lsp/overlay.rs b/vendor/gpui-base/src/input/editor/lsp/overlay.rs new file mode 100644 index 00000000..bae1c9a9 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/lsp/overlay.rs @@ -0,0 +1,217 @@ +use super::*; +use crate::input::EditorMode; +use std::ops::Range; + +use lsp_types::{CompletionItem, Hover}; + +#[derive(Clone, Debug, Default)] +pub struct CompletionMenuState { + pub open: bool, + pub trigger_start_offset: Option, + pub query: String, + pub items: Vec, + revision: u64, +} + +impl CompletionMenuState { + /// Bumped whenever the content changes. + /// + /// A renderer that mirrors this menu compares revisions to decide whether + /// to rebuild, so it never has to compare the item list itself. + pub fn revision(&self) -> u64 { + self.revision + } + + pub(super) fn bump(&mut self) { + self.revision = self.revision.wrapping_add(1); + } +} + +#[derive(Clone, Debug, Default)] +pub struct CodeActionMenuState { + pub open: bool, + pub items: Vec, + revision: u64, +} + +impl CodeActionMenuState { + /// Bumped whenever the content changes. See [`CompletionMenuState::revision`]. + pub fn revision(&self) -> u64 { + self.revision + } + + pub(super) fn bump(&mut self) { + self.revision = self.revision.wrapping_add(1); + } +} + +#[derive(Clone, Debug)] +pub struct HoverPopoverState { + pub symbol_range: Range, + pub hover: Hover, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ContextMenuContent { + pub(crate) completion: CompletionMenuState, + pub(crate) code_action: CodeActionMenuState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputOverlayKind { + Completion, + CodeAction, +} + +impl InputBaseState { + pub fn present_completion_items( + &mut self, + trigger_start_offset: usize, + query: impl Into, + items: Vec, + cx: &mut Context, + ) { + self.extras + .context_menu_content + .completion + .trigger_start_offset = Some(trigger_start_offset); + self.extras.context_menu_content.completion.query = query.into(); + self.extras.context_menu_content.completion.items = items; + self.extras.context_menu_content.completion.open = + !self.extras.context_menu_content.completion.items.is_empty(); + self.extras.context_menu_content.completion.bump(); + cx.notify(); + } + + pub fn present_code_actions(&mut self, items: Vec, cx: &mut Context) { + self.extras.context_menu_content.code_action.items = items; + self.extras.context_menu_content.code_action.open = !self + .extras + .context_menu_content + .code_action + .items + .is_empty(); + self.extras.context_menu_content.code_action.bump(); + cx.notify(); + } + + pub fn present_hover( + &mut self, + symbol_range: Range, + hover: Hover, + cx: &mut Context, + ) { + self.extras.hover_popover = Some(HoverPopoverState { + symbol_range, + hover, + }); + cx.notify(); + } + + pub fn present_diagnostic( + &mut self, + diagnostic: crate::input::DiagnosticEntry, + cx: &mut Context, + ) { + self.diagnostic_popover = Some(Rc::new(diagnostic)); + cx.notify(); + } + + pub fn clear_diagnostic_popover(&mut self, cx: &mut Context) { + if self.diagnostic_popover.take().is_some() { + cx.notify(); + } + } + + pub fn route_overlay_action( + &mut self, + action: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.handle_action_for_context_menu(action, window, cx) + } + + pub fn set_overlay_action_handler( + &mut self, + handler: impl Fn( + InputOverlayKind, + Box, + &mut Window, + &mut Context>, + ) -> bool + + 'static, + ) { + self.overlay_action_handler = Some(Rc::new(handler)); + } + + pub fn has_overlay_action_handler(&self) -> bool { + self.overlay_action_handler.is_some() + } + + pub fn dismiss_completion_overlay(&mut self, cx: &mut Context) { + if self.extras.context_menu_content.completion.open { + self.extras.context_menu_content.completion.open = false; + cx.notify(); + } + } + + pub fn dismiss_code_action_overlay(&mut self, cx: &mut Context) { + if self.extras.context_menu_content.code_action.open { + self.extras.context_menu_content.code_action.open = false; + cx.notify(); + } + } + + pub fn insert_completion( + &mut self, + item: &CompletionItem, + fallback_range: Range, + window: &mut Window, + cx: &mut Context, + ) { + let mut range = fallback_range; + let mut new_text = item.label.clone(); + if let Some(edit) = item.text_edit.as_ref() { + match edit { + lsp_types::CompletionTextEdit::Edit(edit) => { + new_text.clone_from(&edit.new_text); + range = self.text.position_to_offset(&edit.range.start) + ..self.text.position_to_offset(&edit.range.end); + } + lsp_types::CompletionTextEdit::InsertAndReplace(edit) => { + new_text.clone_from(&edit.new_text); + range = self.text.position_to_offset(&edit.replace.start) + ..self.text.position_to_offset(&edit.replace.end); + } + } + } else if let Some(insert_text) = item.insert_text.as_ref() { + new_text.clone_from(insert_text); + range = range.end..range.end; + } + self.completion_inserting = true; + let range = self.range_to_utf16(&range); + self.replace_text_in_range_silent(Some(range), &new_text, window, cx); + self.completion_inserting = false; + self.focus(window, cx); + } + + #[doc(hidden)] + pub fn completion_menu_state(&self) -> &CompletionMenuState { + &self.extras.context_menu_content.completion + } + + #[doc(hidden)] + pub fn code_action_menu_state(&self) -> &CodeActionMenuState { + &self.extras.context_menu_content.code_action + } + + pub fn hover_popover(&self) -> Option<&HoverPopoverState> { + self.extras.hover_popover.as_ref() + } + + pub fn dismiss_lsp_overlays(&mut self, cx: &mut Context) { + self.hide_context_menu(cx); + self.clear_hover_state(cx); + } +} diff --git a/vendor/gpui-base/src/input/editor/lsp/semantic_tokens.rs b/vendor/gpui-base/src/input/editor/lsp/semantic_tokens.rs new file mode 100644 index 00000000..171cec32 --- /dev/null +++ b/vendor/gpui-base/src/input/editor/lsp/semantic_tokens.rs @@ -0,0 +1,341 @@ +use std::ops::Range; + +use anyhow::Result; +use gpui::{App, Context, HighlightStyle, SharedString, Task, Window}; +use instant::Duration; +use lsp_types::{Position, SemanticTokens, SemanticTokensLegend}; +use ropey::Rope; + +use crate::input::{EditorMode, HighlightStyleResolver, InputBaseState, Lsp, RopeExt}; + +/// A provider of semantic highlighting tokens, layered on top of the +/// built-in tree-sitter [`SyntaxHighlighter`](crate::highlighter::SyntaxHighlighter). +/// +/// This is the editor counterpart of the LSP +/// `textDocument/semanticTokens/range` request (and Monaco Editor's +/// [`DocumentRangeSemanticTokensProvider`][monaco]). Like the other +/// providers on [`Lsp`](crate::input::Lsp) — `DocumentColorProvider`, +/// `HoverProvider`, … — it is installed on `InputBaseState::lsp`, fetched +/// asynchronously when the document changes, and its result is cached and +/// composed into the render pipeline. It does **not** replace the +/// tree-sitter highlighter. +/// +/// # Token names and theming +/// +/// Returned tokens are delta-encoded with a numeric `token_type` that +/// indexes [`legend`](Self::legend)`.token_types`. The editor resolves each +/// type *name* against the active +/// [`HighlightTheme`](crate::highlighter::HighlightTheme) at paint time — +/// the same vocabulary the tree-sitter path uses (`"keyword"`, `"comment"`, +/// `"string"`, …; `"keyword.modifier"` falls back to `"keyword"`). Because +/// the color is resolved from the name on every paint, theme switches +/// recolor semantic tokens with no provider cooperation. Token *modifiers* +/// are accepted but not currently mapped to styles. +/// +/// [monaco]: https://microsoft.github.io/monaco-editor/ +pub trait DocumentRangeSemanticTokensProvider { + /// The legend naming the numeric `token_type` field of the tokens + /// returned by [`semantic_tokens`](Self::semantic_tokens). Each entry in + /// [`SemanticTokensLegend::token_types`] is resolved against the active + /// [`HighlightTheme`](crate::highlighter::HighlightTheme). + fn legend(&self) -> SemanticTokensLegend; + + /// Fetches semantic tokens for the specified byte range. + /// + /// textDocument/semanticTokens/range + /// + /// + fn semantic_tokens( + &self, + text: &Rope, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>; +} + +impl Lsp { + /// Get semantic token styles that intersect with the visible byte range, + /// resolving each cached token's type name against `theme`. + /// + /// Called on every paint. The cache is sorted by start position, so this + /// binary-searches the small window of tokens that can touch the viewport + /// (`O(log N + visible)`) instead of scanning the whole document — only + /// the windowed candidates pay the position→byte conversion. Tokens + /// resolving to an empty byte range, or whose type name the theme does + /// not recognize, are skipped. + /// + /// Returns byte ranges and styles. + pub(crate) fn semantic_tokens_for_range( + &self, + text: &Rope, + visible_range: &Range, + theme: &dyn HighlightStyleResolver, + ) -> Vec<(Range, HighlightStyle)> { + if self.semantic_tokens.is_empty() { + return Vec::new(); + } + + let visible_start = text.offset_to_position(visible_range.start); + let visible_end = text.offset_to_position(visible_range.end); + + // Cache is sorted by `range.start`. A token can only touch the + // viewport if its start is before `visible_end` (upper bound) and it + // is not on a line entirely above the viewport's first line (lower + // bound — tokens are single-line, so an earlier line cannot reach in). + let hi = self + .semantic_tokens + .partition_point(|(range, _)| range.start < visible_end); + let lo = self + .semantic_tokens + .partition_point(|(range, _)| range.start.line < visible_start.line); + + self.semantic_tokens[lo..hi] + .iter() + .filter_map(|(range, name)| { + let start = text.position_to_offset(&range.start); + let end = text.position_to_offset(&range.end); + if start >= end || start >= visible_range.end || end <= visible_range.start { + return None; + } + + let style = theme.style(name.as_ref())?; + Some((start..end, style)) + }) + .collect() + } + + pub(crate) fn update_semantic_tokens( + &mut self, + text: &Rope, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(provider) = self.semantic_tokens_provider.as_ref() else { + return; + }; + + let provider = provider.clone(); + let legend = provider.legend(); + let text = text.clone(); + // Fetch the whole document; results are cached and filtered to the + // viewport at paint time (mirrors `update_document_colors`), so a + // scroll never needs a refetch. + let range = 0..text.len(); + let input_state = cx.entity(); + + // debounce timer 100ms + self._semantic_tokens_task = cx.spawn_in(window, async move |_, cx| { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + + let task_result = cx + .update(|window, cx| provider.semantic_tokens(&text, range, window, cx)) + .ok(); + + if let Some(task) = task_result { + if let Ok(tokens) = task.await { + let decoded = decode_semantic_tokens(&tokens, &legend); + let _ = input_state.update(cx, |input_state, cx| { + if decoded != input_state.extras.lsp.semantic_tokens { + input_state.extras.lsp.semantic_tokens = decoded; + cx.notify(); + } + }); + } + } + }); + } +} + +/// Decode the LSP delta-encoding of `tokens` into absolute +/// (position-range, type-name) pairs, sorted by start position. +/// +/// The type name is looked up in `legend.token_types`; tokens whose +/// `token_type` index is out of bounds are skipped. Color resolution is +/// deferred to paint time so theme switches take effect without a refetch. +fn decode_semantic_tokens( + tokens: &SemanticTokens, + legend: &SemanticTokensLegend, +) -> Vec<(lsp_types::Range, SharedString)> { + // Resolve the legend names once; tokens then share them via cheap + // ref-counted clones instead of allocating a String per token. + let names: Vec = legend + .token_types + .iter() + .map(|t| SharedString::from(t.as_str().to_owned())) + .collect(); + + let mut out = Vec::with_capacity(tokens.data.len()); + let mut line: u32 = 0; + let mut character: u32 = 0; + + for token in &tokens.data { + if token.delta_line > 0 { + line += token.delta_line; + character = token.delta_start; + } else { + character += token.delta_start; + } + + let Some(name) = names.get(token.token_type as usize) else { + continue; + }; + + let start = Position::new(line, character); + let end = Position::new(line, character + token.length); + out.push((lsp_types::Range { start, end }, name.clone())); + } + + out.sort_by_key(|(range, _)| range.start); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::hsla; + use lsp_types::{SemanticToken, SemanticTokenType, SemanticTokensLegend}; + + fn legend() -> SemanticTokensLegend { + SemanticTokensLegend { + token_types: vec![SemanticTokenType::KEYWORD, SemanticTokenType::COMMENT], + token_modifiers: vec![], + } + } + + struct TestTheme; + + impl HighlightStyleResolver for TestTheme { + fn style(&self, name: &str) -> Option { + (name == "keyword" || name == "comment").then_some(HighlightStyle { + color: Some(hsla(0.5, 0.5, 0.5, 1.)), + ..Default::default() + }) + } + } + + #[test] + fn test_decode_semantic_tokens_delta() { + // Two tokens: "keyword" at (0,0..4) and "comment" at (1,2..7). + let tokens = SemanticTokens { + result_id: None, + data: vec![ + SemanticToken { + delta_line: 0, + delta_start: 0, + length: 4, + token_type: 0, + token_modifiers_bitset: 0, + }, + SemanticToken { + delta_line: 1, + delta_start: 2, + length: 5, + token_type: 1, + token_modifiers_bitset: 0, + }, + ], + }; + + let decoded = decode_semantic_tokens(&tokens, &legend()); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].0.start, Position::new(0, 0)); + assert_eq!(decoded[0].0.end, Position::new(0, 4)); + assert_eq!(decoded[0].1.as_ref(), "keyword"); + // Second token's line is relative to the first (0 + 1), character + // resets because delta_line > 0. + assert_eq!(decoded[1].0.start, Position::new(1, 2)); + assert_eq!(decoded[1].0.end, Position::new(1, 7)); + assert_eq!(decoded[1].1.as_ref(), "comment"); + } + + #[test] + fn test_decode_skips_out_of_legend_index() { + let tokens = SemanticTokens { + result_id: None, + data: vec![SemanticToken { + delta_line: 0, + delta_start: 0, + length: 3, + token_type: 99, // out of legend bounds + token_modifiers_bitset: 0, + }], + }; + assert!(decode_semantic_tokens(&tokens, &legend()).is_empty()); + } + + #[test] + fn test_for_range_resolves_and_windows() { + let text = Rope::from("SELECT * FROM users\n-- a comment line\n"); + let theme = TestTheme; + + let mut lsp = Lsp::default(); + // "SELECT" (line 0, 0..6) as keyword; comment on line 1. + lsp.semantic_tokens = vec![ + ( + lsp_types::Range { + start: Position::new(0, 0), + end: Position::new(0, 6), + }, + SharedString::from("keyword"), + ), + ( + lsp_types::Range { + start: Position::new(1, 0), + end: Position::new(1, 17), + }, + SharedString::from("comment"), + ), + ]; + + // Visible range covering only line 0 (bytes 0..19). + let styles = lsp.semantic_tokens_for_range(&text, &(0..19), &theme); + assert_eq!( + styles.len(), + 1, + "only the line-0 token should be windowed in" + ); + assert_eq!(styles[0].0, 0..6, "keyword token maps to bytes 0..6"); + assert!( + styles[0].1 != HighlightStyle::default(), + "'keyword' should resolve to a non-default style on default-dark" + ); + } + + #[test] + fn test_for_range_binary_search_window() { + // 100 lines of "foo bar\n" (8 bytes each), one keyword token per line + // covering "foo" (cols 0..3). + let text = Rope::from("foo bar\n".repeat(100).as_str()); + let theme = TestTheme; + + let mut lsp = Lsp::default(); + lsp.semantic_tokens = (0..100u32) + .map(|line| { + ( + lsp_types::Range { + start: Position::new(line, 0), + end: Position::new(line, 3), + }, + SharedString::from("keyword"), + ) + }) + .collect(); + + // Only line 50 visible ("foo" at bytes 400..403). The binary-search + // window must return exactly that one token out of 100. + let line_bytes = "foo bar\n".len(); + let start = 50 * line_bytes; + let styles = lsp.semantic_tokens_for_range(&text, &(start..start + 3), &theme); + assert_eq!(styles.len(), 1); + assert_eq!(styles[0].0, start..start + 3); + + // Empty viewport before all tokens windows nothing in. + assert!( + lsp.semantic_tokens_for_range(&text, &(0..0), &theme) + .is_empty() + ); + } +} diff --git a/vendor/gpui-base/src/input/editor/mod.rs b/vendor/gpui-base/src/input/editor/mod.rs new file mode 100644 index 00000000..d4e16e3d --- /dev/null +++ b/vendor/gpui-base/src/input/editor/mod.rs @@ -0,0 +1,245 @@ +use gpui::{App, Div, Entity, InteractiveElement as _, IntoElement, RenderOnce, Stateful, Window}; + +use super::{EditorMode, InputBaseState, InputModeKind}; + +/// State for source-code editing. +/// +/// This is the shared editing engine in its code-editor kind. Languages, line +/// numbers, folding, indent guides, diagnostics, decorations, and the LSP +/// providers exist on this kind only, so an ordinary input or textarea never +/// exposes them. +pub type EditorState = InputBaseState; + +impl InputModeKind for EditorMode { + const MULTI_LINE: bool = true; + const CODE_EDITOR: bool = true; + + type Extras = super::EditorExtras; + + fn hover_definition_style( + state: &InputBaseState, + _cx: &App, + ) -> Option<(std::ops::Range, gpui::HighlightStyle)> { + state.hover_definition_style() + } + + fn hover_definition_hitbox( + state: &InputBaseState, + window: &mut Window, + _cx: &App, + ) -> Option { + state.hover_definition_hitbox(window) + } + + fn reset_language_features(state: &mut InputBaseState) { + state.extras.lsp.reset(); + } + + fn reset_annotations(state: &mut InputBaseState) { + state.extras.hover_popover = None; + state.extras.decorations.clear(); + } + + fn adjust_annotations( + state: &mut InputBaseState, + range: &std::ops::Range, + new_len: usize, + ) { + state.extras.decorations.adjust_for_edit(range, new_len); + } + + fn refresh_language_features( + state: &mut InputBaseState, + window: &mut Window, + cx: &mut gpui::Context>, + ) { + let text = state.text().clone(); + state.extras.lsp.update(&text, window, cx); + } + + fn accept_inline_completion( + state: &mut InputBaseState, + window: &mut Window, + cx: &mut gpui::Context>, + ) -> bool { + state.accept_inline_completion(window, cx) + } + + fn has_inline_completion(state: &InputBaseState) -> bool { + state.has_inline_completion() + } + + fn on_click( + state: &mut InputBaseState, + event: &gpui::MouseDownEvent, + offset: usize, + window: &mut Window, + cx: &mut gpui::Context>, + ) -> bool { + state.handle_click_hover_definition(event, offset, window, cx) + } + + fn clear_hover_state( + state: &mut InputBaseState, + cx: &mut gpui::Context>, + ) { + state.clear_hover_state(cx); + } + + fn on_text_typed( + state: &mut InputBaseState, + range: &std::ops::Range, + text: &str, + window: &mut Window, + cx: &mut gpui::Context>, + ) { + state.handle_completion_trigger(range, text, window, cx); + } + + fn clear_inline_completion( + state: &mut InputBaseState, + cx: &mut gpui::Context>, + ) { + state.clear_inline_completion(cx); + } + + fn hide_context_menu( + state: &mut InputBaseState, + cx: &mut gpui::Context>, + ) { + state.hide_context_menu(cx); + } + + fn is_context_menu_open(state: &InputBaseState, cx: &App) -> bool { + state.is_context_menu_open(cx) + } + + fn handle_context_menu_action( + state: &mut InputBaseState, + action: Box, + window: &mut Window, + cx: &mut gpui::Context>, + ) -> bool { + state.handle_action_for_context_menu(action, window, cx) + } + + fn on_hover_definition( + state: &mut InputBaseState, + offset: usize, + window: &mut Window, + cx: &mut gpui::Context>, + ) { + state.handle_hover_definition(offset, window, cx); + } + + fn on_mouse_move( + state: &mut InputBaseState, + offset: usize, + event: &gpui::MouseMoveEvent, + window: &mut Window, + cx: &mut gpui::Context>, + ) { + state.handle_mouse_move(offset, event, window, cx); + } + + fn drive_highlighter( + highlighter: &std::rc::Rc>>>, + edit: super::InputEdit, + text: &ropey::Rope, + folding: bool, + window: &mut Window, + cx: &mut gpui::Context>, + ) { + let mut highlighter = highlighter.borrow_mut(); + let Some(highlighter) = highlighter.as_mut() else { + return; + }; + highlighter.update(Some(edit), text, folding, window, cx); + } + + fn register_actions( + element: Stateful
, + entity: &Entity>, + window: &mut Window, + ) -> Stateful
{ + element + .on_action(window.listener_for(entity, InputBaseState::on_action_toggle_code_actions)) + .on_action(window.listener_for(entity, InputBaseState::on_action_go_to_definition)) + } +} + +impl EditorState { + /// The LSP providers and their cached results. + /// + /// This exists on the editor alone: an ordinary input or textarea has no + /// language server, and no field to reach one through. + pub fn lsp(&self) -> &super::Lsp { + &self.extras.lsp + } + + /// The LSP providers, mutably. Configure the providers through this. + pub fn lsp_mut(&mut self) -> &mut super::Lsp { + &mut self.extras.lsp + } +} + +/// An unstyled source-code editor. +#[derive(IntoElement)] +pub struct Editor { + state: Entity, +} + +impl Editor { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + } + } +} + +impl RenderOnce for Editor { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.state + } +} + +/// What a code editor exposes to the renderer. See [`crate::input::InputExtras`]. +impl crate::input::InputExtras for super::EditorExtras { + fn decoration_layers(&self) -> Vec<&[super::TextDecoration]> { + self.decorations.iter().collect() + } + + fn semantic_token_styles( + &self, + text: &ropey::Rope, + range: &std::ops::Range, + resolver: &dyn crate::input::HighlightStyleResolver, + ) -> Vec<(std::ops::Range, gpui::HighlightStyle)> { + self.lsp.semantic_tokens_for_range(text, range, resolver) + } + + fn document_color_swatches( + &self, + text: &ropey::Rope, + range: &std::ops::Range, + ) -> Vec<(std::ops::Range, gpui::Hsla)> { + self.lsp.document_colors_for_range(text, range) + } + + fn hover_symbol_range(&self) -> Option> { + self.hover_popover + .as_ref() + .map(|session| session.symbol_range.clone()) + } + + fn inline_completion_item(&self) -> Option<&lsp_types::InlineCompletionItem> { + self.inline_completion.item.as_ref() + } + + fn context_menu_capabilities(&self) -> (bool, bool) { + ( + self.lsp.definition_provider.is_some(), + !self.lsp.code_action_providers.is_empty(), + ) + } +} diff --git a/vendor/gpui-base/src/input/editor/search.rs b/vendor/gpui-base/src/input/editor/search.rs new file mode 100644 index 00000000..c16e99db --- /dev/null +++ b/vendor/gpui-base/src/input/editor/search.rs @@ -0,0 +1,425 @@ +use crate::input::InputModeKind; +use aho_corasick::AhoCorasick; +use gpui::{Context, Window}; +use ropey::Rope; +use std::{ops::Range, rc::Rc}; + +use super::{InputBaseState, Replace, RopeExt as _, Search, movement::MoveDirection}; + +/// Stateful, presentation-independent search engine used by text inputs. +#[derive(Debug, Clone)] +pub struct SearchMatcher { + text: Rope, + pub query: Option, + matched_ranges: Rc>>, + current_match_ix: usize, + replacing: bool, +} + +#[derive(Debug, Clone)] +pub struct SearchSession { + pub open: bool, + pub replace_mode: bool, + pub case_insensitive: bool, + pub query: String, + pub replacement: String, + pub anchor_offset: Option, + pub matcher: SearchMatcher, +} + +impl Default for SearchSession { + fn default() -> Self { + Self { + open: false, + replace_mode: false, + case_insensitive: true, + query: String::new(), + replacement: String::new(), + anchor_offset: None, + matcher: SearchMatcher::new(), + } + } +} + +impl SearchSession { + pub(crate) fn open(&mut self, replace_mode: bool, replaceable: bool) { + self.open = true; + self.replace_mode = replace_mode && replaceable; + } + + pub(crate) fn close(&mut self) { + self.open = false; + } + + pub(crate) fn update_query(&mut self, query: impl Into, case_insensitive: bool) { + self.query = query.into(); + self.case_insensitive = case_insensitive; + self.matcher.update_query(&self.query, case_insensitive); + } +} + +impl InputBaseState { + pub fn open_search(&mut self, replace_mode: bool, cx: &mut Context) { + if !self.searchable { + return; + } + self.search_session + .open(replace_mode, self.is_replaceable()); + let selected = self.selected_text().to_string(); + if !selected.is_empty() { + self.search_session.query = selected; + } + self.search_session.anchor_offset = self + .last_layout + .as_ref() + .map(|layout| layout.visible_range_offset.start); + self.search_session.matcher.update_query( + &self.search_session.query, + self.search_session.case_insensitive, + ); + self.search_session.matcher.update(&self.text); + if let Some(anchor) = self.search_session.anchor_offset { + self.search_session.matcher.update_cursor_by_offset(anchor); + } + cx.notify(); + } + + pub fn search_session(&self) -> &SearchSession { + &self.search_session + } + + #[doc(hidden)] + pub fn set_search_replace_mode(&mut self, replace_mode: bool, cx: &mut Context) { + self.search_session.replace_mode = replace_mode && self.is_replaceable(); + cx.notify(); + } + + /// Returns true if the search panel can replace the matches. + /// + /// This is false when the input is not `replaceable`, or when it is + /// `disabled` or `readonly`. + pub fn is_replaceable(&self) -> bool { + self.replaceable && self.is_editable() + } + + pub fn set_search_query( + &mut self, + query: impl Into, + case_insensitive: bool, + cx: &mut Context, + ) { + self.search_session.update_query(query, case_insensitive); + self.search_session.matcher.update(&self.text); + cx.notify(); + } + + pub fn close_search(&mut self, cx: &mut Context) { + self.search_session.close(); + cx.notify(); + } + + pub fn next_search_match(&mut self, cx: &mut Context) -> Option> { + let previous = self.search_session.matcher.current_match_index(); + let range = self.search_session.matcher.next()?; + let direction = (self.search_session.matcher.current_match_index() > previous) + .then_some(MoveDirection::Down); + self.scroll_to(range.end, direction, cx); + Some(range) + } + + pub fn previous_search_match(&mut self, cx: &mut Context) -> Option> { + let previous = self.search_session.matcher.current_match_index(); + let range = self.search_session.matcher.next_back()?; + let direction = (self.search_session.matcher.current_match_index() < previous) + .then_some(MoveDirection::Up); + self.scroll_to(range.start, direction, cx); + Some(range) + } + + pub fn replace_current_search_match( + &mut self, + replacement: &str, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.is_replaceable() { + return false; + } + let matcher = &mut self.search_session.matcher; + let Some(range) = matcher + .matched_ranges() + .get(matcher.current_match_index()) + .cloned() + else { + return false; + }; + let next = matcher.peek().unwrap_or_else(|| range.clone()); + let direction = matcher + .has_next_without_wrap() + .then_some(MoveDirection::Down); + if direction.is_none() { + matcher.set_current_match_index(0); + } + matcher.begin_replacement(); + let range_utf16 = self.range_to_utf16(&range); + self.scroll_to(next.end, direction, cx); + self.replace_text_in_range_silent(Some(range_utf16), replacement, window, cx); + true + } + + pub fn replace_all_search_matches( + &mut self, + replacement: &str, + window: &mut Window, + cx: &mut Context, + ) -> usize { + if !self.is_replaceable() { + return 0; + } + let ranges = self.search_session.matcher.matched_ranges(); + if ranges.is_empty() { + return 0; + } + let mut text = self.text.clone(); + for range in ranges.iter().rev() { + text.replace(range.clone(), replacement); + } + self.search_session.matcher.begin_replacement(); + let count = ranges.len(); + self.replace_text_in_range_silent(Some(0..self.text.len()), &text.to_string(), window, cx); + self.scroll_to(0, Some(MoveDirection::Down), cx); + count + } + + pub(super) fn update_search(&mut self, _cx: &mut gpui::App) { + self.search_session.matcher.update(&self.text); + } + + pub(super) fn on_action_search(&mut self, _: &Search, _: &mut Window, cx: &mut Context) { + if !self.searchable { + return; + } + self.open_search(false, cx); + } + + pub(super) fn on_action_replace( + &mut self, + _: &Replace, + _: &mut Window, + cx: &mut Context, + ) { + if !self.searchable { + return; + } + self.open_search(true, cx); + } +} + +impl Default for SearchMatcher { + fn default() -> Self { + Self::new() + } +} + +impl SearchMatcher { + pub fn new() -> Self { + Self { + text: "".into(), + query: None, + matched_ranges: Rc::new(Vec::new()), + current_match_ix: 0, + replacing: false, + } + } + + /// Update the source text and recompute matches. + pub fn update(&mut self, text: &Rope) { + if self.text.eq(text) { + self.replacing = false; + return; + } + self.text = text.clone(); + self.update_matches(); + } + + pub fn update_query(&mut self, query: &str, case_insensitive: bool) { + self.query = (!query.is_empty()).then(|| { + AhoCorasick::builder() + .ascii_case_insensitive(case_insensitive) + .build([query]) + .expect("failed to build input search query") + }); + self.update_matches(); + } + + pub fn matched_ranges(&self) -> Rc>> { + self.matched_ranges.clone() + } + + pub fn current_match_index(&self) -> usize { + self.current_match_ix + } + + pub fn len(&self) -> usize { + self.matched_ranges.len() + } + + pub fn is_empty(&self) -> bool { + self.matched_ranges.is_empty() + } + + pub fn label(&self) -> String { + if self.is_empty() { + "0/0".into() + } else { + format!("{}/{}", self.current_match_ix + 1, self.len()) + } + } + + fn peek(&self) -> Option> { + self.next_index() + .and_then(|ix| self.matched_ranges.get(ix).cloned()) + } + + fn has_next_without_wrap(&self) -> bool { + self.current_match_ix < self.matched_ranges.len().saturating_sub(1) + } + + pub fn update_cursor_by_offset(&mut self, offset: usize) { + for (ix, range) in self.matched_ranges.iter().enumerate() { + self.current_match_ix = ix; + if range.contains(&offset) || range.end >= offset { + return; + } + } + } + + /// Preserve the current logical match while a replacement mutates text. + fn begin_replacement(&mut self) { + self.replacing = true; + } + + fn set_current_match_index(&mut self, index: usize) { + self.current_match_ix = index.min(self.matched_ranges.len().saturating_sub(1)); + } + + fn next_index(&self) -> Option { + if self.is_empty() { + None + } else if self.has_next_without_wrap() { + Some(self.current_match_ix + 1) + } else { + Some(0) + } + } + + fn update_matches(&mut self) { + let mut ranges = Vec::new(); + if let Some(query) = &self.query { + let text = self.text.to_string(); + ranges.extend( + query + .stream_find_iter(text.as_bytes()) + .map(|result| result.expect("input search match").range()), + ); + } + self.matched_ranges = Rc::new(ranges); + if !self.replacing || self.is_empty() { + self.current_match_ix = 0; + } else { + self.current_match_ix = self.current_match_ix.min(self.len() - 1); + } + self.replacing = false; + } +} + +impl Iterator for SearchMatcher { + type Item = Range; + + fn next(&mut self) -> Option { + let ix = self.next_index()?; + self.current_match_ix = ix; + self.matched_ranges.get(ix).cloned() + } +} + +impl DoubleEndedIterator for SearchMatcher { + fn next_back(&mut self) -> Option { + if self.is_empty() { + return None; + } + if self.current_match_ix == 0 { + self.current_match_ix = self.len(); + } + self.current_match_ix -= 1; + self.matched_ranges.get(self.current_match_ix).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_navigates_and_preserves_replacement_position() { + let mut matcher = SearchMatcher::new(); + matcher.update(&Rope::from("foo FOO foo")); + matcher.update_query("foo", true); + assert_eq!(&*matcher.matched_ranges(), &[0..3, 4..7, 8..11]); + assert_eq!(matcher.next(), Some(4..7)); + assert_eq!(matcher.next_back(), Some(0..3)); + + matcher.set_current_match_index(2); + matcher.begin_replacement(); + matcher.update(&Rope::from("foo FOO bar")); + assert_eq!(matcher.current_match_index(), 1); + } + + #[test] + fn next_wraps_to_start() { + let mut matcher = SearchMatcher::new(); + matcher.update(&Rope::from(".....aaaaa.....aaaaa.....aaaaa")); + matcher.update_query("aaaaa", false); + matcher.set_current_match_index(2); + assert_eq!(matcher.next(), Some(5..10)); + } + + #[test] + fn replacement_keeps_current_match_index_on_next_match() { + let mut matcher = SearchMatcher::new(); + matcher.update(&Rope::from("foo foo foo")); + matcher.update_query("foo", true); + assert_eq!(matcher.label(), "1/3"); + + assert!(matcher.has_next_without_wrap()); + matcher.begin_replacement(); + matcher.update(&Rope::from("bar foo foo")); + assert_eq!(matcher.current_match_index(), 0); + assert_eq!(matcher.matched_ranges()[0], 4..7); + assert_eq!(matcher.label(), "1/2"); + + matcher.set_current_match_index(1); + assert!(!matcher.has_next_without_wrap()); + matcher.set_current_match_index(0); + matcher.begin_replacement(); + matcher.update(&Rope::from("bar foo bar")); + assert_eq!(matcher.current_match_index(), 0); + assert_eq!(matcher.matched_ranges()[0], 4..7); + assert_eq!(matcher.label(), "1/1"); + } + + #[test] + fn update_matches_clamps_current_match_index_while_replacing() { + let mut matcher = SearchMatcher::new(); + matcher.update(&Rope::from("foo foo foo")); + matcher.update_query("foo", true); + matcher.set_current_match_index(2); + matcher.begin_replacement(); + + matcher.update(&Rope::from("foo xoo foo")); + + assert_eq!(matcher.len(), 2); + assert_eq!(matcher.current_match_index(), 1); + assert_eq!(matcher.label(), "2/2"); + } +} diff --git a/vendor/gpui-base/src/input/input/mod.rs b/vendor/gpui-base/src/input/input/mod.rs new file mode 100644 index 00000000..f5bb65c7 --- /dev/null +++ b/vendor/gpui-base/src/input/input/mod.rs @@ -0,0 +1,33 @@ +use gpui::{App, Entity, IntoElement, RenderOnce, Window}; + +use super::{InputBaseState, InputMode}; + +/// State for a single-line text input. +/// +/// This is the shared editing engine in its single-line kind. Multi-line +/// layout, auto-grow, and code-editor configuration do not exist on this type — +/// those methods live on [`super::TextareaState`] and [`super::EditorState`]. +pub type InputState = InputBaseState; + +/// An unstyled single-line text input. +/// +/// Applications that need a fully styled control can wrap this state with +/// their own presentation or use `gpui-component::Input`. +#[derive(IntoElement)] +pub struct Input { + state: Entity, +} + +impl Input { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + } + } +} + +impl RenderOnce for Input { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.state + } +} diff --git a/vendor/gpui-base/src/input/mod.rs b/vendor/gpui-base/src/input/mod.rs new file mode 100644 index 00000000..e8d9c2b2 --- /dev/null +++ b/vendor/gpui-base/src/input/mod.rs @@ -0,0 +1,99 @@ +//! Text input: the shared editing engine and the three states built on it. +//! +//! Nothing here should be `pub` unless it is reachable from outside the crate. +//! A `pub` on an item behind a private module says something the module path +//! contradicts, and it hides the real API surface from anyone reading it. +#![warn(unreachable_pub)] + +use gpui::App; + +/// Character used by masked editor modes. +pub(crate) const MASK_CHAR: char = '•'; + +mod base; +#[path = "base/blink_cursor.rs"] +pub(crate) mod blink_cursor; +#[path = "base/change.rs"] +mod change; +#[path = "base/cursor.rs"] +mod cursor; +#[path = "editor/decorations.rs"] +mod decorations; +#[path = "editor/diagnostics.rs"] +mod diagnostics; +#[path = "editor/display_map/mod.rs"] +mod display_map; +mod editor; +#[path = "base/element.rs"] +mod element; +#[path = "editor/highlighting.rs"] +mod highlighting; +#[path = "editor/indent.rs"] +mod indent; +mod input; +#[path = "base/kind.rs"] +mod kind; +#[path = "base/layout.rs"] +mod layout; +#[path = "editor/lsp/mod.rs"] +mod lsp; +#[path = "base/mask_pattern.rs"] +mod mask_pattern; +#[path = "base/mode.rs"] +mod mode; +#[path = "base/movement.rs"] +mod movement; +#[path = "base/native.rs"] +mod native; +#[path = "base/rope_ext.rs"] +mod rope_ext; +#[path = "editor/search.rs"] +mod search; +#[path = "base/selection.rs"] +mod selection; +#[path = "base/state.rs"] +mod state; +mod textarea; +#[path = "base/undo_manager.rs"] +mod undo_manager; + +pub(crate) fn init(cx: &mut App) { + state::init(cx); +} + +pub use crate::number_input::{NumberInputEvent, NumberStep}; +pub use base::{InputBase, InputContextMenuCapabilities, InputStyles}; +pub use cursor::Selection; +pub use decorations::{TextDecoration, TextDecorationCollection}; +pub use diagnostics::{ + Diagnostic, DiagnosticEntry, DiagnosticRelatedInformation, DiagnosticSet, DiagnosticSeverity, + DiagnosticSummary, DiagnosticTag, RelatedInformation, +}; +pub use display_map::{BufferPoint, DisplayMap, DisplayPoint, FoldRange, WrappingIndent}; +pub use editor::{Editor, EditorState}; +pub use highlighting::{ + DiagnosticColors, FoldIconRenderer, HighlightStyleResolver, InputEditorStyle, InputHighlighter, + InputHighlighterFactory, SharedHighlightStyleResolver, +}; +pub use indent::TabSize; +pub use input::{Input, InputState}; +pub use kind::{ + EditorExtras, EditorMode, InputExtras, InputMode, InputModeKind, MultiLineMode, TextareaMode, +}; +pub use lsp::{ + CodeActionItem, CodeActionMenuState, CodeActionProvider, CompletionMenuOptions, + CompletionMenuState, CompletionProvider, DefinitionProvider, DocumentColorProvider, + DocumentRangeSemanticTokensProvider, HoverPopoverState, HoverProvider, InputOverlayKind, Lsp, + ShowDocumentHandler, +}; +pub use lsp_types::Position; +pub use mask_pattern::MaskPattern; +#[cfg(target_os = "macos")] +#[doc(hidden)] +pub use native::set_text_content_type; +pub use native::{NativeMenu, NativeMenuItem}; +pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines}; +pub use ropey::Rope; +pub use search::{SearchMatcher, SearchSession}; +pub use state::*; +pub use textarea::{Textarea, TextareaState}; diff --git a/vendor/gpui-base/src/input/textarea/mod.rs b/vendor/gpui-base/src/input/textarea/mod.rs new file mode 100644 index 00000000..341d108e --- /dev/null +++ b/vendor/gpui-base/src/input/textarea/mod.rs @@ -0,0 +1,30 @@ +use gpui::{App, Entity, IntoElement, RenderOnce, Window}; + +use super::{InputBaseState, TextareaMode}; + +/// State for editing ordinary multi-line text. +/// +/// This is the shared editing engine in its multi-line kind. Code-editor +/// facilities such as languages, diagnostics, folding, and LSP do not exist on +/// this type — those methods live on [`super::EditorState`]. +pub type TextareaState = InputBaseState; + +/// An unstyled ordinary multi-line text input. +#[derive(IntoElement)] +pub struct Textarea { + state: Entity, +} + +impl Textarea { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + } + } +} + +impl RenderOnce for Textarea { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.state + } +} diff --git a/vendor/gpui-base/src/lib.rs b/vendor/gpui-base/src/lib.rs new file mode 100644 index 00000000..8cf3f254 --- /dev/null +++ b/vendor/gpui-base/src/lib.rs @@ -0,0 +1,190 @@ +//! Behavior and infrastructure foundations for GPUI applications. +//! +//! Primitives deliberately avoid presentation styles. Layout, positioning, +//! colors, sizing, and motion belong to applications or the +//! `gpui-component` façade. + +mod accordion; +pub mod actions; +mod alert_dialog; +pub mod animation; +#[doc(hidden)] +pub mod async_util; +mod auto_scroll; +mod avatar; +mod button; +mod calendar; +mod checkbox; +mod collapsible; +mod color_picker; +mod combobox; +pub mod component_traits; +mod date_picker; +mod dialog; +pub mod dock; +mod element_ext; +mod event; +mod focus_trap; +mod geometry; +mod global_state; +mod history; +mod hover_card; +mod index_path; +pub mod input; +mod link; +mod list_settings; +#[cfg(all(target_os = "macos", not(test)))] +mod macos_accessibility; +mod measure; +pub mod motion; +mod number_input; +mod otp_input; +mod pagination; +mod popover; +mod popup; +mod positioner; +mod progress; +mod radio; +mod radio_group; +mod resizable; +mod scrollbar; +mod select; +mod sheet; +pub mod slider; +mod state_style; +mod styled; +mod switch; +mod table; +mod tabs; +mod text_boundary; +mod text_selection; +mod theme; +pub mod theme_tokens; +mod toast; +mod toggle; +mod toggle_group; +mod tooltip; +mod tree; +mod virtual_list; + +pub use accordion::{Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger}; +pub use alert_dialog::{ + AlertDialog, AlertDialogAction, AlertDialogBackdrop, AlertDialogCancel, AlertDialogClose, + AlertDialogDescription, AlertDialogPopup, AlertDialogTitle, AlertDialogTrigger, +}; +pub use auto_scroll::AutoScroll; +pub use avatar::{Avatar, AvatarFallback, AvatarImage}; +pub use button::{Button, ButtonStyles}; +pub use calendar::{ + Calendar, CalendarEvent, CalendarItem, CalendarItemKind, CalendarItemState, CalendarState, + CalendarView, Date, IntervalMatcher, Matcher, RangeMatcher, +}; +pub use checkbox::{ + Checkbox, CheckboxIndicator, CheckboxIndicatorStyles, CheckboxState, CheckboxStyles, +}; +pub use collapsible::Collapsible; +pub use color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState, ColorSwatch, HslaSliders}; +pub use combobox::Combobox; +pub use component_traits::FocusableExt; +pub use component_traits::{Disableable, Selectable}; +pub use date_picker::DatePicker; +pub use dialog::{ + Dialog, DialogBackdrop, DialogChangeReason, DialogClose, DialogDescription, DialogHandle, + DialogPopup, DialogTitle, DialogTrigger, +}; +pub use element_ext::ElementExt; +pub use event::{InteractiveElementExt, OngoingScrollExt}; +pub use focus_trap::FocusTrapElement; +#[doc(hidden)] +pub use focus_trap::active_focus_trap; +pub use geometry::*; +pub use global_state::{DeferredPopover, GlobalState}; +pub use history::{History, HistoryItem}; +pub use hover_card::{HoverCard, HoverCardState}; +pub use index_path::IndexPath; +pub use input::{Editor, Input, InputBase, InputStyles, Textarea}; +pub use link::{Link, LinkStyles}; +pub use list_settings::ListSettings; +#[cfg(all(target_os = "macos", not(test)))] +#[doc(hidden)] +pub use macos_accessibility::install_window_hit_test_forwarder; +#[doc(hidden)] +pub use measure::measurement_enabled; +pub use measure::{Measure, measure, measure_if}; +pub use motion::{Interpolate, Transition, TransitionId, transition}; +pub use number_input::{ + Decrement, Increment, NumberInput, NumberInputEvent, NumberInputText, NumberStep, StepAction, + step_value, +}; +pub use otp_input::{OtpInput, OtpState}; +pub use pagination::{Pagination, PaginationItem, PaginationState}; +pub use popover::{Popover, PopoverState}; +pub use popup::{POPUP_PRIORITY, Popup}; +pub use positioner::{Align, Positioner, ResolvedPosition}; +pub use progress::{Progress, ProgressIndicator, ProgressTrack}; +pub use radio::{Radio, RadioStyles}; +pub use radio_group::RadioGroup; +#[doc(hidden)] +pub use resizable::{PANEL_MIN_SIZE, resize_handle}; +pub use resizable::{ + ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, ResizeHandleContext, + ResizeHandleRenderer, h_resizable, resizable_panel, v_resizable, +}; +pub use scrollbar::{ + Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarHandle, ScrollbarMode, ScrollbarMotion, + ScrollbarStyles, ScrollbarThumbStyle, ScrollbarTrackStyle, +}; +pub use select::Select; +pub use sheet::Sheet; +pub use slider::{Slider, SliderIndicator, SliderThumb, SliderTrack}; +pub use state_style::StateStyle; +#[cfg(any(feature = "inspector", debug_assertions))] +pub use styled::styled_ext_reflection_methods; +pub use styled::{RoleOverride, StyledExt, box_shadow, h_flex, v_flex}; +pub use switch::{ + Switch, SwitchStyles, SwitchThumb, SwitchThumbStyles, SwitchTrack, SwitchTrackStyles, +}; +pub use table::{Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow}; +pub use tabs::{Tab, TabStyles, Tabs}; +pub use text_selection::{ + TextSelection, TextSelectionContentKey, TextSelectionCoverage, TextSelectionEndpoint, + TextSelectionEvent, TextSelectionHandle, TextSelectionLayer, TextSelectionProjection, + TextSelectionRegistration, TextSelectionRun, TextSelectionScopeId, TextSelectionSnapshot, + TextSelectionWindowPoints, +}; +pub use theme::{ResizableTheme, ScrollbarTheme, Theme}; +pub use theme_tokens::{ + ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens, TextStyleToken, + TypographyTokens, +}; +pub use toast::{ + Toast, ToastAdvance, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState, + ToastTransitionStatus, +}; +pub use toggle::{Toggle, ToggleStyles}; +pub use toggle_group::ToggleGroup; +pub use tooltip::{Tooltip, TooltipOverlay, TooltipPositioner, TooltipRequest, TooltipTransition}; +pub use tree::{Tree, TreeEntry, TreeEntryState, TreeEvent, TreeItem, TreeState}; +#[doc(hidden)] +pub use tree::{init as init_tree, key_context as tree_key_context}; +#[doc(hidden)] +pub use virtual_list::virtual_list; +pub use virtual_list::{VirtualList, VirtualListScrollHandle, h_virtual_list, v_virtual_list}; + +use gpui::App; + +/// Initializes global infrastructure owned by the base layer. +pub fn init(cx: &mut App) { + let _ = Theme::global_mut(cx); + GlobalState::init(cx); + dialog::init(cx); + focus_trap::init(cx); + popover::init(cx); + sheet::init(cx); + combobox::init(cx); + color_picker::init(cx); + select::init(cx); + number_input::init(cx); + input::init(cx); + tree::init(cx); +} diff --git a/vendor/gpui-base/src/link.rs b/vendor/gpui-base/src/link.rs new file mode 100644 index 00000000..bbcfe138 --- /dev/null +++ b/vendor/gpui-base/src/link.rs @@ -0,0 +1,466 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString, + Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{StateStyle, StyledExt as _}; + +type ActivationHandler = Rc; +type OpenHandler = Rc; + +/// An unstyled link that owns focus, activation, and accessibility semantics. +/// +/// `href` is target data, not an instruction to launch a browser. Applications +/// inject navigation through [`Link::open_with`], allowing internal routes, +/// embedded web views, and external browsers to share the same behavior module. +/// `href` is also not rendered as text; applications provide visible content +/// through the element's child slot. +#[derive(IntoElement)] +pub struct Link { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + semantic_styles: LinkStyles, + href: Option, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_activate: Option, + open_with: Option, + accessibility_label: Option, + tab_index: isize, + tab_stop: bool, +} + +/// Semantic root styles supported by [`Link`]. +#[derive(Default)] +pub struct LinkStyles { + disabled: StyleRefinement, +} + +impl LinkStyles { + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl Link { + pub fn new(id: impl Into) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + semantic_styles: LinkStyles::default(), + href: None, + disabled: false, + children: SmallVec::new(), + on_activate: None, + open_with: None, + accessibility_label: None, + tab_index: 0, + tab_stop: true, + } + } + + /// Sets the application-defined navigation target. + pub fn href(mut self, href: impl Into) -> Self { + self.href = Some(href.into()); + self + } + + /// Injects the strategy used to open `href` on activation. + /// + /// Base never calls [`App::open_url`] on its own. + pub fn open_with( + mut self, + open: impl Fn(&str, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.open_with = Some(Rc::new(open)); + self + } + + /// Observes pointer or keyboard activation after the open strategy runs. + pub fn on_activate( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_activate = Some(Rc::new(handler)); + self + } + + /// Sets whether pointer and keyboard activation are ignored. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Configures application-owned styles for the link's semantic states. + pub fn styles(mut self, build: impl FnOnce(LinkStyles) -> LinkStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + self.disabled.then_some(&self.semantic_styles.disabled), + ) + } + + /// Sets the name exposed to accessibility clients. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Sets the focus traversal index. Use this within a GPUI tab group. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + /// Sets whether the link participates in keyboard focus traversal. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle { + window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + } +} + +impl Styled for Link { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Link { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Link { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Link {} + +impl RenderOnce for Link { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle(window, cx); + let disabled = self.disabled; + let style = self.resolved_style(); + let href = self.href; + let open_with = self.open_with; + let on_activate = self.on_activate; + let activates = on_activate.is_some() || href.is_some() && open_with.is_some(); + + self.base + .role(Role::Link) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .when(!disabled, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when(disabled, |this| { + this.on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + }) + .when(!disabled && activates, |this| { + this.on_click(move |event, window, cx| { + if let (Some(href), Some(open)) = (&href, &open_with) { + open(href.as_ref(), event, window, cx); + } + if let Some(on_activate) = &on_activate { + on_activate(event, window, cx); + } + }) + }) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + TestAppContext, VisualTestContext, accesskit, canvas, point, px, + }; + + struct LinkHarness { + disabled: bool, + activations: Rc>, + keyboard_events: Rc>, + opened: Rc>>, + parent_clicks: Rc>, + } + + impl Render for LinkHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let activations = self.activations.clone(); + let keyboard_events = self.keyboard_events.clone(); + let opened = self.opened.clone(); + let parent_clicks = self.parent_clicks.clone(); + + div() + .id("link-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Link::new("link") + .href("app://settings") + .disabled(self.disabled) + .size_full() + .open_with(move |href, _, _, _| { + opened.borrow_mut().push(href.to_owned()); + }) + .on_activate(move |event, _, _| { + activations.set(activations.get() + 1); + if matches!(event, ClickEvent::Keyboard(_)) { + keyboard_events.set(keyboard_events.get() + 1); + } + }), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + disabled: bool, + ) -> ( + &mut VisualTestContext, + Rc>, + Rc>, + Rc>>, + Rc>, + ) { + let activations = Rc::new(Cell::new(0)); + let keyboard_events = Rc::new(Cell::new(0)); + let opened = Rc::new(RefCell::new(Vec::new())); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let activations = activations.clone(); + let keyboard_events = keyboard_events.clone(); + let opened = opened.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| LinkHarness { + disabled, + activations, + keyboard_events, + opened, + parent_clicks, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, activations, keyboard_events, opened, parent_clicks) + } + + fn activate_key(cx: &mut VisualTestContext, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + #[gpui::test] + fn pointer_runs_injected_open_strategy_and_activation_once(cx: &mut TestAppContext) { + let (cx, activations, keyboard_events, opened, _) = harness(cx, false); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + + assert_eq!(activations.get(), 1); + assert_eq!(keyboard_events.get(), 0); + assert_eq!(&*opened.borrow(), &["app://settings"]); + assert_eq!(cx.opened_url(), None); + } + + #[gpui::test] + fn open_strategy_runs_before_activation_callback(cx: &mut TestAppContext) { + struct OrderedLink(Rc>>); + + impl Render for OrderedLink { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let opened = self.0.clone(); + let activated = self.0.clone(); + Link::new("ordered-link") + .href("app://ordered") + .size(px(100.)) + .open_with(move |_, _, _, _| opened.borrow_mut().push("open")) + .on_activate(move |_, _, _| activated.borrow_mut().push("activate")) + } + } + + let order = Rc::new(RefCell::new(Vec::new())); + let result = order.clone(); + let (_, cx) = cx.add_window_view(move |_, _| OrderedLink(order)); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + + assert_eq!(result.borrow().as_slice(), &["open", "activate"]); + } + + #[gpui::test] + fn enter_and_space_each_activate_once(cx: &mut TestAppContext) { + let (cx, activations, keyboard_events, opened, _) = harness(cx, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + activations.set(0); + opened.borrow_mut().clear(); + cx.update(|window, cx| { + assert!(window.focused(cx).is_some()); + window.draw(cx).clear(cx); + }); + + activate_key(cx, "enter"); + activate_key(cx, "space"); + + assert_eq!(activations.get(), 2); + assert_eq!(keyboard_events.get(), 2); + assert_eq!(&*opened.borrow(), &["app://settings", "app://settings"]); + assert_eq!(cx.opened_url(), None); + } + + #[gpui::test] + fn href_without_strategy_never_opens_externally(cx: &mut TestAppContext) { + struct TargetOnly; + + impl Render for TargetOnly { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Link::new("target-only") + .href("https://example.com") + .size(px(100.)) + } + } + + let (_, cx) = cx.add_window_view(|_, _| TargetOnly); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(cx.opened_url(), None); + } + + #[gpui::test] + fn disabled_link_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) { + let (cx, activations, _, opened, parent_clicks) = harness(cx, true); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + activate_key(cx, "enter"); + activate_key(cx, "space"); + + assert_eq!(activations.get(), 0); + assert!(opened.borrow().is_empty()); + assert_eq!(parent_clicks.get(), 0); + } + + #[test] + fn visual_state_styles_remain_application_owned() { + let _ = Link::new("states") + .styles(|styles| styles.disabled(|style| style.opacity(0.5))) + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.8)) + .focus_visible(|style| style.opacity(0.7)); + } + + #[test] + fn disabled_style_applies_only_while_disabled_and_then_wins() { + let enabled = Link::new("enabled") + .opacity(0.9) + .styles(|styles| styles.disabled(|style| style.opacity(0.5))); + assert_eq!(enabled.resolved_style().opacity, Some(0.9)); + + let disabled = Link::new("disabled") + .styles(|styles| styles.disabled(|style| style.opacity(0.5))) + .opacity(0.9) + .disabled(true); + assert_eq!(disabled.resolved_style().opacity, Some(0.5)); + + let semantic_only = Link::new("semantic-only") + .styles(|styles| styles.disabled(|style| style.opacity(0.5))) + .disabled(true); + assert_eq!(semantic_only.resolved_style().opacity, Some(0.5)); + } + + #[gpui::test] + fn accessibility_exposes_link_role_label_and_action_surface(cx: &mut TestAppContext) { + type Captured = Arc>>; + + struct A11yProbe { + captured: Captured, + } + + impl Render for A11yProbe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.captured.clone(); + canvas( + move |_, window, cx| { + let mut info = |link: Link| { + let mut node = accesskit::Node::new(Role::Link); + link.render(window, cx) + .into_element() + .write_a11y_info(&mut node); + node + }; + let enabled = info( + Link::new("enabled") + .href("app://settings") + .accessibility_label("Settings") + .open_with(|_, _, _, _| {}), + ); + let disabled = info( + Link::new("disabled") + .disabled(true) + .accessibility_label("Settings") + .on_activate(|_, _, _| {}), + ); + *captured.lock().unwrap() = Some((enabled, disabled)); + }, + |_, _, _, _| {}, + ) + } + } + + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let (enabled, disabled) = result.lock().unwrap().take().unwrap(); + + assert_eq!(enabled.role(), Role::Link); + assert_eq!(enabled.label(), Some("Settings")); + assert!(enabled.supports_action(accesskit::Action::Click)); + assert_eq!(enabled.url(), None); + + assert_eq!(disabled.role(), Role::Link); + assert!(!disabled.supports_action(accesskit::Action::Click)); + assert!(!disabled.is_disabled()); + } +} diff --git a/vendor/gpui-base/src/list_settings.rs b/vendor/gpui-base/src/list_settings.rs new file mode 100644 index 00000000..6cbd0b71 --- /dev/null +++ b/vendor/gpui-base/src/list_settings.rs @@ -0,0 +1,17 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Global settings for list presentation behavior. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListSettings { + /// Whether list items use the active highlight style by default. + pub active_highlight: bool, +} + +impl Default for ListSettings { + fn default() -> Self { + Self { + active_highlight: true, + } + } +} diff --git a/vendor/gpui-base/src/macos_accessibility.rs b/vendor/gpui-base/src/macos_accessibility.rs new file mode 100644 index 00000000..75e942c5 --- /dev/null +++ b/vendor/gpui-base/src/macos_accessibility.rs @@ -0,0 +1,78 @@ +use std::{ffi::CString, ptr::null_mut}; + +use gpui::Window; +use objc2::{ + Message, + encode::{Encode, EncodeArguments, EncodeReturn, Encoding}, + ffi::{class_addMethod, object_getClass}, + msg_send, + runtime::{AnyClass, AnyObject, MethodImplementation, Sel}, + sel, +}; +use objc2_app_kit::{NSView, NSWindow}; +use objc2_foundation::NSPoint; +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + +pub fn install_window_hit_test_forwarder(window: &Window) { + let Some(view) = ns_view(window) else { + return; + }; + let Some(window) = view.window() else { + return; + }; + + unsafe { + let class = object_getClass((&*window as *const NSWindow).cast::()); + if !class.is_null() { + add_method( + class.cast_mut(), + sel!(accessibilityHitTest:), + hit_test_forwarder as extern "C" fn(_, _, _) -> _, + ); + } + } +} + +extern "C" fn hit_test_forwarder(this: &NSWindow, _cmd: Sel, point: NSPoint) -> *mut AnyObject { + this.contentView().map_or_else(null_mut, |view| unsafe { + msg_send![&*view, accessibilityHitTest: point] + }) +} + +fn ns_view(window: &Window) -> Option<&NSView> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + unsafe { (handle.ns_view.as_ptr() as *const NSView).as_ref() } +} + +unsafe fn add_method(class: *mut AnyClass, sel: Sel, func: F) +where + T: Message + ?Sized, + F: MethodImplementation, +{ + let encs = F::Arguments::ENCODINGS; + debug_assert_eq!( + count_args(sel), + encs.len(), + "selector {sel:?} argument count does not match method implementation" + ); + + let types = method_type_encoding(&F::Return::ENCODING_RETURN, encs); + let success = unsafe { class_addMethod(class, sel, func.__imp(), types.as_ptr()) }; + let _ = success.as_bool(); +} + +fn count_args(sel: Sel) -> usize { + sel.name().to_bytes().iter().filter(|&&c| c == b':').count() +} + +fn method_type_encoding(ret: &Encoding, args: &[Encoding]) -> CString { + let mut types = format!("{ret}{}{}", <*mut AnyObject>::ENCODING, Sel::ENCODING); + for enc in args { + use core::fmt::Write; + write!(&mut types, "{enc}").unwrap(); + } + CString::new(types).unwrap() +} diff --git a/vendor/gpui-base/src/measure.rs b/vendor/gpui-base/src/measure.rs new file mode 100644 index 00000000..b8c17e8d --- /dev/null +++ b/vendor/gpui-base/src/measure.rs @@ -0,0 +1,48 @@ +use gpui::SharedString; + +#[inline] +pub fn measurement_enabled() -> bool { + std::env::var("ZED_MEASUREMENTS").is_ok() || std::env::var("GPUI_MEASUREMENTS").is_ok() +} + +/// Measures `f` when `if_` is true and measurement logging is enabled. +#[inline] +#[track_caller] +pub fn measure_if(name: impl Into, if_: bool, f: impl FnOnce()) { + if if_ && measurement_enabled() { + let measure = Measure::new(name); + f(); + measure.end(); + } else { + f(); + } +} + +/// Measures `f` when measurement logging is enabled. +#[inline] +#[track_caller] +pub fn measure(name: impl Into, f: impl FnOnce()) { + measure_if(name, true, f); +} + +/// An elapsed-time measurement emitted through `tracing` when ended. +pub struct Measure { + name: SharedString, + start: instant::Instant, +} + +impl Measure { + #[track_caller] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + start: instant::Instant::now(), + } + } + + #[track_caller] + pub fn end(self) { + let duration = self.start.elapsed(); + tracing::trace!("{} in {:?}", self.name, duration); + } +} diff --git a/vendor/gpui-base/src/motion.rs b/vendor/gpui-base/src/motion.rs new file mode 100644 index 00000000..11886c21 --- /dev/null +++ b/vendor/gpui-base/src/motion.rs @@ -0,0 +1,398 @@ +#[cfg(not(target_family = "wasm"))] +use std::time::Instant; +use std::{rc::Rc, time::Duration}; +#[cfg(target_family = "wasm")] +use web_time::Instant; + +use gpui::{App, ElementId, SharedString, Window}; + +use crate::animation::{Lerp, ease_out_cubic}; + +/// A value that can be interpolated between two application-owned targets. +pub trait Interpolate: Clone { + fn interpolate(&self, target: &Self, progress: f32) -> Self; +} + +impl Interpolate for T { + fn interpolate(&self, target: &Self, progress: f32) -> Self { + self.lerp(target, progress) + } +} + +/// CSS-like timing policy for a target-value transition. +/// +/// This type is intentionally separate from [`crate::animation::Transition`], +/// whose legacy interface applies concrete fade, slide, and size effects to an +/// element. A value transition never chooses a visual property for the caller. +#[derive(Clone)] +pub struct Transition { + duration: Duration, + delay: Duration, + easing: Rc f32>, +} + +impl Transition { + pub fn new(duration: Duration) -> Self { + Self { + duration, + delay: Duration::ZERO, + easing: Rc::new(ease_out_cubic), + } + } + + pub fn delay(mut self, delay: Duration) -> Self { + self.delay = delay; + self + } + + pub fn ease(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self { + self.easing = Rc::new(easing); + self + } + + fn sample(&self, progress: f32) -> f32 { + (self.easing)(progress.clamp(0.0, 1.0)) + } + + fn progress(&self, elapsed: Duration) -> f32 { + if elapsed <= self.delay { + return 0.0; + } + if self.duration.is_zero() { + return 1.0; + } + (elapsed.saturating_sub(self.delay).as_secs_f32() / self.duration.as_secs_f32()).min(1.0) + } +} + +/// Identifies one independently transitioning value. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct TransitionId(ElementId); + +impl From for TransitionId { + fn from(id: ElementId) -> Self { + Self(id) + } +} + +impl From<&'static str> for TransitionId { + fn from(id: &'static str) -> Self { + Self(id.into()) + } +} + +impl From for TransitionId { + fn from(id: String) -> Self { + Self(id.into()) + } +} + +impl From for TransitionId { + fn from(id: SharedString) -> Self { + Self(id.into()) + } +} + +impl From for TransitionId { + fn from(id: usize) -> Self { + Self(id.into()) + } +} + +impl From for TransitionId { + fn from(id: i32) -> Self { + Self(id.into()) + } +} + +impl From for ElementId { + fn from(id: TransitionId) -> Self { + ElementId::NamedChild(id.0.into(), "__base-transition-state".into()) + } +} + +impl From<(I, C)> for TransitionId +where + I: Into, + C: Into, +{ + fn from((id, channel): (I, C)) -> Self { + Self(ElementId::NamedChild(id.into().into(), channel.into())) + } +} + +#[derive(Clone)] +struct ValueTransition { + from: T, + target: T, + started_at: Instant, +} + +/// Returns the current value for a CSS-like transition toward `target`. +/// +/// State is keyed by `id`. The first value is adopted immediately; later target +/// changes transition from the value sampled at that instant. Components opt +/// into this function explicitly—base components do not install default motion. +/// +/// Call this while rendering an element, where GPUI keyed element state is +/// available. A channel id must identify one value type within that element. +pub fn transition( + id: impl Into, + target: T, + policy: Transition, + window: &mut Window, + cx: &mut App, +) -> T +where + T: Interpolate + PartialEq + 'static, +{ + let id: ElementId = id.into().into(); + let now = cx.background_executor().now(); + let state = window.use_keyed_state(id, cx, |_, _| ValueTransition { + from: target.clone(), + target: target.clone(), + started_at: now, + }); + + let snapshot = state.read(cx).clone(); + + if cx.reduce_motion() || policy.duration.is_zero() { + if snapshot.from != target || snapshot.target != target { + state.update(cx, |state, _| { + state.from = target.clone(); + state.target = target.clone(); + state.started_at = now; + }); + } + return target; + } + + let elapsed = now.saturating_duration_since(snapshot.started_at); + let progress = policy.progress(elapsed); + let sampled = snapshot + .from + .interpolate(&snapshot.target, policy.sample(progress)); + + let (value, running) = if snapshot.target != target { + state.update(cx, |state, _| { + state.from = sampled.clone(); + state.target = target.clone(); + state.started_at = now; + }); + (sampled, true) + } else { + (sampled, progress < 1.0 && snapshot.from != snapshot.target) + }; + if running { + window.request_animation_frame(); + } + value +} + +#[cfg(test)] +mod tests { + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + time::Duration, + }; + + use gpui::{Empty, IntoElement, Render, TestAppContext, WindowHandle, px, size}; + + use super::*; + + #[test] + fn transition_ids_accept_element_like_scalars_and_named_channels() { + assert_eq!( + TransitionId::from("opacity"), + TransitionId::from(ElementId::from("opacity")) + ); + assert_ne!( + TransitionId::from(("terms", "fill")), + TransitionId::from(("terms", "mark-opacity")) + ); + let _: TransitionId = 7usize.into(); + let _: TransitionId = 7i32.into(); + } + + struct TestView { + target: Rc>, + duration: Duration, + samples: Rc>>, + } + + impl Render for TestView { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { + self.samples.borrow_mut().push(transition( + ("test", "value"), + self.target.get(), + Transition::new(self.duration).ease(|t| t), + window, + cx, + )); + Empty + } + } + + struct DelayedView { + target: Rc>, + samples: Rc>>, + } + + impl Render for DelayedView { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { + self.samples.borrow_mut().push(transition( + ("delayed-test", "value"), + self.target.get(), + Transition::new(Duration::from_millis(100)) + .delay(Duration::from_millis(50)) + .ease(|t| t), + window, + cx, + )); + Empty + } + } + + struct Fixture { + window: WindowHandle, + target: Rc>, + samples: Rc>>, + } + + impl Fixture { + fn open(cx: &mut TestAppContext, duration: Duration) -> Self { + let target = Rc::new(Cell::new(0.0)); + let samples = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let target = target.clone(); + let samples = samples.clone(); + move |_, _| TestView { + target, + duration, + samples, + } + }); + cx.run_until_parked(); + Self { + window, + target, + samples, + } + } + + fn render(&self, cx: &mut TestAppContext, target: f32) -> f32 { + self.target.set(target); + self.window + .update(cx, |_, window, _| window.refresh()) + .unwrap(); + cx.run_until_parked(); + *self.samples.borrow().last().unwrap() + } + + fn pending_frame(&self, cx: &mut TestAppContext) -> usize { + self.window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap() + } + } + + #[gpui::test] + fn a_zero_duration_target_change_is_immediate(cx: &mut TestAppContext) { + let fixture = Fixture::open(cx, Duration::ZERO); + assert_eq!(fixture.render(cx, 1.0), 1.0); + } + + #[gpui::test] + fn a_changed_target_transitions_over_time(cx: &mut TestAppContext) { + let duration = Duration::from_millis(100); + let fixture = Fixture::open(cx, duration); + assert_eq!(fixture.render(cx, 10.0), 0.0); + + cx.executor().advance_clock(Duration::from_millis(50)); + assert_eq!(fixture.render(cx, 10.0), 5.0); + } + + #[gpui::test] + fn requested_animation_frames_resample_without_manual_refresh(cx: &mut TestAppContext) { + let duration = Duration::from_millis(100); + let fixture = Fixture::open(cx, duration); + assert_eq!(fixture.render(cx, 10.0), 0.0); + + cx.executor().advance_clock(Duration::from_millis(50)); + assert_eq!(fixture.pending_frame(cx), 1); + cx.run_until_parked(); + + assert_eq!(*fixture.samples.borrow().last().unwrap(), 5.0); + } + + #[gpui::test] + fn reversing_uses_the_current_sample_without_jumping(cx: &mut TestAppContext) { + let duration = Duration::from_millis(100); + let fixture = Fixture::open(cx, duration); + assert_eq!(fixture.render(cx, 10.0), 0.0); + + cx.executor().advance_clock(Duration::from_millis(50)); + assert_eq!(fixture.render(cx, 0.0), 5.0); + cx.executor().advance_clock(Duration::from_millis(25)); + assert_eq!(fixture.render(cx, 0.0), 3.75); + } + + #[gpui::test] + fn delay_holds_the_previous_value_before_interpolation(cx: &mut TestAppContext) { + let target = Rc::new(Cell::new(0.0)); + let samples = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let target = target.clone(); + let samples = samples.clone(); + move |_, _| DelayedView { target, samples } + }); + cx.run_until_parked(); + + target.set(10.0); + window.update(cx, |_, window, _| window.refresh()).unwrap(); + cx.run_until_parked(); + assert_eq!(*samples.borrow().last().unwrap(), 0.0); + + cx.executor().advance_clock(Duration::from_millis(50)); + window.update(cx, |_, window, _| window.refresh()).unwrap(); + cx.run_until_parked(); + assert_eq!(*samples.borrow().last().unwrap(), 0.0); + + cx.executor().advance_clock(Duration::from_millis(50)); + window.update(cx, |_, window, _| window.refresh()).unwrap(); + cx.run_until_parked(); + assert_eq!(*samples.borrow().last().unwrap(), 5.0); + } + + #[gpui::test] + fn a_completed_transition_stops_requesting_frames(cx: &mut TestAppContext) { + let duration = Duration::from_millis(100); + let fixture = Fixture::open(cx, duration); + fixture.render(cx, 1.0); + assert_eq!(fixture.pending_frame(cx), 1); + + cx.executor().advance_clock(duration); + assert_eq!(fixture.render(cx, 1.0), 1.0); + fixture.pending_frame(cx); + cx.run_until_parked(); + assert_eq!(fixture.pending_frame(cx), 0); + } + + #[gpui::test] + fn reduced_motion_adopts_the_target_without_requesting_a_frame(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); + let duration = Duration::from_millis(100); + let fixture = Fixture::open(cx, duration); + assert_eq!(fixture.render(cx, 1.0), 1.0); + assert_eq!(fixture.pending_frame(cx), 0); + } +} diff --git a/vendor/gpui-base/src/number_input.rs b/vendor/gpui-base/src/number_input.rs new file mode 100644 index 00000000..b3c53dc1 --- /dev/null +++ b/vendor/gpui-base/src/number_input.rs @@ -0,0 +1,489 @@ +use crate::input::InputState; +use std::rc::Rc; + +use gpui::Focusable; +use gpui::{ + AnyElement, App, Entity, EventEmitter, InteractiveElement as _, IntoElement, KeyBinding, + ParentElement, RenderOnce, Role, StatefulInteractiveElement as _, StyleRefinement, Styled, + Window, actions, div, prelude::FluentBuilder as _, +}; + +use crate::{Button, InputBase, StyledExt as _}; + +actions!(number_input, [Increment, Decrement]); + +const CONTEXT: &str = "NumberInput"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", Increment, Some(CONTEXT)), + KeyBinding::new("down", Decrement, Some(CONTEXT)), + ]); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StepAction { + Decrement, + Increment, +} + +/// Strategy used by numeric editors when stepping their value. +#[derive(Clone)] +pub enum NumberStep { + Fixed(f64), + ByValue(Rc f64>), +} + +impl NumberStep { + pub fn by_value(f: impl Fn(f64, StepAction, &mut gpui::App) -> f64 + 'static) -> Self { + Self::ByValue(Rc::new(f)) + } + + pub(crate) fn value(&self, current: f64, action: StepAction, cx: &mut gpui::App) -> f64 { + match self { + Self::Fixed(step) => *step, + Self::ByValue(f) => f(current, action, cx), + } + } +} + +impl From for NumberStep { + fn from(step: f64) -> Self { + Self::Fixed(step) + } +} + +#[derive(Clone)] +pub enum NumberInputEvent { + Step(StepAction), +} +impl EventEmitter for InputState {} + +impl InputState { + /// Apply a number-input step or emit a step event when stepping is caller-controlled. + fn apply_number_step( + &mut self, + action: StepAction, + window: &mut Window, + cx: &mut gpui::Context, + ) { + if self.disabled { + return; + } + if let Some(step) = self.number_step.clone() { + let value = self.unmask_value(); + let current = value.trim().parse::().unwrap_or(0.); + let step = step.value(current, action, cx); + if let Some(new_value) = + step_value(&value, action, step, self.number_min, self.number_max) + { + if self.is_valid_input(&new_value, cx) { + let range = self.range_to_utf16(&(0..self.value().len())); + self.replace_text_in_range_silent(Some(range), &new_value, window, cx); + return; + } + } else { + return; + } + } + cx.emit(NumberInputEvent::Step(action)); + } +} + +type StepHandler = Rc; +type ButtonDecorator = Box Button>; + +/// An unstyled spinbutton root composed from the foundational [`InputBase`] frame. +#[derive(IntoElement)] +pub struct NumberInput { + style: StyleRefinement, + children: Vec, + disabled: bool, + state: Entity, + on_step: Option, + decrement_button: Option, + increment_button: Option, + input: Option, + controls_right: bool, +} + +/// The built-in text region of a [`NumberInput`]. +/// +/// Applications provide the editor itself as a child and can style this region +/// without having to recreate the number input's fixed three-part structure. +#[derive(IntoElement)] +pub struct NumberInputText { + style: StyleRefinement, + children: Vec, +} + +impl NumberInputText { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: Vec::new(), + } + } +} + +impl Default for NumberInputText { + fn default() -> Self { + Self::new() + } +} + +impl Styled for NumberInputText { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for NumberInputText { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for NumberInputText { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + gpui::div() + .min_w_0() + .flex_1() + .children(self.children) + .refine_style(&self.style) + } +} + +impl NumberInput { + pub fn new(state: &Entity) -> Self { + Self { + style: StyleRefinement::default(), + children: Vec::new(), + disabled: false, + state: state.clone(), + on_step: None, + decrement_button: None, + increment_button: None, + input: None, + controls_right: false, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_step( + mut self, + handler: impl Fn(StepAction, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_step = Some(Rc::new(handler)); + self + } + + /// Decorate the built-in decrement button with application-owned content and styles. + pub fn decrement_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self { + self.decrement_button = Some(Box::new(decorate)); + self + } + + /// Decorate the built-in increment button with application-owned content and styles. + pub fn increment_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self { + self.increment_button = Some(Box::new(decorate)); + self + } + + /// Decorate the built-in text region with an editor, adornments, and styles. + pub fn input(mut self, input: impl IntoElement) -> Self { + self.input = Some(input.into_any_element()); + self + } + + /// Stack both step buttons on the right side of the text region. + pub fn controls_right(mut self) -> Self { + self.controls_right = true; + self + } +} + +impl Styled for NumberInput { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for NumberInput { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for NumberInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let disabled = self.disabled; + let controls_right = self.controls_right; + let on_step = self.on_step.unwrap_or_else(|| { + let state = self.state.clone(); + Rc::new(move |action, window, cx| { + state.update(cx, |state, cx| { + state.focus(window, cx); + state.apply_number_step(action, window, cx); + }); + }) + }); + self.state.update(cx, |state, _| state.ensure_number_mask()); + let value = self.state.read(cx).value().parse::().ok(); + let decrement_button = self.decrement_button.map_or_else( + || Button::new("decrement"), + |decorate| decorate(Button::new("decrement")), + ); + let increment_button = self.increment_button.map_or_else( + || Button::new("increment"), + |decorate| decorate(Button::new("increment")), + ); + let text = NumberInputText::new().children(self.input); + + // Stepping is driven from the focused editor, so the buttons never take + // focus themselves; otherwise every press would pull the focus ring off + // the frame and hand it straight back. + let decrement_button = decrement_button + .when(controls_right, |this| this.flex_1().min_h_0()) + .when(!controls_right, |this| this.flex_none()) + .focusable(false) + .disabled(disabled) + .on_click({ + let on_step = on_step.clone(); + move |_, window, cx| { + on_step(StepAction::Decrement, window, cx); + } + }); + let increment_button = increment_button + .when(controls_right, |this| this.flex_1().min_h_0()) + .when(!controls_right, |this| this.flex_none()) + .focusable(false) + .disabled(disabled) + .on_click({ + let on_step = on_step.clone(); + move |_, window, cx| { + on_step(StepAction::Increment, window, cx); + } + }); + + let content = if controls_right { + div() + .flex() + .items_center() + .size_full() + .child(text.children(self.children)) + .child( + div() + .h_full() + .flex() + .flex_col() + .child(increment_button) + .child(decrement_button), + ) + .into_any_element() + } else { + div() + .flex() + .items_center() + .size_full() + .child(decrement_button) + .child(text.children(self.children)) + .child(increment_button) + .into_any_element() + }; + + InputBase::new(("number-input", self.state.entity_id())) + .track_focus(&self.state.focus_handle(cx)) + .flex() + .items_center() + .disabled(disabled) + .role(Role::SpinButton) + .when_some(value, |this, value| this.aria_numeric_value(value)) + .key_context(CONTEXT) + .on_action({ + let on_step = on_step.clone(); + move |_: &Increment, window, cx| { + if disabled { + cx.propagate(); + } else { + on_step(StepAction::Increment, window, cx); + } + } + }) + .on_action(move |_: &Decrement, window, cx| { + if disabled { + cx.propagate(); + } else { + on_step(StepAction::Decrement, window, cx); + } + }) + .child(content) + .refine_style(&self.style) + .render(window, cx) + } +} + +/// Step a numeric string while preserving decimal precision and range direction. +pub fn step_value( + value: &str, + action: StepAction, + step: f64, + min: Option, + max: Option, +) -> Option { + fn fraction_digits(value: &str) -> usize { + value.split('.').nth(1).map_or(0, |fraction| fraction.len()) + } + + let current = value.trim().parse::().ok(); + let mut new_value = match action { + StepAction::Increment => current.unwrap_or(0.) + step, + StepAction::Decrement => current.unwrap_or(0.) - step, + }; + let mut digits = fraction_digits(value).max(fraction_digits(&step.to_string())); + if let Some(min) = min + && new_value < min + { + new_value = min; + digits = digits.max(fraction_digits(&min.to_string())); + } + if let Some(max) = max + && new_value > max + { + new_value = max; + digits = digits.max(fraction_digits(&max.to_string())); + } + + if let Some(current) = current { + let moved = match action { + StepAction::Increment => new_value > current, + StepAction::Decrement => new_value < current, + }; + if !moved { + return None; + } + } + + Some(format!("{new_value:.digits$}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::theme::Theme; + use gpui::{ + AppContext as _, Context, Entity, Modifiers, MouseButton, Render, TestAppContext, + VisualTestContext, point, px, + }; + + struct StepperHarness { + state: Entity, + } + + impl Render for StepperHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + NumberInput::new(&self.state) + .w(px(120.)) + .h(px(20.)) + .decrement_button(|button| button.w(px(20.)).h_full()) + .input(div().size_full()) + .increment_button(|button| button.w(px(20.)).h_full()) + } + } + + /// Pressing a step button must not move focus off the editor, not even for + /// the press itself: the frame draws a focus ring, so a focus round-trip + /// makes it flicker on every click. + #[gpui::test] + fn pressing_a_step_button_never_takes_focus_off_the_editor(cx: &mut TestAppContext) { + cx.update(crate::init); + + let mut created: Option> = None; + let window = cx.update(|cx| { + cx.open_window(Default::default(), |window, cx| { + cx.set_global(Theme::default()); + let state = cx.new(|cx| InputState::new(window, cx).step(1.)); + created = Some(state.clone()); + cx.new(|_| StepperHarness { state }) + }) + .unwrap() + }); + let state = created.unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + cx.update(|window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + window.draw(cx).clear(cx); + }); + cx.update(|window, cx| { + assert!( + state.read(cx).focus_handle(cx).is_focused(window), + "the editor should start focused" + ); + }); + + // The decrement button sits at the left edge of the control. + cx.simulate_mouse_move( + point(px(10.), px(10.)), + MouseButton::Left, + Modifiers::default(), + ); + cx.simulate_event(gpui::MouseDownEvent { + button: MouseButton::Left, + position: point(px(10.), px(10.)), + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.update(|window, cx| { + assert!( + state.read(cx).focus_handle(cx).is_focused(window), + "pressing the step button pulled focus off the editor" + ); + }); + + cx.simulate_event(gpui::MouseUpEvent { + button: MouseButton::Left, + position: point(px(10.), px(10.)), + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.update(|window, cx| { + assert!( + state.read(cx).focus_handle(cx).is_focused(window), + "releasing the step button left focus off the editor" + ); + }); + } + + #[test] + fn stepping_preserves_precision_and_directional_bounds() { + assert_eq!( + step_value("0.1", StepAction::Increment, 0.2, None, None).as_deref(), + Some("0.3") + ); + assert_eq!( + step_value("10", StepAction::Decrement, 1., Some(10.), None), + None + ); + assert_eq!( + step_value("99.5", StepAction::Increment, 1., None, Some(100.)).as_deref(), + Some("100.0") + ); + assert_eq!( + step_value("10", StepAction::Increment, 1., None, Some(10.)), + None + ); + assert_eq!( + step_value("5", StepAction::Decrement, 10., Some(0.), None).as_deref(), + Some("0") + ); + } +} diff --git a/vendor/gpui-base/src/otp_input.rs b/vendor/gpui-base/src/otp_input.rs new file mode 100644 index 00000000..d21e4982 --- /dev/null +++ b/vendor/gpui-base/src/otp_input.rs @@ -0,0 +1,228 @@ +use crate::{ + StyledExt as _, + input::{InputEvent, blink_cursor::BlinkCursor}, +}; +use gpui::{ + AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement, Render, RenderOnce, + SharedString, StyleRefinement, Styled, Subscription, Window, div, prelude::FluentBuilder as _, +}; + +/// Stateful input and focus behavior for a fixed-length numeric one-time code. +pub struct OtpState { + focus_handle: FocusHandle, + value: SharedString, + blink_cursor: Entity, + masked: bool, + length: usize, + _subscriptions: Vec, +} + +impl OtpState { + pub fn new(length: usize, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + let blink_cursor = cx.new(|_| BlinkCursor::new()); + let subscriptions = vec![ + cx.observe(&blink_cursor, |_, _, cx| cx.notify()), + cx.observe_window_activation(window, |this, window, cx| { + if window.is_window_active() && this.focus_handle.is_focused(window) { + this.blink_cursor.update(cx, |cursor, cx| cursor.start(cx)); + } + }), + cx.on_focus(&focus_handle, window, Self::on_focus), + cx.on_blur(&focus_handle, window, Self::on_blur), + ]; + Self { + focus_handle, + value: SharedString::default(), + blink_cursor, + masked: false, + length, + _subscriptions: subscriptions, + } + } + + pub fn default_value(mut self, value: impl Into) -> Self { + self.value = value.into(); + self + } + + pub fn set_value( + &mut self, + value: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.value = value.into(); + cx.notify(); + } + + pub fn value(&self) -> &SharedString { + &self.value + } + pub fn len(&self) -> usize { + self.length + } + pub fn is_empty(&self) -> bool { + self.value.is_empty() + } + pub fn is_masked(&self) -> bool { + self.masked + } + pub fn cursor_visible(&self, cx: &App) -> bool { + self.blink_cursor.read(cx).visible() + } + pub fn masked(mut self, masked: bool) -> Self { + self.masked = masked; + self + } + pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { + self.masked = masked; + cx.notify(); + } + pub fn focus(&self, window: &mut Window, cx: &mut Context) { + self.focus_handle.focus(window, cx); + } + + fn to_digit_char(value: char) -> Option { + value.to_digit(10).map(|_| value).or_else(|| { + let digit = (value as u32).checked_sub('0' as u32)?; + char::from_digit(digit, 10) + }) + } + + fn edit_value(value: &str, key: &str, key_char: Option<&str>, length: usize) -> Option { + let mut chars: Vec = value.chars().collect(); + if key == "backspace" { + chars.pop(); + } else { + let digit = key + .chars() + .next() + .and_then(Self::to_digit_char) + .or_else(|| key_char?.chars().next().and_then(Self::to_digit_char)); + let digit = digit?; + if chars.len() >= length { + return None; + } + chars.push(digit); + } + Some(chars.iter().collect()) + } + + fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some(value) = Self::edit_value( + &self.value, + &event.keystroke.key, + event.keystroke.key_char.as_deref(), + self.length, + ) else { + return; + }; + window.prevent_default(); + cx.stop_propagation(); + self.blink_cursor.update(cx, |cursor, cx| cursor.pause(cx)); + self.value = value.into(); + if self.value.chars().count() == self.length { + cx.emit(InputEvent::Change); + } + cx.notify(); + } + + fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { + self.blink_cursor.update(cx, |cursor, cx| cursor.start(cx)); + cx.emit(InputEvent::Focus); + } + fn on_blur(&mut self, _: &mut Window, cx: &mut Context) { + self.blink_cursor.update(cx, |cursor, cx| cursor.stop(cx)); + cx.emit(InputEvent::Blur); + } +} + +impl Focusable for OtpState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} +impl EventEmitter for OtpState {} +impl Render for OtpState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +/// Unstyled OTP interaction root. Applications provide the visual cells as children. +#[derive(IntoElement)] +pub struct OtpInput { + state: Entity, + disabled: bool, + style: StyleRefinement, + children: Vec, +} + +impl OtpInput { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + disabled: false, + style: StyleRefinement::default(), + children: vec![], + } + } + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} +impl Styled for OtpInput { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl ParentElement for OtpInput { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} +impl RenderOnce for OtpInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state; + div() + .id(("base-otp-input", state.entity_id())) + .track_focus(&state.read(cx).focus_handle) + .when(!self.disabled, |this| { + this.on_key_down(window.listener_for(&state, OtpState::on_key_down)) + }) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::OtpState; + + #[test] + fn keyboard_editing_backspaces_and_stops_at_length() { + assert_eq!( + OtpState::edit_value("12", "backspace", None, 4).as_deref(), + Some("1") + ); + assert_eq!( + OtpState::edit_value("12", "3", None, 4).as_deref(), + Some("123") + ); + assert_eq!(OtpState::edit_value("1234", "5", None, 4), None); + assert_eq!(OtpState::edit_value("12", "left", None, 4), None); + } + + #[test] + fn programmatic_values_remain_unfiltered_for_compatibility() { + // Programmatic values intentionally do not use the keyboard editor path. + // This captures the legacy contract: callers may display arbitrary or + // over-length values, while actual key entry remains digit-only. + let value: gpui::SharedString = "token-over-length".into(); + assert_eq!(value.as_ref(), "token-over-length"); + assert_eq!(OtpState::edit_value("token", "x", None, 2), None); + } +} diff --git a/vendor/gpui-base/src/pagination.rs b/vendor/gpui-base/src/pagination.rs new file mode 100644 index 00000000..76971e0a --- /dev/null +++ b/vendor/gpui-base/src/pagination.rs @@ -0,0 +1,281 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Role, + SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, +}; + +use crate::StyledExt as _; + +type PageChangeHandler = Rc; + +/// A visible destination in a pagination control. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum PaginationItem { + Page(usize), + Ellipsis(Range), +} + +/// The controlled behavior shared by every part of a pagination control. +#[derive(Clone)] +pub struct PaginationState { + current_page: usize, + total_pages: usize, + visible_pages: usize, + disabled: bool, + on_change: Option, +} + +impl PaginationState { + pub fn new(current_page: usize, total_pages: usize) -> Self { + let total_pages = total_pages.max(1); + Self { + current_page: current_page.clamp(1, total_pages), + total_pages, + visible_pages: 5, + disabled: false, + on_change: None, + } + } + + pub fn visible_pages(mut self, visible_pages: usize) -> Self { + self.visible_pages = visible_pages.max(5); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Handles a requested page change. + /// + /// Unlike the element-level controls, this is a model-level request that + /// may also come from the keyboard or from application code, so it does not + /// carry a pointer event. + pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + pub fn current_page(&self) -> usize { + self.current_page + } + + pub fn total_pages(&self) -> usize { + self.total_pages + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn has_on_change(&self) -> bool { + self.on_change.is_some() + } + + pub fn previous_page(&self) -> Option { + (!self.disabled && self.current_page > 1).then(|| self.current_page - 1) + } + + pub fn next_page(&self) -> Option { + (!self.disabled && self.current_page < self.total_pages).then(|| self.current_page + 1) + } + + /// Requests a controlled page change after applying the shared disabled, + /// bounds, and current-page guards. + pub fn request_page(&self, page: usize, window: &mut Window, cx: &mut App) { + if self.disabled || page == self.current_page || !(1..=self.total_pages).contains(&page) { + return; + } + + if let Some(on_change) = &self.on_change { + on_change(page, window, cx); + } + } + + pub fn items(&self) -> Vec { + calculate_items(self.current_page, self.total_pages, self.visible_pages) + } +} + +/// An unstyled pagination navigation landmark. +#[derive(IntoElement)] +pub struct Pagination { + base: gpui::Stateful, + state: PaginationState, + style: StyleRefinement, + accessibility_label: SharedString, + children: Vec, +} + +impl Pagination { + pub fn new(id: impl Into, state: PaginationState) -> Self { + Self { + base: div().id(id.into()), + state, + style: StyleRefinement::default(), + accessibility_label: "Pagination".into(), + children: Vec::new(), + } + } + + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = label.into(); + self + } + + pub fn state(&self) -> &PaginationState { + &self.state + } +} + +impl ParentElement for Pagination { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for Pagination { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for Pagination { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Pagination {} + +impl RenderOnce for Pagination { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::Navigation) + .aria_label(self.accessibility_label) + .children(self.children) + .refine_style(&self.style) + } +} + +fn calculate_items(current: usize, total: usize, max_visible: usize) -> Vec { + if total <= 1 { + return vec![]; + } + + let max_visible = max_visible.max(5); + if total <= max_visible { + return (1..=total).map(PaginationItem::Page).collect(); + } + + let mut pages = vec![PaginationItem::Page(1)]; + let side_pages = (max_visible - 3) / 2; + let start = if current <= side_pages + 1 { + 2 + } else if current > total - side_pages - 1 { + total - side_pages - 1 + } else { + current - side_pages + }; + + if start > 2 { + pages.push(PaginationItem::Ellipsis(2..start)); + } + + let end = if current >= total - side_pages { + total - 1 + } else if current <= side_pages + 1 { + side_pages + 2 + } else { + current + side_pages + }; + + pages.extend((start..=end).map(PaginationItem::Page)); + if end < total - 1 { + pages.push(PaginationItem::Ellipsis(end + 1..total)); + } + pages.push(PaginationItem::Page(total)); + pages +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + use gpui::{Element as _, accesskit}; + + #[test] + fn clamps_controlled_values_and_navigation_boundaries() { + let first = PaginationState::new(0, 0); + assert_eq!(first.current_page(), 1); + assert_eq!(first.total_pages(), 1); + assert_eq!(first.previous_page(), None); + assert_eq!(first.next_page(), None); + + let last = PaginationState::new(20, 10); + assert_eq!(last.current_page(), 10); + assert_eq!(last.previous_page(), Some(9)); + assert_eq!(last.next_page(), None); + assert_eq!(last.clone().disabled(true).previous_page(), None); + } + + #[test] + fn creates_pages_and_navigable_ellipsis_ranges() { + assert_eq!( + PaginationState::new(5, 10).visible_pages(7).items(), + vec![ + PaginationItem::Page(1), + PaginationItem::Ellipsis(2..3), + PaginationItem::Page(3), + PaginationItem::Page(4), + PaginationItem::Page(5), + PaginationItem::Page(6), + PaginationItem::Page(7), + PaginationItem::Ellipsis(8..10), + PaginationItem::Page(10), + ] + ); + } + + #[gpui::test] + fn validates_every_page_change_request(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + let requested = Rc::new(Cell::new(None)); + let state = PaginationState::new(3, 5).on_change({ + let requested = requested.clone(); + move |page, _, _| requested.set(Some(page)) + }); + + state.request_page(3, window, cx); + state.request_page(0, window, cx); + state.request_page(6, window, cx); + assert_eq!(requested.get(), None); + + state.request_page(4, window, cx); + assert_eq!(requested.get(), Some(4)); + requested.set(None); + state.clone().disabled(true).request_page(2, window, cx); + assert_eq!(requested.get(), None); + }); + } + + #[gpui::test] + fn exposes_a_named_navigation_landmark(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + let mut node = accesskit::Node::new(Role::Navigation); + Pagination::new("pagination", PaginationState::new(1, 5)) + .accessibility_label("Search results pages") + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + + assert_eq!(node.role(), Role::Navigation); + assert_eq!(node.label(), Some("Search results pages")); + }); + } +} diff --git a/vendor/gpui-base/src/popover.rs b/vendor/gpui-base/src/popover.rs new file mode 100644 index 00000000..26f812ad --- /dev/null +++ b/vendor/gpui-base/src/popover.rs @@ -0,0 +1,402 @@ +use std::rc::Rc; + +use gpui::{ + Anchor, AnyElement, App, Context, DismissEvent, ElementId, EventEmitter, FocusHandle, + Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement as _, + Render, RenderOnce, Role, StatefulInteractiveElement as _, Subscription, Window, div, + prelude::FluentBuilder as _, +}; + +use crate::{DeferredPopover, GlobalState, Popup, Selectable, actions::Cancel}; + +const CONTEXT: &str = "Popover"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]); +} + +type OpenChangeHandler = Rc; + +/// State and focus lifecycle for an unstyled popover. +/// +/// Applications own trigger/content construction, positioning, appearance, and +/// animation. This state owns the controlled open lifecycle, dismissal, focus +/// capture and restoration, and deferred-popup registration. +pub struct PopoverState { + focus_handle: FocusHandle, + tracked_focus_handle: Option, + previous_focus_handle: Option, + open: bool, + on_open_change: Option, + dismiss_subscription: Option, + /// Held while open, so that a state collected without being closed — this + /// lives in element state — takes its registration with it. + deferred_context: Option, +} + +impl PopoverState { + pub fn new(default_open: bool, cx: &mut App) -> Self { + Self { + focus_handle: cx.focus_handle(), + tracked_focus_handle: None, + previous_focus_handle: None, + open: default_open, + on_open_change: None, + dismiss_subscription: None, + deferred_context: None, + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + self.toggle_open(window, cx); + } + } + + pub fn show(&mut self, window: &mut Window, cx: &mut Context) { + if !self.open { + self.toggle_open(window, cx); + } + } + + #[doc(hidden)] + pub fn set_open(&mut self, open: bool, cx: &mut Context) { + self.open = open; + self.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx)); + } + + #[doc(hidden)] + pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { + let opening = !self.open; + if opening { + self.previous_focus_handle = window.focused(cx); + } + self.set_open(opening, cx); + + if self.open { + let state = cx.entity(); + self.tracked_focus_handle + .clone() + .unwrap_or_else(|| self.focus_handle.clone()) + .focus(window, cx); + + self.dismiss_subscription = + Some( + window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| { + state.update(cx, |state, cx| state.dismiss(window, cx)); + window.refresh(); + }), + ); + } else { + self.dismiss_subscription = None; + if let Some(previous) = self.previous_focus_handle.take() { + if self.focus_handle.contains_focused(window, cx) { + previous.focus(window, cx); + } + } + } + + if let Some(callback) = self.on_open_change.as_ref() { + callback(&self.open, window, cx); + } + cx.notify(); + } + + #[doc(hidden)] + pub fn track_focus(&mut self, focus_handle: Option) { + self.tracked_focus_handle = focus_handle; + } + + #[doc(hidden)] + pub fn set_on_open_change(&mut self, handler: Option) { + self.on_open_change = handler; + } + + #[doc(hidden)] + pub fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.dismiss(window, cx); + } +} + +impl Focusable for PopoverState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PopoverState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +impl EventEmitter for PopoverState {} + +type TriggerBuilder = Box AnyElement>; +type ContentBuilder = + Box) -> AnyElement>; + +/// An unstyled popover owning trigger interaction, open state, dismissal, and focus. +#[derive(IntoElement)] +pub struct Popover { + id: ElementId, + anchor: Anchor, + default_open: bool, + open: Option, + tracked_focus_handle: Option, + trigger: Option, + content: Option, + mouse_button: MouseButton, + overlay_closable: bool, + on_open_change: Option, +} + +impl Popover { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + anchor: Anchor::TopLeft, + default_open: false, + open: None, + tracked_focus_handle: None, + trigger: None, + content: None, + mouse_button: MouseButton::Left, + overlay_closable: true, + on_open_change: None, + } + } + + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self { + self.mouse_button = mouse_button; + self + } + + pub fn trigger(mut self, trigger: T) -> Self + where + T: Selectable + IntoElement + 'static, + { + self.trigger = Some(Box::new(|is_open, _, _| { + let selected = trigger.is_selected(); + trigger.selected(selected || is_open).into_any_element() + })); + self + } + + /// Supplies a trigger builder for higher-level presentation facades. + #[doc(hidden)] + pub fn trigger_with( + mut self, + trigger: impl FnOnce(bool, &Window, &App) -> AnyElement + 'static, + ) -> Self { + self.trigger = Some(Box::new(trigger)); + self + } + + pub fn default_open(mut self, open: bool) -> Self { + self.default_open = open; + self + } + + pub fn open(mut self, open: bool) -> Self { + self.open = Some(open); + self + } + + pub fn track_focus(mut self, handle: &FocusHandle) -> Self { + self.tracked_focus_handle = Some(handle.clone()); + self + } + + pub fn overlay_closable(mut self, closable: bool) -> Self { + self.overlay_closable = closable; + self + } + + pub fn on_open_change( + mut self, + callback: impl Fn(&bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_change = Some(Rc::new(callback)); + self + } + + pub fn content(mut self, content: F) -> Self + where + E: IntoElement, + F: FnOnce(&mut PopoverState, &mut Window, &mut Context) -> E + 'static, + { + self.content = Some(Box::new(move |state, window, cx| { + content(state, window, cx).into_any_element() + })); + self + } +} + +impl RenderOnce for Popover { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| { + PopoverState::new(self.default_open, cx) + }); + state.update(cx, |state, cx| { + state.track_focus(self.tracked_focus_handle); + state.set_on_open_change(self.on_open_change); + if let Some(open) = self.open { + state.set_open(open, cx); + } + }); + + let open = state.read(cx).is_open(); + let focus_handle = state.read(cx).focus_handle(cx); + let Some(trigger) = self.trigger else { + return div().id("empty").into_any_element(); + }; + let parent_view_id = window.current_view(); + let popup = Popup::new(self.id, trigger(open, window, cx)) + .anchor(self.anchor) + .on_mouse_down(self.mouse_button, { + let state = state.clone(); + move |_, window, cx| { + cx.stop_propagation(); + state.update(cx, |state, cx| { + state.set_open(open, cx); + state.toggle_open(window, cx); + }); + cx.notify(parent_view_id); + } + }); + if !open { + return popup.into_any_element(); + } + + let content = div() + .id("content") + // A popover surface is a non-modal dialog: it takes focus and is + // dismissed with Escape, which is what this role tells assistive + // technology to expect. + .role(Role::Dialog) + .occlude() + .tab_group() + .track_focus(&focus_handle) + .key_context(CONTEXT) + .on_action(window.listener_for(&state, PopoverState::on_action_cancel)) + .when_some(self.content, |this, content| { + this.child(state.update(cx, |state, cx| (content)(state, window, cx))) + }) + .when(self.overlay_closable, |this| { + this.on_mouse_down_out({ + let state = state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| state.dismiss(window, cx)); + cx.notify(parent_view_id); + } + }) + }); + popup.content(content).into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, Context, Render, Styled as _, point, px}; + use std::{cell::RefCell, rc::Rc}; + + /// Popover state lives in element state, which is collected as soon as it + /// stops being rendered — a trigger scrolled out of a virtual list, a panel + /// closed with its menu open. A registration that outlived it would leave + /// the application believing a popup is open for the rest of the session. + #[gpui::test] + fn a_state_dropped_while_open_closes_the_deferred_context(cx: &mut gpui::TestAppContext) { + let state = cx.update(|cx| { + GlobalState::init(cx); + let state = cx.new(|cx| PopoverState::new(false, cx)); + state.update(cx, |state, cx| state.set_open(true, cx)); + assert!(GlobalState::is_in_deferred_context(cx)); + state + }); + + cx.update(|_| drop(state)); + cx.update(|cx| assert!(!GlobalState::is_in_deferred_context(cx))); + } + + #[gpui::test] + fn open_state_registers_and_unregisters_deferred_context(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + GlobalState::init(cx); + let state = cx.new(|cx| PopoverState::new(false, cx)); + + state.update(cx, |state, cx| state.set_open(true, cx)); + assert!(state.read(cx).is_open()); + assert!(GlobalState::is_in_deferred_context(cx)); + + state.update(cx, |state, cx| state.set_open(false, cx)); + assert!(!state.read(cx).is_open()); + assert!(!GlobalState::is_in_deferred_context(cx)); + }); + } + + struct PopoverHarness { + changes: Rc>>, + default_open: bool, + } + + impl Render for PopoverHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + Popover::new("base-popover") + .default_open(self.default_open) + .trigger_with(|_, _, _| div().child("Open").into_any_element()) + .content(|_, _, _| { + div() + .debug_selector(|| "base-popover-content".into()) + .size(px(40.)) + }) + .on_open_change(move |open, _, _| changes.borrow_mut().push(*open)) + } + } + + #[gpui::test] + fn unstyled_popover_owns_pointer_open_and_outside_dismiss(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let changes = changes.clone(); + move |_, _| PopoverHarness { + changes, + default_open: false, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_click(point(px(20.), px(10.)), Default::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("base-popover-content").is_some()); + + cx.simulate_click(point(px(300.), px(300.)), Default::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("base-popover-content").is_none()); + assert_eq!(&*changes.borrow(), &[true, false, false]); + } + + #[gpui::test] + fn default_open_renders_content_without_activation(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let (_, cx) = cx.add_window_view(|_, _| PopoverHarness { + changes: Rc::new(RefCell::new(Vec::new())), + default_open: true, + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("base-popover-content").is_some()); + } +} diff --git a/vendor/gpui-base/src/popup.rs b/vendor/gpui-base/src/popup.rs new file mode 100644 index 00000000..7029ed56 --- /dev/null +++ b/vendor/gpui-base/src/popup.rs @@ -0,0 +1,200 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + Anchor, AnyElement, App, Bounds, Div, ElementId, InteractiveElement, Interactivity, + IntoElement, ParentElement, Pixels, Point, RenderOnce, StatefulInteractiveElement, + StyleRefinement, Styled, Window, deferred, div, px, +}; + +use crate::{ElementExt as _, Positioner, StyledExt as _}; + +/// Distance kept between a popup and the window edge. +const WINDOW_MARGIN: Pixels = px(8.); + +/// Deferred paint priority for interactive surfaces that must appear above dialogs. +pub const POPUP_PRIORITY: usize = 100; + +#[derive(Default)] +struct PopupAnchorState { + bounds: Bounds, + captured: bool, +} + +/// An unstyled trigger and anchored popup host. +/// +/// `Popup` owns trigger measurement, anchor-point calculation, first-frame +/// synchronization, deferred rendering, and window-edge snapping. Callers own +/// open state, interaction, popup content, appearance, and motion. +#[derive(IntoElement)] +pub struct Popup { + id: ElementId, + base: gpui::Stateful
, + style: StyleRefinement, + anchor: Anchor, + trigger: AnyElement, + content: Option, +} + +impl Popup { + pub fn new(id: impl Into, trigger: impl IntoElement) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + anchor: Anchor::TopLeft, + trigger: trigger.into_any_element(), + content: None, + } + } + + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + pub fn content(mut self, content: impl IntoElement) -> Self { + self.content = Some(content.into_any_element()); + self + } + + pub fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds) -> Point { + match anchor { + Anchor::TopLeft => trigger_bounds.origin, + Anchor::TopCenter => trigger_bounds.top_center(), + Anchor::TopRight => trigger_bounds.top_right(), + Anchor::BottomLeft => Point { + x: trigger_bounds.origin.x, + y: trigger_bounds.origin.y - trigger_bounds.size.height, + }, + Anchor::BottomCenter => Point { + x: trigger_bounds.top_center().x, + y: trigger_bounds.origin.y - trigger_bounds.size.height, + }, + Anchor::BottomRight => Point { + x: trigger_bounds.top_right().x, + y: trigger_bounds.origin.y - trigger_bounds.size.height, + }, + Anchor::LeftCenter | Anchor::RightCenter => trigger_bounds.origin, + } + } +} + +impl Styled for Popup { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for Popup { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Popup {} + +impl RenderOnce for Popup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = + window.use_keyed_state((self.id, "anchor"), cx, |_, _| PopupAnchorState::default()); + let anchor = self.anchor; + let position = Rc::new(Cell::new(Self::resolved_corner( + anchor, + state.read(cx).bounds, + ))); + + let root = self + .base + .child(self.trigger) + .on_prepaint({ + let state = state.clone(); + let position = position.clone(); + move |bounds, window, cx| { + position.set(Self::resolved_corner(anchor, bounds)); + let first = state.update(cx, |state, _| { + let first = !state.captured; + state.bounds = bounds; + state.captured = true; + first + }); + if first { + window.request_animation_frame(); + } + } + }) + .refine_style(&self.style); + + let Some(content) = self.content else { + return root; + }; + if !state.read(cx).captured { + return root; + } + + root.child( + deferred( + Positioner::corner(anchor, position.get()) + .margin(WINDOW_MARGIN) + .child(content), + ) + .with_priority(POPUP_PRIORITY), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, px}; + + #[test] + fn resolved_corner_preserves_existing_anchor_math() { + let bounds = Bounds { + origin: Point::new(px(100.), px(100.)), + size: gpui::Size::new(px(200.), px(50.)), + }; + assert_eq!( + Popup::resolved_corner(Anchor::TopCenter, bounds), + Point::new(px(200.), px(100.)) + ); + assert_eq!( + Popup::resolved_corner(Anchor::BottomRight, bounds), + Point::new(px(300.), px(50.)) + ); + } + + struct Harness; + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Popup::new( + "popup", + div() + .debug_selector(|| "popup-trigger".into()) + .size(px(100.)), + ) + .content( + div() + .debug_selector(|| "popup-content".into()) + .size(px(20.)), + ) + } + } + + #[gpui::test] + fn trigger_capture_enables_deferred_content_on_the_next_frame(cx: &mut gpui::TestAppContext) { + let (_, window) = cx.add_window_view(|_, _| Harness); + window.update(|window, cx| window.draw(cx).clear(cx)); + window.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + window.debug_bounds("popup-trigger").unwrap().size, + gpui::Size::new(px(100.), px(100.)) + ); + assert_eq!( + window.debug_bounds("popup-content").unwrap().size, + gpui::Size::new(px(20.), px(20.)) + ); + } +} diff --git a/vendor/gpui-base/src/positioner.rs b/vendor/gpui-base/src/positioner.rs new file mode 100644 index 00000000..dba528b3 --- /dev/null +++ b/vendor/gpui-base/src/positioner.rs @@ -0,0 +1,622 @@ +//! Shared popup positioning. +//! +//! Every anchored surface in Base resolves its position here so that flipping, +//! alignment, and viewport clamping cannot drift apart between popups, +//! tooltips, and menus. + +use gpui::{ + Anchor, AnyElement, App, Bounds, Display, Element, GlobalElementId, Half as _, + InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, + Window, point, px, +}; + +use crate::Placement; + +/// Alignment of a popup along the side it is placed on. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Align { + /// Align with the trigger's leading edge. + Start, + /// Center on the trigger. + #[default] + Center, + /// Align with the trigger's trailing edge. + End, +} + +/// How a popup derives its position. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Strategy { + /// Place `anchor`'s corner of the popup at `position`, then clamp into the + /// viewport. This reproduces GPUI's `anchored` corner behavior and does not + /// flip to the opposite side. + Corner { + anchor: Anchor, + position: Point, + }, + /// Place the popup on `placement`'s side of the trigger, flipping to the + /// opposite side when it does not fit, then clamp into the viewport. + Side { + trigger_bounds: Bounds, + placement: Option, + align: Align, + offset: Pixels, + }, +} + +/// The bounds a popup resolved to, and the side it ended up on. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedPosition { + /// Final popup bounds in window coordinates. + pub bounds: Bounds, + /// The side the popup was placed on. `None` for corner positioning, which + /// has no notion of a side. + pub placement: Option, +} + +/// An unstyled element that positions its children as an anchored popup. +/// +/// This owns measurement, side selection, alignment, and viewport clamping. It +/// installs no presentation of its own and adds no wrapper element around its +/// children. +pub struct Positioner { + strategy: Strategy, + margin: Pixels, + children: Vec, +} + +#[doc(hidden)] +pub struct PositionerState { + child_layout_ids: Vec, +} + +impl Positioner { + /// Places the popup on a side of `trigger_bounds`, flipping when needed. + pub fn side(trigger_bounds: Bounds) -> Self { + Self { + strategy: Strategy::Side { + trigger_bounds, + placement: None, + align: Align::Center, + offset: px(0.), + }, + margin: px(4.), + children: Vec::new(), + } + } + + /// Places `anchor`'s corner of the popup at `position`. + /// + /// This is the corner-anchoring path used by triggers that were written + /// against GPUI's `anchored` element. It clamps into the viewport without + /// changing the requested anchor. + pub fn corner(anchor: Anchor, position: Point) -> Self { + Self { + strategy: Strategy::Corner { anchor, position }, + margin: px(4.), + children: Vec::new(), + } + } + + /// Sets the preferred side. Only meaningful for [`Positioner::side`]. + pub fn placement(mut self, placement: Placement) -> Self { + if let Strategy::Side { + placement: slot, .. + } = &mut self.strategy + { + *slot = Some(placement); + } + self + } + + /// Sets the alignment along the chosen side. Only meaningful for + /// [`Positioner::side`]. + pub fn align(mut self, align: Align) -> Self { + if let Strategy::Side { align: slot, .. } = &mut self.strategy { + *slot = align; + } + self + } + + /// Sets the gap between the trigger and the popup. Only meaningful for + /// [`Positioner::side`]. + pub fn offset(mut self, offset: Pixels) -> Self { + if let Strategy::Side { offset: slot, .. } = &mut self.strategy { + *slot = offset; + } + self + } + + /// Sets the minimum distance kept between the popup and the viewport edge. + pub fn margin(mut self, margin: Pixels) -> Self { + self.margin = margin; + self + } +} + +/// Resolves the bounds of a popup of `popup_size`. +/// +/// Side placement picks the preferred side when the popup fits, otherwise the +/// opposite side, otherwise whichever side has more room. The result is always +/// clamped into the viewport with `margin`. +fn resolve( + strategy: Strategy, + popup_size: Size, + viewport_size: Size, + margin: Pixels, +) -> ResolvedPosition { + match strategy { + Strategy::Corner { anchor, position } => ResolvedPosition { + bounds: clamp( + Bounds::from_anchor_and_size(anchor, position, popup_size), + viewport_size, + margin, + ), + placement: None, + }, + Strategy::Side { + trigger_bounds, + placement, + align, + offset, + } => { + let placement = + resolve_placement(trigger_bounds, popup_size, viewport_size, margin, placement); + let origin = side_origin(trigger_bounds, popup_size, placement, align, offset); + ResolvedPosition { + bounds: clamp(Bounds::new(origin, popup_size), viewport_size, margin), + placement: Some(placement), + } + } + } +} + +fn resolve_placement( + trigger_bounds: Bounds, + popup_size: Size, + viewport_size: Size, + margin: Pixels, + preferred: Option, +) -> Placement { + let right_limit = (viewport_size.width - margin).max(margin); + let bottom_limit = (viewport_size.height - margin).max(margin); + let available_left = (trigger_bounds.left() - margin).max(px(0.)); + let available_right = (right_limit - trigger_bounds.right()).max(px(0.)); + let available_above = (trigger_bounds.top() - margin).max(px(0.)); + let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.)); + + match preferred { + Some(Placement::Right) if popup_size.width <= available_right => Placement::Right, + Some(Placement::Right) if popup_size.width <= available_left => Placement::Left, + Some(Placement::Right) if available_right >= available_left => Placement::Right, + Some(Placement::Right) => Placement::Left, + Some(Placement::Left) if popup_size.width <= available_left => Placement::Left, + Some(Placement::Left) if popup_size.width <= available_right => Placement::Right, + Some(Placement::Left) if available_left >= available_right => Placement::Left, + Some(Placement::Left) => Placement::Right, + Some(Placement::Bottom) if popup_size.height <= available_below => Placement::Bottom, + Some(Placement::Bottom) if popup_size.height <= available_above => Placement::Top, + Some(Placement::Bottom) if available_below >= available_above => Placement::Bottom, + Some(Placement::Bottom) => Placement::Top, + Some(Placement::Top) | None if popup_size.height <= available_above => Placement::Top, + Some(Placement::Top) | None if popup_size.height <= available_below => Placement::Bottom, + Some(Placement::Top) | None if available_below >= available_above => Placement::Bottom, + Some(Placement::Top) | None => Placement::Top, + } +} + +fn side_origin( + trigger_bounds: Bounds, + popup_size: Size, + placement: Placement, + align: Align, + offset: Pixels, +) -> Point { + let aligned_x = match align { + Align::Start => trigger_bounds.left(), + Align::Center => trigger_bounds.center().x - popup_size.width.half(), + Align::End => trigger_bounds.right() - popup_size.width, + }; + let aligned_y = match align { + Align::Start => trigger_bounds.top(), + Align::Center => trigger_bounds.center().y - popup_size.height.half(), + Align::End => trigger_bounds.bottom() - popup_size.height, + }; + + match placement { + Placement::Top => point(aligned_x, trigger_bounds.top() - popup_size.height - offset), + Placement::Bottom => point(aligned_x, trigger_bounds.bottom() + offset), + Placement::Left => point(trigger_bounds.left() - popup_size.width - offset, aligned_y), + Placement::Right => point(trigger_bounds.right() + offset, aligned_y), + } +} + +fn clamp( + mut bounds: Bounds, + viewport_size: Size, + margin: Pixels, +) -> Bounds { + let right_limit = (viewport_size.width - margin).max(margin); + let bottom_limit = (viewport_size.height - margin).max(margin); + + if bounds.right() > right_limit { + bounds.origin.x -= bounds.right() - right_limit; + } + if bounds.left() < margin { + bounds.origin.x = margin; + } + if bounds.bottom() > bottom_limit { + bounds.origin.y -= bounds.bottom() - bottom_limit; + } + if bounds.top() < margin { + bounds.origin.y = margin; + } + + bounds +} + +impl ParentElement for Positioner { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Element for Positioner { + type RequestLayoutState = PositionerState; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let child_layout_ids = self + .children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>(); + let layout_id = window.request_layout( + Style { + position: Position::Absolute, + display: Display::Flex, + ..Style::default() + }, + child_layout_ids.iter().copied(), + cx, + ); + + (layout_id, PositionerState { child_layout_ids }) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) { + if request_layout.child_layout_ids.is_empty() { + return; + } + + let mut child_min = point(Pixels::MAX, Pixels::MAX); + let mut child_max = Point::default(); + for child_layout_id in &request_layout.child_layout_ids { + let child_bounds = window.layout_bounds(*child_layout_id); + child_min = child_min.min(&child_bounds.origin); + child_max = child_max.max(&child_bounds.bottom_right()); + } + + let popup_size = (child_max - child_min).into(); + let client_inset = window.client_inset().unwrap_or(px(0.)); + let position = resolve( + self.strategy, + popup_size, + window.viewport_size(), + self.margin + client_inset, + ); + let offset = position.bounds.origin - bounds.origin; + let offset = point(offset.x.round(), offset.y.round()); + + window.with_element_offset(offset, |window| { + for child in &mut self.children { + child.prepaint(window, cx); + } + }); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + for child in &mut self.children { + child.paint(window, cx); + } + } +} + +impl IntoElement for Positioner { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MARGIN: Pixels = px(4.); + + fn trigger(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), Size::new(px(w), px(h))) + } + + fn viewport() -> Size { + Size::new(px(500.), px(400.)) + } + + fn side( + trigger_bounds: Bounds, + placement: Option, + align: Align, + popup: Size, + ) -> ResolvedPosition { + resolve( + Strategy::Side { + trigger_bounds, + placement, + align, + offset: px(0.), + }, + popup, + viewport(), + MARGIN, + ) + } + + #[test] + fn prefers_the_requested_side_when_it_fits() { + let position = side( + trigger(200., 200., 40., 20.), + Some(Placement::Top), + Align::Center, + Size::new(px(80.), px(30.)), + ); + + assert_eq!(position.placement, Some(Placement::Top)); + assert_eq!(position.bounds.bottom(), px(200.)); + } + + #[test] + fn flips_to_the_opposite_side_when_the_preferred_side_does_not_fit() { + let position = side( + trigger(200., 10., 40., 20.), + Some(Placement::Top), + Align::Center, + Size::new(px(80.), px(60.)), + ); + + assert_eq!(position.placement, Some(Placement::Bottom)); + assert_eq!(position.bounds.top(), px(30.)); + } + + #[test] + fn clamps_into_the_viewport_while_keeping_the_flipped_side() { + let position = side( + trigger(480., 200., 40., 20.), + Some(Placement::Bottom), + Align::Center, + Size::new(px(120.), px(30.)), + ); + + assert_eq!(position.placement, Some(Placement::Bottom)); + assert_eq!(position.bounds.right(), viewport().width - MARGIN); + } + + #[test] + fn alignment_selects_the_leading_center_or_trailing_edge() { + let trigger_bounds = trigger(200., 200., 100., 20.); + let popup = Size::new(px(40.), px(30.)); + + let start = side(trigger_bounds, Some(Placement::Bottom), Align::Start, popup); + let center = side( + trigger_bounds, + Some(Placement::Bottom), + Align::Center, + popup, + ); + let end = side(trigger_bounds, Some(Placement::Bottom), Align::End, popup); + + assert_eq!(start.bounds.left(), px(200.)); + assert_eq!(center.bounds.left(), px(230.)); + assert_eq!(end.bounds.left(), px(260.)); + } + + #[test] + fn side_offset_adds_a_gap_between_trigger_and_popup() { + let position = resolve( + Strategy::Side { + trigger_bounds: trigger(200., 200., 40., 20.), + placement: Some(Placement::Bottom), + align: Align::Center, + offset: px(8.), + }, + Size::new(px(40.), px(30.)), + viewport(), + MARGIN, + ); + + assert_eq!(position.bounds.top(), px(228.)); + } + + #[test] + fn corner_positioning_places_the_named_corner_and_never_reports_a_side() { + let position = resolve( + Strategy::Corner { + anchor: Anchor::TopLeft, + position: point(px(100.), px(100.)), + }, + Size::new(px(40.), px(30.)), + viewport(), + MARGIN, + ); + + assert_eq!(position.placement, None); + assert_eq!(position.bounds.origin, point(px(100.), px(100.))); + } + + #[test] + fn corner_positioning_clamps_but_does_not_flip() { + let position = resolve( + Strategy::Corner { + anchor: Anchor::TopLeft, + position: point(px(480.), px(390.)), + }, + Size::new(px(40.), px(30.)), + viewport(), + MARGIN, + ); + + assert_eq!(position.placement, None); + assert_eq!(position.bounds.right(), viewport().width - MARGIN); + assert_eq!(position.bounds.bottom(), viewport().height - MARGIN); + } + + // Migrated from the tooltip module when its private positioning logic was + // merged into this one. They passed unchanged across that move, which is + // what proves the merge preserved tooltip placement behavior. + const WINDOW_MARGIN: Pixels = px(4.); + + fn tooltip_placement( + trigger_bounds: Bounds, + popup: Size, + viewport: Size, + margin: Pixels, + placement: Option, + ) -> (Bounds, Placement) { + let resolved = resolve( + Strategy::Side { + trigger_bounds, + placement, + align: Align::Center, + offset: px(0.), + }, + popup, + viewport, + margin, + ); + (resolved.bounds, resolved.placement.unwrap()) + } + + fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), Size::new(px(width), px(height))) + } + + fn size_px(width: f32, height: f32) -> Size { + Size::new(px(width), px(height)) + } + + #[test] + fn prefers_above_when_space_allows() { + let trigger = bounds(100., 80., 80., 24.); + let position = tooltip_placement( + trigger, + size_px(120., 30.), + size_px(300., 200.), + WINDOW_MARGIN, + None, + ); + assert_eq!(position.1, Placement::Top); + assert_eq!(position.0.origin, point(px(80.), px(50.))); + } + #[test] + fn flips_and_clamps_on_each_axis() { + let top = tooltip_placement( + bounds(24., 4., 120., 32.), + size_px(240., 32.), + size_px(520., 260.), + WINDOW_MARGIN, + None, + ); + assert_eq!(top.1, Placement::Bottom); + + let right = tooltip_placement( + bounds(260., 60., 32., 32.), + size_px(120., 30.), + size_px(300., 200.), + WINDOW_MARGIN, + Some(Placement::Right), + ); + assert_eq!(right.1, Placement::Left); + + let left_edge = tooltip_placement( + bounds(4., 80., 24., 24.), + size_px(120., 30.), + size_px(300., 200.), + WINDOW_MARGIN, + None, + ); + assert_eq!(left_edge.0.left(), WINDOW_MARGIN); + } + #[test] + fn places_tooltip_to_the_right() { + let trigger = bounds(20., 60., 32., 32.); + let position = tooltip_placement( + trigger, + size_px(120., 30.), + size_px(300., 200.), + WINDOW_MARGIN, + Some(Placement::Right), + ); + assert_eq!(position.1, Placement::Right); + assert_eq!(position.0.left(), trigger.right()); + assert_eq!(position.0.center().y, trigger.center().y); + } + #[test] + fn right_placement_clamps_vertical_edges() { + let trigger = bounds(20., 2., 32., 20.); + let position = tooltip_placement( + trigger, + size_px(120., 40.), + size_px(300., 200.), + WINDOW_MARGIN, + Some(Placement::Right), + ); + assert_eq!(position.1, Placement::Right); + assert_eq!(position.0.top(), WINDOW_MARGIN); + assert_eq!(position.0.left(), trigger.right()); + } + #[test] + fn uses_larger_side_when_neither_vertical_side_fits() { + let position = tooltip_placement( + bounds(120., 20., 40., 20.), + size_px(160., 120.), + size_px(300., 100.), + WINDOW_MARGIN, + None, + ); + assert_eq!(position.1, Placement::Bottom); + assert_eq!(position.0.top(), WINDOW_MARGIN); + assert_eq!(position.0.left(), px(60.)); + } +} diff --git a/vendor/gpui-base/src/progress.rs b/vendor/gpui-base/src/progress.rs new file mode 100644 index 00000000..36e0ec26 --- /dev/null +++ b/vendor/gpui-base/src/progress.rs @@ -0,0 +1,157 @@ +use gpui::{ + AnyElement, App, Div, ElementId, InteractiveElement, Interactivity, IntoElement, ParentElement, + RenderOnce, Role, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::StyledExt as _; + +/// An unstyled linear progress root with controlled value accessibility. +#[derive(IntoElement)] +pub struct Progress { + base: gpui::Stateful
, + style: StyleRefinement, + value: f32, + indeterminate: bool, + children: SmallVec<[AnyElement; 2]>, +} + +impl Progress { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + value: 0., + indeterminate: false, + children: SmallVec::new(), + } + } + + /// Sets the controlled percentage value, clamped to `0..=100`. + pub fn value(mut self, value: f32) -> Self { + self.value = value.clamp(0., 100.); + self + } + + pub fn indeterminate(mut self, indeterminate: bool) -> Self { + self.indeterminate = indeterminate; + self + } +} + +impl Styled for Progress { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Progress { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Progress { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Progress {} + +impl RenderOnce for Progress { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::ProgressIndicator) + .aria_min_numeric_value(0.) + .aria_max_numeric_value(100.) + .when(!self.indeterminate, |this| { + this.aria_numeric_value(self.value as f64) + }) + .children(self.children) + .refine_style(&self.style) + } +} + +macro_rules! progress_part { + ($name:ident, $docs:literal) => { + #[doc = $docs] + #[derive(IntoElement)] + pub struct $name { + base: Div, + style: StyleRefinement, + children: SmallVec<[AnyElement; 1]>, + } + + impl $name { + pub fn new() -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } + } + + impl RenderOnce for $name { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base.children(self.children).refine_style(&self.style) + } + } + }; +} + +progress_part!(ProgressTrack, "An unstyled progress track."); +progress_part!(ProgressIndicator, "An unstyled progress indicator."); + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Element as _, accesskit}; + + #[gpui::test] + fn clamps_and_projects_numeric_accessibility(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + let mut node = accesskit::Node::new(Role::ProgressIndicator); + Progress::new("progress") + .value(120.) + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + + assert_eq!(node.numeric_value(), Some(100.)); + assert_eq!(node.min_numeric_value(), Some(0.)); + assert_eq!(node.max_numeric_value(), Some(100.)); + }); + } + + #[gpui::test] + fn indeterminate_progress_omits_numeric_value(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + let mut node = accesskit::Node::new(Role::ProgressIndicator); + Progress::new("loading") + .value(40.) + .indeterminate(true) + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + + assert_eq!(node.numeric_value(), None); + }); + } +} diff --git a/vendor/gpui-base/src/radio.rs b/vendor/gpui-base/src/radio.rs new file mode 100644 index 00000000..6a1dc496 --- /dev/null +++ b/vendor/gpui-base/src/radio.rs @@ -0,0 +1,413 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, ParentElement, Refineable as _, RenderOnce, Role, SharedString, Stateful, + StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{StateStyle, StyledExt as _}; + +type ChangeHandler = Rc; + +/// An unstyled radio control that owns activation, focus, and accessibility behavior. +/// +/// The application owns its indicator, label, layout, colors, and state styling. +/// Selection is controlled through [`Radio::checked`]; activating an unchecked +/// radio requests `true` through [`Radio::on_change`]. +#[derive(IntoElement)] +pub struct Radio { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + semantic_styles: RadioStyles, + checked: bool, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_change: Option, + accessibility_label: Option, + tab_index: isize, + tab_stop: bool, + provided_focus_handle: Option, + position_in_set: Option, + size_of_set: Option, +} + +/// Semantic root styles supported by [`Radio`]. +#[derive(Default)] +pub struct RadioStyles { + checked: StyleRefinement, + disabled: StyleRefinement, +} + +impl RadioStyles { + pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.checked + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl Radio { + pub fn new(id: impl Into) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + semantic_styles: RadioStyles::default(), + checked: false, + disabled: false, + children: SmallVec::new(), + on_change: None, + accessibility_label: None, + tab_index: 0, + tab_stop: true, + provided_focus_handle: None, + position_in_set: None, + size_of_set: None, + } + } + + /// Updates the element identity used when the radio is rendered. + /// + /// A group that assigns positional ids after construction needs this so + /// each radio keeps a distinct element identity. + pub fn id(mut self, id: impl Into) -> Self { + let id = id.into(); + self.base.interactivity().element_id = Some(id.clone()); + self.id = id; + self + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Configures application-owned styles for the radio's semantic states. + pub fn styles(mut self, build: impl FnOnce(RadioStyles) -> RadioStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.checked.then_some(&self.semantic_styles.checked), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } + + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Handles a requested selection change. + /// + /// The callback receives `true`. Activating an already checked radio is a + /// no-op because a radio cannot deselect itself. + pub fn on_change( + mut self, + handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Uses a caller-owned focus handle instead of creating keyed state. + /// + /// A styled radio needs this to draw its own focus ring from the same + /// handle the primitive tracks. + pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { + self.provided_focus_handle = Some(focus_handle.clone()); + self + } + + /// Sets this radio's one-based position and its group's total size, so + /// assistive technology can announce "option 2 of 5". + pub fn set_position(mut self, position: usize, size: usize) -> Self { + self.position_in_set = Some(position); + self.size_of_set = Some(size); + self + } + + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle { + self.provided_focus_handle.clone().unwrap_or_else(|| { + window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + }) + } +} + +impl Styled for Radio { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Radio { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Radio { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Radio {} + +impl RenderOnce for Radio { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle(window, cx); + let disabled = self.disabled; + let checked = self.checked; + let style = self.resolved_style(); + let on_change = self.on_change; + + self.base + .role(Role::RadioButton) + .aria_toggled(if checked { + Toggled::True + } else { + Toggled::False + }) + // A radio is both "toggled" and "selected"; different assistive + // technology reads one or the other, so state both rather than + // making callers choose. + .aria_selected(checked) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .when_some(self.position_in_set, |this, position| { + this.aria_position_in_set(position) + }) + .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size)) + .when(!disabled, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when_some( + (!disabled && !checked).then_some(on_change).flatten(), + |this, on_change| { + this.on_click(move |event, window, cx| { + on_change(!checked, event, window, cx); + }) + }, + ) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::Cell, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + TestAppContext, VisualTestContext, accesskit, canvas, point, px, + }; + + #[test] + fn semantic_state_styles_are_available_to_applications() { + let _ = Radio::new("states").styles(|styles| { + styles + .checked(|style| style.opacity(0.8)) + .disabled(|style| { + style + .opacity(0.5) + .when(true, |style| style.border_1()) + .when_some(Some(0.4), |style, opacity| style.opacity(opacity)) + .when_none(&None::, |style| style.rounded_sm()) + }) + }); + } + + #[test] + fn semantic_root_styles_follow_radio_priority() { + let styled = |radio: Radio| { + radio.styles(|styles| { + styles + .checked(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }) + }; + + assert_eq!(styled(Radio::new("normal")).resolved_style().opacity, None); + assert_eq!( + styled(Radio::new("checked").checked(true)) + .resolved_style() + .opacity, + Some(0.8) + ); + assert_eq!( + styled(Radio::new("checked-disabled").checked(true).disabled(true)) + .resolved_style() + .opacity, + Some(0.5) + ); + assert_eq!( + styled( + Radio::new("state-over-instance") + .checked(true) + .disabled(true) + .opacity(0.9), + ) + .resolved_style() + .opacity, + Some(0.5) + ); + } + + struct RadioHarness { + checked: bool, + disabled: bool, + changes: Rc>, + keyboard_changes: Rc>, + } + + impl Render for RadioHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let keyboard_changes = self.keyboard_changes.clone(); + Radio::new("radio") + .checked(self.checked) + .disabled(self.disabled) + .size(px(100.)) + .on_change(move |checked, event, _, _| { + assert!(checked); + changes.set(changes.get() + 1); + if matches!(event, ClickEvent::Keyboard(_)) { + keyboard_changes.set(keyboard_changes.get() + 1); + } + }) + } + } + + fn harness( + cx: &mut TestAppContext, + checked: bool, + disabled: bool, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + let changes = Rc::new(Cell::new(0)); + let keyboard_changes = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let changes = changes.clone(); + let keyboard_changes = keyboard_changes.clone(); + move |_, _| RadioHarness { + checked, + disabled, + changes, + keyboard_changes, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, changes, keyboard_changes) + } + + #[gpui::test] + fn pointer_and_keyboard_activation_fire_once(cx: &mut TestAppContext) { + let (cx, changes, keyboard_changes) = harness(cx, false, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(changes.get(), 1); + + changes.set(0); + cx.update(|window, cx| window.draw(cx).clear(cx)); + for key in ["enter", "space"] { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + assert_eq!(changes.get(), 2); + assert_eq!(keyboard_changes.get(), 2); + } + + #[gpui::test] + fn checked_and_disabled_radios_are_inert(cx: &mut TestAppContext) { + for (checked, disabled) in [(true, false), (false, true)] { + let (cx, changes, _) = harness(cx, checked, disabled); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.simulate_keystrokes("enter space"); + assert_eq!(changes.get(), 0); + } + } + + #[gpui::test] + fn accessibility_exposes_role_state_and_action(cx: &mut TestAppContext) { + type Captured = Arc>>; + struct Probe(Captured); + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.0.clone(); + canvas( + move |_, window, cx| { + let mut node = accesskit::Node::new(Role::RadioButton); + Radio::new("probe") + .checked(true) + .accessibility_label("Choice") + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + *captured.lock().unwrap() = Some(node); + }, + |_, _, _, _| {}, + ) + } + } + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Probe(captured)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let node = result.lock().unwrap().take().unwrap(); + assert_eq!(node.role(), Role::RadioButton); + assert_eq!(node.label(), Some("Choice")); + assert_eq!(node.toggled(), Some(Toggled::True)); + assert!(!node.supports_action(accesskit::Action::Click)); + } +} diff --git a/vendor/gpui-base/src/radio_group.rs b/vendor/gpui-base/src/radio_group.rs new file mode 100644 index 00000000..7dffa03a --- /dev/null +++ b/vendor/gpui-base/src/radio_group.rs @@ -0,0 +1,67 @@ +use gpui::{ + AnyElement, App, Axis, Div, ElementId, InteractiveElement, Interactivity, IntoElement, + ParentElement, RenderOnce, Role, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, + Window, accesskit, div, +}; +use smallvec::SmallVec; + +use crate::StyledExt as _; + +/// An unstyled container for a set of radio elements. +#[derive(IntoElement)] +pub struct RadioGroup { + base: Stateful
, + style: StyleRefinement, + axis: Axis, + children: SmallVec<[AnyElement; 4]>, +} + +impl RadioGroup { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id.into()), + style: StyleRefinement::default(), + axis: Axis::Vertical, + children: SmallVec::new(), + } + } + + /// Sets the semantic axis of the group. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } +} + +impl Styled for RadioGroup { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for RadioGroup { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for RadioGroup { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for RadioGroup {} + +impl RenderOnce for RadioGroup { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::RadioGroup) + .aria_orientation(match self.axis { + Axis::Horizontal => accesskit::Orientation::Horizontal, + Axis::Vertical => accesskit::Orientation::Vertical, + }) + .children(self.children) + .refine_style(&self.style) + } +} diff --git a/vendor/gpui-base/src/resizable/mod.rs b/vendor/gpui-base/src/resizable/mod.rs new file mode 100644 index 00000000..ddc52eac --- /dev/null +++ b/vendor/gpui-base/src/resizable/mod.rs @@ -0,0 +1,530 @@ +use std::ops::Range; + +use gpui::{ + Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px, +}; + +mod panel; +mod resize_handle; +pub use panel::*; +#[doc(hidden)] +pub use resize_handle::*; + +#[doc(hidden)] +pub const PANEL_MIN_SIZE: Pixels = px(100.); + +/// Create a [`ResizablePanelGroup`] with horizontal resizing +pub fn h_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Horizontal) +} + +/// Create a [`ResizablePanelGroup`] with vertical resizing +pub fn v_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Vertical) +} + +/// Create a [`ResizablePanel`]. +pub fn resizable_panel() -> ResizablePanel { + ResizablePanel::new() +} + +/// State for a [`ResizablePanel`] +#[derive(Debug, Clone)] +pub struct ResizableState { + /// The `axis` will sync to actual axis of the ResizablePanelGroup in use. + axis: Axis, + panels: Vec, + sizes: Vec, + resizing_panel_ix: Option, + bounds: Bounds, +} + +impl Default for ResizableState { + fn default() -> Self { + Self { + axis: Axis::Horizontal, + panels: vec![], + sizes: vec![], + resizing_panel_ix: None, + bounds: Bounds::default(), + } + } +} + +impl ResizableState { + /// Get the size of the panels. + pub fn sizes(&self) -> &Vec { + &self.sizes + } + + /// Programmatically resize the panel at `ix` to `size`, redistributing + /// space among siblings using the same logic as a drag. + /// + /// Sizes are clamped to the panel's `size_range` and to the container. + /// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference + /// persistence) see the change just as if the user had dragged a handle. + /// + /// Out-of-range indices are a no-op. For the last panel, space is taken + /// from the previous sibling (the last panel has no handle of its own). + pub fn resize_panel( + &mut self, + ix: usize, + size: Pixels, + window: &mut Window, + cx: &mut Context, + ) { + if ix >= self.sizes.len() { + return; + } + if ix + 1 < self.sizes.len() { + self.resize_panel_at_handle(ix, size, window, cx); + } else if ix > 0 { + // Last panel: drive its size by resizing the previous sibling so + // the freed space lands here. + let delta = self.sizes[ix] - size; + let prev = self.sizes[ix - 1]; + self.resize_panel_at_handle(ix - 1, prev + delta, window, cx); + } + self.done_resizing(cx); + } + + /// Insert a panel state at `ix`, or append it when no index is supplied. + /// + /// Existing panel sizes are redistributed so their total remains equal to + /// the current container size. + pub fn insert_panel( + &mut self, + size: Option, + ix: Option, + cx: &mut Context, + ) { + let panel_state = ResizablePanelState { + size, + ..Default::default() + }; + + let size = size.unwrap_or(PANEL_MIN_SIZE); + + // We make sure that the size always sums up to the container size + // by reducing the size of all other panels first. + let container_size = self.container_size().max(px(1.)); + let total_leftover_size = (container_size - size).max(px(1.)); + + for (i, panel) in self.panels.iter_mut().enumerate() { + let ratio = self.sizes[i] / container_size; + self.sizes[i] = total_leftover_size * ratio; + panel.size = Some(self.sizes[i]); + } + + if let Some(ix) = ix { + self.panels.insert(ix, panel_state); + self.sizes.insert(ix, size); + } else { + self.panels.push(panel_state); + self.sizes.push(size); + }; + + cx.notify(); + } + + /// Adopt slot sizes decided by an owner that keeps its own record of the + /// layout — the dock's pane tree does. + /// + /// Unlike [`Self::insert_panel`], nothing is redistributed: the caller has + /// already decided how the space divides, and re-normalizing here would + /// undo exactly that decision. Slots the caller left unconstrained keep + /// whatever they had. + pub(crate) fn adopt_sizes(&mut self, sizes: &[Option], cx: &mut Context) { + let mut changed = false; + for (ix, size) in sizes.iter().enumerate() { + // The preference is mirrored exactly, `None` included. That is the + // load-bearing half: `insert_panel` resolves every existing + // panel's `None` into a concrete value as a side effect of + // redistributing, so after inserting one slot the caller's "these + // two are equally unconstrained" has quietly become "that one is + // pinned, this one is the only flexible slot" — and the flexible + // one then swallows whatever the pinned ones leave over. + if let Some(panel) = self.panels.get_mut(ix) { + if panel.size != *size { + panel.size = *size; + changed = true; + } + } + + // The measurement only moves when the tree names a size; an + // unconstrained slot keeps whatever it was last laid out at until + // the next pass recomputes it. + let Some(size) = size else { continue }; + if let Some(slot) = self.sizes.get_mut(ix) { + if *slot != *size { + *slot = *size; + changed = true; + } + } + } + + if changed { + cx.notify(); + } + } + + pub(crate) fn sync_panels_count( + &mut self, + axis: Axis, + panels_count: usize, + cx: &mut Context, + ) { + let mut changed = self.axis != axis; + self.axis = axis; + + if panels_count > self.panels.len() { + let diff = panels_count - self.panels.len(); + self.panels + .extend(vec![ResizablePanelState::default(); diff]); + self.sizes.extend(vec![PANEL_MIN_SIZE; diff]); + changed = true; + } + + if panels_count < self.panels.len() { + self.panels.truncate(panels_count); + self.sizes.truncate(panels_count); + changed = true; + } + + if changed { + // We need to make sure the total size is in line with the container size. + self.adjust_to_container_size(cx); + } + } + + pub(crate) fn update_panel_size( + &mut self, + panel_ix: usize, + bounds: Bounds, + size_range: Range, + cx: &mut Context, + ) { + let size = bounds.size.along(self.axis); + // This check is only necessary to stop the very first panel from resizing on its own + // it needs to be passed when the panel is freshly created so we get the initial size, + // but its also fine when it sometimes passes later. + if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() { + self.sizes[panel_ix] = size; + self.panels[panel_ix].size = Some(size); + } + self.panels[panel_ix].bounds = bounds; + self.panels[panel_ix].size_range = size_range; + cx.notify(); + } + + /// Remove the panel at `panel_ix` and redistribute the remaining space. + pub fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context) { + self.panels.remove(panel_ix); + self.sizes.remove(panel_ix); + if let Some(resizing_panel_ix) = self.resizing_panel_ix { + if resizing_panel_ix > panel_ix { + self.resizing_panel_ix = Some(resizing_panel_ix - 1); + } + } + self.adjust_to_container_size(cx); + } + + /// Reset the panel at `panel_ix` while preserving its current size. + pub fn reset_panel(&mut self, panel_ix: usize, cx: &mut Context) { + let old_size = self.sizes[panel_ix]; + + self.panels[panel_ix] = ResizablePanelState::default(); + self.sizes[panel_ix] = old_size; + self.adjust_to_container_size(cx); + } + + /// Remove all panel state. + pub fn clear(&mut self) { + self.panels.clear(); + self.sizes.clear(); + } + + #[inline] + /// Return the measured size of the group along its resize axis. + pub fn container_size(&self) -> Pixels { + self.bounds.size.along(self.axis) + } + + pub(crate) fn done_resizing(&mut self, cx: &mut Context) { + self.resizing_panel_ix = None; + cx.emit(ResizablePanelEvent::Resized); + } + + fn panel_size_range(&self, ix: usize) -> Range { + let Some(panel) = self.panels.get(ix) else { + return PANEL_MIN_SIZE..Pixels::MAX; + }; + + panel.size_range.clone() + } + + fn sync_real_panel_sizes(&mut self, _: &App) { + for (i, panel) in self.panels.iter().enumerate() { + self.sizes[i] = panel.bounds.size.along(self.axis); + } + } + + /// Resize the panel at `ix` by treating `ix` as the drag-handle position + /// (the handle that sits between panel `ix` and panel `ix + 1`). Returns + /// early on the last panel since there is no handle below it. + /// + /// This is the worker behind drag interactions and the public + /// [`Self::resize_panel`] API. + fn resize_panel_at_handle( + &mut self, + ix: usize, + size: Pixels, + _: &mut Window, + cx: &mut Context, + ) { + let old_sizes = self.sizes.clone(); + + let mut ix = ix; + // Only resize the left panels. + if ix >= old_sizes.len() - 1 { + return; + } + let container_size = self.container_size(); + self.sync_real_panel_sizes(cx); + + let move_changed = size - old_sizes[ix]; + if move_changed == px(0.) { + return; + } + + let size_range = self.panel_size_range(ix); + let new_size = size.clamp(size_range.start, size_range.end); + let is_expand = move_changed > px(0.); + + let main_ix = ix; + let mut new_sizes = old_sizes.clone(); + + if is_expand { + let mut changed = new_size - old_sizes[ix]; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix < old_sizes.len() - 1 { + ix += 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + new_sizes[ix] -= to_reduce; + changed -= to_reduce; + } + } else { + let mut changed = new_size - size; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix > 0 { + ix -= 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + changed -= to_reduce; + new_sizes[ix] -= to_reduce; + } + + new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed; + } + + // If total size exceeds container size, adjust the main panel + let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::().into(); + if total_size > container_size { + let overflow = total_size - container_size; + new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start); + } + + for (i, _) in old_sizes.iter().enumerate() { + let size = new_sizes[i]; + self.panels[i].size = Some(size); + } + self.sizes = new_sizes; + cx.notify(); + } + + /// Adjust panel sizes according to the container size. + /// + /// When the container size changes, the panels should take up the same percentage as they did before. + fn adjust_to_container_size(&mut self, cx: &mut Context) { + if self.container_size().is_zero() { + return; + } + + // A panel with no size preference is laid out by flex, and its entry + // in `sizes` is a placeholder until something measures it. Rescaling + // by a ratio computed from that placeholder drags the panels that + // *do* have a preference along with it: a 200px sidebar beside one + // flexible panel comes back 587px wide on the frame after the first, + // which reads as the layout jumping once for no reason. Flex already + // fits the container, so there is nothing here to adjust. + if self.panels.iter().any(|panel| panel.size.is_none()) { + return; + } + + let container_size = self.container_size(); + let total = self.sizes.iter().map(|s| s.as_f32()).sum::(); + if !total.is_finite() || total <= 0. { + return; + } + let total_size = px(total); + + for i in 0..self.panels.len() { + let size = self.sizes[i]; + let ratio = size / total_size; + let new_size = container_size * ratio; + + self.sizes[i] = new_size; + self.panels[i].size = Some(new_size); + } + cx.notify(); + } +} + +impl EventEmitter for ResizableState {} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResizablePanelState { + pub size: Option, + pub size_range: Range, + bounds: Bounds, +} + +#[cfg(test)] +mod tests { + use std::{cell::Cell, rc::Rc}; + + use gpui::{ + AppContext as _, Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, + ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, + point, px, size, + }; + + use super::{ResizableState, h_resizable, resizable_panel}; + + struct ResizableHarness { + state: gpui::Entity, + resizes: Rc>, + } + + impl Render for ResizableHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(px(400.)).h(px(100.)).child( + h_resizable("resizable") + .with_state(&self.state) + .on_resize({ + let resizes = self.resizes.clone(); + move |_, _, _| resizes.set(resizes.get() + 1) + }) + .child( + resizable_panel() + .size(px(150.)) + .child(div().size_full().debug_selector(|| "first-panel".into())), + ) + .child( + resizable_panel() + .size(px(250.)) + .child(div().size_full().debug_selector(|| "second-panel".into())), + ), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + ) -> ( + &mut VisualTestContext, + gpui::Entity, + Rc>, + ) { + let state = cx.update(|cx| cx.new(|_| ResizableState::default())); + let resizes = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + let resizes = resizes.clone(); + move |_, _| ResizableHarness { state, resizes } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, state, resizes) + } + + #[gpui::test] + fn dynamic_panel_lifecycle_is_owned_by_resizable_state(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| ResizableState::default())); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.bounds.size = size(px(400.), px(100.)); + state.panels.push(Default::default()); + state.sizes.push(px(400.)); + state.insert_panel(Some(px(200.)), None, cx); + assert_eq!(state.sizes(), &vec![px(200.), px(200.)]); + + state.reset_panel(0, cx); + assert_eq!(state.sizes(), &vec![px(200.), px(200.)]); + + state.remove_panel(0, cx); + assert_eq!(state.sizes(), &vec![px(400.)]); + + state.clear(); + assert!(state.sizes().is_empty()); + }); + }); + } + + #[gpui::test] + fn group_measures_panels_and_programmatic_resize_uses_drag_rules(cx: &mut TestAppContext) { + let (cx, state, _) = harness(cx); + let first = cx.debug_bounds("first-panel").unwrap(); + let second = cx.debug_bounds("second-panel").unwrap(); + assert_eq!(first.size.width + second.size.width, px(400.)); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.resize_panel(0, px(220.), window, cx); + }); + window.draw(cx).clear(cx); + }); + + state.read_with(cx, |state, _| { + assert_eq!(state.sizes(), &vec![px(220.), px(180.)]); + }); + } + + #[gpui::test] + fn dragging_the_handle_resizes_and_emits_once(cx: &mut TestAppContext) { + let (cx, state, resizes) = harness(cx); + let boundary = cx.debug_bounds("second-panel").unwrap().left(); + + cx.simulate_mouse_down( + point(boundary - px(2.), px(50.)), + MouseButton::Left, + Modifiers::default(), + ); + cx.simulate_mouse_move( + point(boundary + px(10.), px(50.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + cx.simulate_mouse_move( + point(px(220.), px(50.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(220.), px(50.)), + MouseButton::Left, + Modifiers::default(), + ); + + state.read_with(cx, |state, _| { + assert_eq!(state.sizes(), &vec![px(220.), px(180.)]); + }); + assert_eq!(resizes.get(), 1); + } +} diff --git a/vendor/gpui-component/src/resizable/panel.rs b/vendor/gpui-base/src/resizable/panel.rs similarity index 66% rename from vendor/gpui-component/src/resizable/panel.rs rename to vendor/gpui-base/src/resizable/panel.rs index ee7ed1dc..a20b252c 100644 --- a/vendor/gpui-component/src/resizable/panel.rs +++ b/vendor/gpui-base/src/resizable/panel.rs @@ -4,15 +4,15 @@ use std::{ }; use gpui::{ - canvas, div, prelude::FluentBuilder, Along, AnyElement, App, AppContext, Axis, Bounds, Context, - Element, ElementId, Empty, Entity, EventEmitter, InteractiveElement as _, IntoElement, - IsZero as _, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, - Styled, Window, + Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity, + EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent, + ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder, }; -use crate::{h_flex, resizable::PANEL_MIN_SIZE, v_flex, AxisExt}; +use crate::{AxisExt, ElementExt, StyledExt as _, h_flex, resizable::PANEL_MIN_SIZE, v_flex}; -use super::{resizable_panel, resize_handle, ResizableState}; +use super::{ResizableState, ResizeHandleRenderer, resizable_panel, resize_handle}; pub enum ResizablePanelEvent { Resized, @@ -35,6 +35,7 @@ pub struct ResizablePanelGroup { size: Option, children: Vec, on_resize: Rc, &mut Window, &mut App)>, + handle_appearance: Option, } impl ResizablePanelGroup { @@ -47,9 +48,19 @@ impl ResizablePanelGroup { state: None, size: None, on_resize: Rc::new(|_, _, _| {}), + handle_appearance: None, } } + /// Hand the painted part of every divider in this group to `appearance`. + /// + /// The hit area, the cursor and the drag stay here; a renderer that + /// returns `None` for a given handle leaves the built-in line on it. + pub fn with_handle_appearance(mut self, appearance: ResizeHandleRenderer) -> Self { + self.handle_appearance = Some(appearance); + self + } + /// Bind yourself to a resizable state entity. /// /// If not provided, it will handle its own state internally. @@ -134,16 +145,10 @@ impl RenderOnce for ResizablePanelGroup { v_flex() }; - // Sync panels to the state before rendering them. This includes each - // panel's initial size and growth policy so state and flex layout do - // not disagree during the first measured frame. - let panel_configs = self - .children - .iter() - .map(|panel| (panel.initial_size, panel.grow)) - .collect(); + // Sync panels to the state + let panels_count = self.children.len(); state.update(cx, |state, cx| { - state.sync_panels(self.axis, panel_configs, cx); + state.sync_panels_count(self.axis, panels_count, cx); }); container @@ -157,30 +162,24 @@ impl RenderOnce for ResizablePanelGroup { panel.panel_ix = ix; panel.axis = self.axis; panel.state = Some(state.clone()); + panel.handle_appearance = self.handle_appearance.clone(); panel }), ) - .child({ - canvas( - { - let state = state.clone(); - move |bounds, _, cx| { - state.update(cx, |state, cx| { - let size_changed = state.bounds.size.along(self.axis) - != bounds.size.along(self.axis); - - state.bounds = bounds; - - if size_changed { - state.adjust_to_container_size(cx); - } - }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + let size_changed = + state.bounds.size.along(self.axis) != bounds.size.along(self.axis); + + state.bounds = bounds; + + if size_changed { + state.adjust_to_container_size(cx); } - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full() + }) + } }) .child(ResizePanelGroupElement { state: state.clone(), @@ -191,6 +190,32 @@ impl RenderOnce for ResizablePanelGroup { } /// A resizable panel inside a [`ResizablePanelGroup`]. +/// +/// Implements [`Styled`], so call sites can override the panel's +/// rendered styles. User overrides are applied **between** the panel's +/// flex defaults and its size management — the caller can override the +/// internal `flex_grow: 1` (e.g. via `.flex_none()`) and add their own +/// padding / colors / borders, while the panel's runtime size +/// constraints (`min_w`/`max_w`/`flex_basis` driven by `ResizableState`) +/// always win. +/// +/// A common override is `.flex_none()`: the panel sets `flex_grow: 1` +/// internally, so a sized panel that should hold its width when a +/// sibling collapses needs to opt out of growth via `.flex_none()`. +/// +/// ```ignore +/// h_resizable("layout") +/// .child(resizable_panel().size(px(220.)).flex_none().child(sidebar)) +/// .child(resizable_panel().child(content)) // flex +/// .child(resizable_panel().size(px(280.)).flex_none().child(metadata)) +/// ``` +/// +/// **Reserved styles**: do not call these from outside — they fight the +/// panel's own layout management: +/// - `.flex_basis(...)` — driven by `ResizableState`, not by the caller. +/// - `.absolute()` — would remove the panel from the resizable's flex flow. +/// - `.overflow_hidden()` — may clip the resize handle, which is positioned +/// absolute at `left: -4px` of each panel after the first. #[derive(IntoElement)] pub struct ResizablePanel { axis: Axis, @@ -200,9 +225,10 @@ pub struct ResizablePanel { initial_size: Option, /// size range limit of this panel. size_range: Range, - grow: bool, children: Vec, visible: bool, + style: StyleRefinement, + handle_appearance: Option, } impl ResizablePanel { @@ -213,10 +239,11 @@ impl ResizablePanel { initial_size: None, state: None, size_range: (PANEL_MIN_SIZE..Pixels::MAX), - grow: true, axis: Axis::Horizontal, children: vec![], visible: true, + style: StyleRefinement::default(), + handle_appearance: None, } } @@ -239,15 +266,11 @@ impl ResizablePanel { self.size_range = range.into(); self } +} - /// Set whether this panel should absorb free space in the group. - /// - /// The default is `true`, preserving the historical behavior where all - /// panels flex-grow. Set this to `false` for sidebars/toolkits that should - /// keep their configured size until the user explicitly drags a handle. - pub fn grow(mut self, grow: bool) -> Self { - self.grow = grow; - self +impl Styled for ResizablePanel { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style } } @@ -276,10 +299,17 @@ impl RenderOnce for ResizablePanel { div() .id(("resizable-panel", self.panel_ix)) .flex() - .when(self.grow, |this| this.flex_grow()) - .when(!self.grow, |this| this.flex_none()) + .flex_grow_1() .size_full() .relative() + // Apply caller style overrides here — between the flex defaults + // above and the size management below. This lets callers cancel + // the unconditional `.flex_grow_1()` (via `.flex_none()`, the load- + // bearing case for sized panels next to a collapsing sibling) and + // add their own padding / colors / borders, while keeping the + // panel's runtime size constraints (min/max + `flex_basis` driven + // by `ResizableState`) authoritative. + .refine_style(&self.style) .when(self.axis.is_vertical(), |this| { this.min_h(size_range.start).max_h(size_range.end) }) @@ -289,7 +319,7 @@ impl RenderOnce for ResizablePanel { // 1. initial_size is None, to use auto size. // 2. initial_size is Some and size is none, to use the initial size of the panel for first time render. // 3. initial_size is Some and size is Some, use `size`. - .when(self.initial_size.is_none(), |this| this.flex_shrink()) + .when(self.initial_size.is_none(), |this| this.flex_shrink_1()) .when_some(self.initial_size, |this, initial_size| { // The `self.size` is None, that mean the initial size for the panel, // so we need set `flex_shrink_0` To let it keep the initial size. @@ -303,35 +333,31 @@ impl RenderOnce for ResizablePanel { Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)), None => this, }) - .child({ - canvas( - { - let state = state.clone(); - move |bounds, _, cx| { - state.update(cx, |state, cx| { - state.update_panel_size(self.panel_ix, bounds, self.size_range, cx) - }) - } - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full() + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + state.update_panel_size(self.panel_ix, bounds, self.size_range, cx) + }) + } }) .children(self.children) .when(self.panel_ix > 0, |this| { let ix = self.panel_ix - 1; - this.child(resize_handle(("resizable-handle", ix), self.axis).on_drag( - DragPanel, - move |drag_panel, _, _, cx| { - cx.stop_propagation(); - // Set current resizing panel ix - state.update(cx, |state, _| { - state.resizing_panel_ix = Some(ix); - }); - cx.new(|_| drag_panel.deref().clone()) - }, - )) + this.child( + resize_handle(("resizable-handle", ix), self.axis) + .when_some(self.handle_appearance.clone(), |handle, appearance| { + handle.with_appearance(appearance) + }) + .on_drag(DragPanel, move |drag_panel, _, _, cx| { + cx.stop_propagation(); + // Set current resizing panel ix + state.update(cx, |state, _| { + state.resizing_panel_ix = Some(ix); + }); + cx.new(|_| drag_panel.deref().clone()) + }), + ) }) } } @@ -408,12 +434,18 @@ impl Element for ResizePanelGroupElement { let panel = state.panels.get(ix).expect("BUG: invalid panel index"); match axis { - Axis::Horizontal => { - state.resize_panel(ix, e.position.x - panel.bounds.left(), window, cx) - } - Axis::Vertical => { - state.resize_panel(ix, e.position.y - panel.bounds.top(), window, cx); - } + Axis::Horizontal => state.resize_panel_at_handle( + ix, + e.position.x - panel.bounds.left(), + window, + cx, + ), + Axis::Vertical => state.resize_panel_at_handle( + ix, + e.position.y - panel.bounds.top(), + window, + cx, + ), } cx.notify(); }) diff --git a/vendor/gpui-component/src/resizable/resize_handle.rs b/vendor/gpui-base/src/resizable/resize_handle.rs similarity index 66% rename from vendor/gpui-component/src/resizable/resize_handle.rs rename to vendor/gpui-base/src/resizable/resize_handle.rs index cfac3108..2988cdc5 100644 --- a/vendor/gpui-component/src/resizable/resize_handle.rs +++ b/vendor/gpui-base/src/resizable/resize_handle.rs @@ -1,30 +1,62 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, Element, ElementId, Entity, - GlobalElementId, InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, - ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window, + AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement, + IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, + StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px, }; -use crate::{dock::DockPlacement, ActiveTheme as _, AxisExt as _}; +use crate::{AxisExt as _, Side, theme::ActiveTheme as _}; pub(crate) const HANDLE_PADDING: Pixels = px(4.); pub(crate) const HANDLE_SIZE: Pixels = px(1.); /// Create a resize handle for a resizable panel. -pub(crate) fn resize_handle( +#[doc(hidden)] +pub fn resize_handle( id: impl Into, axis: Axis, ) -> ResizeHandle { ResizeHandle::new(id, axis) } -pub(crate) struct ResizeHandle { +/// Draws the visible part of a resize handle. +/// +/// Returning `None` keeps the built-in line, so a renderer can override some +/// handles and leave the rest alone. +pub type ResizeHandleRenderer = + Rc Option>; + +/// What a [`ResizeHandleRenderer`] is told about the handle it is drawing. +/// +/// The hit area, the cursor and the drag itself stay with the handle; a +/// renderer only supplies what is painted inside it. +pub struct ResizeHandleContext { + axis: Axis, + active: bool, +} + +impl ResizeHandleContext { + /// The axis the handle resizes along: `Horizontal` for a vertical divider + /// between two side-by-side panels. + pub fn axis(&self) -> Axis { + self.axis + } + + /// Whether this handle is the one being dragged right now. + pub fn is_active(&self) -> bool { + self.active + } +} + +#[doc(hidden)] +pub struct ResizeHandle { id: ElementId, axis: Axis, drag_value: Option>, - placement: Option, + placement: Option, on_drag: Option, &mut Window, &mut App) -> Entity>>, + appearance: Option, } impl ResizeHandle { @@ -35,11 +67,18 @@ impl ResizeHandle { on_drag: None, drag_value: None, placement: None, + appearance: None, axis, } } - pub(crate) fn on_drag( + /// Hand the painted part of this handle to `appearance`. + pub fn with_appearance(mut self, appearance: ResizeHandleRenderer) -> Self { + self.appearance = Some(appearance); + self + } + + pub fn on_drag( mut self, value: T, f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, @@ -52,7 +91,7 @@ impl ResizeHandle { self } - pub(crate) fn placement(mut self, placement: DockPlacement) -> Self { + pub fn placement(mut self, placement: Side) -> Self { self.placement = Some(placement); self } @@ -106,9 +145,9 @@ impl Element for ResizeHandle { let state = state.unwrap_or(ResizeHandleState::default()); let bg_color = if state.is_active() { - cx.theme().drag_border + cx.theme().resizable.active_handle } else { - cx.theme().border + cx.theme().resizable.handle }; let mut el = div() @@ -124,7 +163,7 @@ impl Element for ResizeHandle { ) }) .map(|this| match self.placement { - Some(DockPlacement::Left) => { + Some(Side::Left) => { // Special for Left Dock // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING) this.cursor_col_resize() @@ -153,11 +192,29 @@ impl Element for ResizeHandle { }), }) .child( - div() - .bg(bg_color) - .group_hover("handle", |this| this.bg(bg_color)) - .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) - .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + // A renderer that declines — or is absent — leaves the + // built-in line, so overriding one handle never obliges a + // caller to redraw them all. + self.appearance + .as_ref() + .and_then(|appearance| { + appearance( + &ResizeHandleContext { + axis, + active: state.is_active(), + }, + window, + cx, + ) + }) + .unwrap_or_else(|| { + div() + .bg(bg_color) + .group_hover("handle", |this| this.bg(bg_color)) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)) + .into_any_element() + }), ) .into_any_element(); diff --git a/vendor/gpui-base/src/scrollbar.rs b/vendor/gpui-base/src/scrollbar.rs new file mode 100644 index 00000000..18432ae1 --- /dev/null +++ b/vendor/gpui-base/src/scrollbar.rs @@ -0,0 +1,2347 @@ +use std::{cell::Cell, ops::Deref, panic::Location, rc::Rc}; + +use instant::{Duration, Instant}; + +use crate::{ + AxisExt, + animation::{ease_in_cubic, ease_out_cubic}, + theme::ActiveTheme as _, +}; +use gpui::{ + Anchor, App, Axis, Background, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, + ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, + IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, + Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, + fill, point, prelude::FluentBuilder, px, relative, size, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) +const WIDTH: Pixels = px(4. * 2. + 8.); +const MIN_THUMB_SIZE: Pixels = px(48.); + +const THUMB_WIDTH: Pixels = px(6.); +const THUMB_RADIUS: Pixels = Pixels::ZERO; +const THUMB_INSET: Pixels = px(4.); + +const THUMB_ACTIVE_WIDTH: Pixels = px(8.); +const THUMB_ACTIVE_RADIUS: Pixels = Pixels::ZERO; +const THUMB_ACTIVE_INSET: Pixels = px(4.); + +/// How long visibility is held after the last activity, when the styled layer +/// projects no [`ScrollbarMotion`] of its own. +/// +/// This is a visibility hold rather than motion: without it [`ScrollbarMode::Scrolling`] +/// could never reveal the scrollbar. +const DEFAULT_IDLE: Duration = Duration::from_secs(2); + +fn clamp_thumb_radius(radius: Pixels, bounds: Bounds) -> Pixels { + radius + .min(bounds.size.width / 2.) + .min(bounds.size.height / 2.) +} + +/// Scrollbar show mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default, JsonSchema)] +pub enum ScrollbarMode { + /// Show scrollbar when scrolling, will fade out after idle. + #[default] + Scrolling, + /// Show scrollbar on hover. + Hover, + /// Always show scrollbar. + Always, +} + +impl ScrollbarMode { + fn is_hover(&self) -> bool { + matches!(self, Self::Hover) + } + + fn is_always(&self) -> bool { + matches!(self, Self::Always) + } +} + +/// A trait for scroll handles that can get and set offset. +pub trait ScrollbarHandle: 'static { + /// Bounds of the viewport the scrollbar overlays. + fn viewport_bounds(&self) -> Bounds; + /// Get the current offset of the scroll handle. + fn offset(&self) -> Point; + /// Set the offset of the scroll handle. + fn set_offset(&self, offset: Point); + /// The full size of the content, including padding. + fn content_size(&self) -> Size; + /// Called when start dragging the scrollbar thumb. + fn start_drag(&self) {} + /// Called when end dragging the scrollbar thumb. + fn end_drag(&self) {} +} + +impl ScrollbarHandle for ScrollHandle { + fn viewport_bounds(&self) -> Bounds { + self.bounds() + } + + fn offset(&self) -> Point { + self.offset() + } + + fn set_offset(&self, offset: Point) { + self.set_offset(offset); + } + + fn content_size(&self) -> Size { + (self.max_offset() + self.bounds().size.into()).into() + } +} + +impl ScrollbarHandle for UniformListScrollHandle { + fn viewport_bounds(&self) -> Bounds { + self.0.borrow().base_handle.bounds() + } + + fn offset(&self) -> Point { + self.0.borrow().base_handle.offset() + } + + fn set_offset(&self, offset: Point) { + self.0.borrow_mut().base_handle.set_offset(offset) + } + + fn content_size(&self) -> Size { + let base_handle = &self.0.borrow().base_handle; + (base_handle.max_offset() + base_handle.bounds().size.into()).into() + } +} + +impl ScrollbarHandle for ListState { + fn viewport_bounds(&self) -> Bounds { + ListState::viewport_bounds(self) + } + + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + + fn set_offset(&self, offset: Point) { + self.set_offset_from_scrollbar(offset); + } + + fn content_size(&self) -> Size { + self.viewport_bounds().size + self.max_offset_for_scrollbar().into() + } + + fn start_drag(&self) { + self.scrollbar_drag_started(); + } + + fn end_drag(&self) { + self.scrollbar_drag_ended(); + } +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +struct ScrollbarState(Rc>); + +#[doc(hidden)] +#[derive(Debug, Clone, Copy)] +struct ScrollbarStateInner { + hovered_axis: Option, + hovered_on_thumb: Option, + dragged_axis: Option, + drag_pos: Point, + last_scroll_offset: Point, + last_scroll_time: Option, + // Last update offset + last_update: Instant, + idle_timer_scheduled: bool, + visibility: VisibilityAnimation, + vertical_width: WidthAnimation, + horizontal_width: WidthAnimation, +} + +impl Default for ScrollbarState { + fn default() -> Self { + let now = Instant::now(); + Self(Rc::new(Cell::new(ScrollbarStateInner { + hovered_axis: None, + hovered_on_thumb: None, + dragged_axis: None, + drag_pos: point(px(0.), px(0.)), + last_scroll_offset: point(px(0.), px(0.)), + last_scroll_time: None, + last_update: now, + idle_timer_scheduled: false, + visibility: VisibilityAnimation::hidden(now), + vertical_width: WidthAnimation::new(now), + horizontal_width: WidthAnimation::new(now), + }))) + } +} + +#[derive(Debug, Clone, Copy)] +struct ScalarTransition { + from: T, + target: T, + started_at: Instant, + duration: Duration, +} + +impl ScalarTransition { + fn settled(value: T, now: Instant) -> Self { + Self { + from: value, + target: value, + started_at: now, + duration: Duration::ZERO, + } + } + + fn sample(&self, now: Instant, interpolate: impl FnOnce(T, T, f32) -> T) -> (T, bool) { + if self.from == self.target || self.duration.is_zero() { + return (self.target, false); + } + let linear = now.saturating_duration_since(self.started_at).as_secs_f32() + / self.duration.as_secs_f32(); + if linear >= 1.0 { + (self.target, false) + } else { + ( + interpolate(self.from, self.target, linear.clamp(0.0, 1.0)), + true, + ) + } + } + + fn start(&mut self, from: T, target: T, duration: Duration, now: Instant) { + self.from = from; + self.target = target; + self.started_at = now; + self.duration = duration; + } + + fn settle(&mut self, target: T, now: Instant) { + self.start(target, target, Duration::ZERO, now); + } +} + +#[derive(Debug, Clone, Copy)] +struct WidthAnimation { + transition: ScalarTransition, + initialized: bool, +} + +impl WidthAnimation { + fn new(now: Instant) -> Self { + Self { + transition: ScalarTransition::settled(Pixels::ZERO, now), + initialized: false, + } + } + + fn sample(&self, now: Instant) -> (Pixels, bool) { + self.transition.sample(now, |from, target, linear| { + from + (target - from) * ease_out_cubic(linear) + }) + } + + /// Move toward `target` over `duration`. A zero duration adopts the target + /// immediately, which is how reduced motion and a motionless theme arrive here. + fn set_target(&mut self, target: Pixels, duration: Duration, now: Instant) -> (Pixels, bool) { + if duration.is_zero() || !self.initialized { + self.transition.settle(target, now); + self.initialized = true; + } else if self.transition.target != target { + let from = self.sample(now).0; + self.transition.start(from, target, duration, now); + } + self.sample(now) + } +} + +/// How a scrollbar becomes visible. +/// +/// The styled layer chooses the choreography; Base only plays it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ScrollbarEntrance { + /// Fade in without moving. + #[default] + Fade, + /// Slide in from the nearest edge while fading. + SlideAndFade, +} + +/// Motion tokens used by [`Scrollbar`]. +/// +/// Base installs no motion of its own: every transition duration defaults to +/// zero, so visibility and thumb width snap. Product timing belongs to the +/// styled layer, which projects it through [`crate::ScrollbarTheme`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ScrollbarMotion { + idle: Duration, + enter: Duration, + exit: Duration, + expand: Duration, + entrance: ScrollbarEntrance, + thumb_hover_entrance: ScrollbarEntrance, +} + +impl Default for ScrollbarMotion { + fn default() -> Self { + Self { + idle: DEFAULT_IDLE, + enter: Duration::ZERO, + exit: Duration::ZERO, + expand: Duration::ZERO, + entrance: ScrollbarEntrance::Fade, + thumb_hover_entrance: ScrollbarEntrance::Fade, + } + } +} + +impl ScrollbarMotion { + /// How long visibility is held after the last scroll, drag, or hover. + pub fn with_idle(mut self, idle: Duration) -> Self { + self.idle = idle; + self + } + + /// How long the scrollbar takes to become fully visible. + pub fn with_enter(mut self, enter: Duration) -> Self { + self.enter = enter; + self + } + + /// How long the scrollbar takes to fade away once the idle hold expires. + pub fn with_exit(mut self, exit: Duration) -> Self { + self.exit = exit; + self + } + + /// How long the thumb takes to reach a new width. + pub fn with_expand(mut self, expand: Duration) -> Self { + self.expand = expand; + self + } + + /// Which entrance choreography to play. + pub fn with_entrance(mut self, entrance: ScrollbarEntrance) -> Self { + self.entrance = entrance; + self + } + + /// Which entrance choreography to play when hover reveals the thumb. + pub fn with_thumb_hover_entrance(mut self, entrance: ScrollbarEntrance) -> Self { + self.thumb_hover_entrance = entrance; + self + } + + pub fn idle(&self) -> Duration { + self.idle + } + + pub fn enter(&self) -> Duration { + self.enter + } + + pub fn exit(&self) -> Duration { + self.exit + } + + pub fn expand(&self) -> Duration { + self.expand + } + + pub fn entrance(&self) -> ScrollbarEntrance { + self.entrance + } + + pub fn thumb_hover_entrance(&self) -> ScrollbarEntrance { + self.thumb_hover_entrance + } + + fn entrance_for(&self, mode: ScrollbarMode, thumb_hovered: bool) -> ScrollbarEntrance { + if mode.is_hover() && thumb_hovered { + self.thumb_hover_entrance + } else { + self.entrance + } + } +} + +#[derive(Debug, Clone, Copy)] +struct VisibilityAnimation { + opacity: ScalarTransition, + position: ScalarTransition, + entrance: ScrollbarEntrance, +} + +#[derive(Debug, Clone, Copy)] +struct VisibilitySample { + opacity: f32, + position: f32, + running: bool, +} + +impl VisibilityAnimation { + fn hidden(now: Instant) -> Self { + Self { + opacity: ScalarTransition::settled(0.0, now), + position: ScalarTransition::settled(0.0, now), + entrance: ScrollbarEntrance::Fade, + } + } + + fn sample(&self, now: Instant) -> VisibilitySample { + let entering = + self.opacity.target > self.opacity.from || self.position.target > self.position.from; + let (opacity, opacity_running) = self.opacity.sample(now, |from, target, linear| { + let factor = if entering { + linear + } else { + ease_in_cubic(linear) + }; + from + (target - from) * factor + }); + let (position, position_running) = self.position.sample(now, |from, target, linear| { + let factor = if entering { + ease_out_cubic(linear) + } else { + ease_in_cubic(linear) + }; + from + (target - from) * factor + }); + + VisibilitySample { + opacity, + position, + running: opacity_running || position_running, + } + } + + /// Reverse or start a transition toward `visible`. + /// + /// The leg runs for `enter` or `exit` scaled by the distance still to cover, + /// so an interrupted transition keeps its speed instead of restarting. + fn set_visible( + &mut self, + visible: bool, + entrance: ScrollbarEntrance, + enter: Duration, + exit: Duration, + now: Instant, + ) { + let target = if visible { 1.0 } else { 0.0 }; + let full_duration = if visible { enter } else { exit }; + if full_duration.is_zero() { + // A motionless policy — reduced motion, an always-visible scrollbar, + // or a theme that projects no motion — adopts the target outright, + // even if a transition was in flight when the policy changed. + self.opacity.settle(target, now); + self.position.settle(target, now); + self.entrance = entrance; + return; + } + if self.opacity.target == target + && self.position.target == target + && self.entrance == entrance + { + return; + } + + let sample = self.sample(now); + let from_position = if visible && entrance == ScrollbarEntrance::Fade { + 1.0 + } else { + sample.position + }; + let distance = (target - sample.opacity) + .abs() + .max((target - from_position).abs()); + let duration = full_duration.mul_f32(distance); + self.opacity.start(sample.opacity, target, duration, now); + self.position.start(from_position, target, duration, now); + self.entrance = entrance; + } +} + +fn visibility_translation(axis: Axis, track_width: Pixels, progress: f32) -> Point { + let offset = track_width * (1.0 - progress.clamp(0.0, 1.0)); + if axis.is_vertical() { + point(offset, px(0.)) + } else { + point(px(0.), offset) + } +} + +fn wants_visible( + mode: ScrollbarMode, + is_hovered: bool, + is_dragging: bool, + last_scroll_time: Option, + idle: Duration, + now: Instant, +) -> bool { + mode.is_always() + || is_dragging + || (mode.is_hover() && is_hovered) + || last_scroll_time.is_some_and(|last| now.saturating_duration_since(last) < idle) +} + +fn tracks_thumb_hover(mode: ScrollbarMode, is_visible: bool) -> bool { + mode.is_hover() || is_visible +} + +fn hover_keeps_visible(mode: ScrollbarMode, is_hovered: bool, is_currently_visible: bool) -> bool { + is_hovered && (mode.is_hover() || (mode == ScrollbarMode::Scrolling && is_currently_visible)) +} + +impl Deref for ScrollbarState { + type Target = Rc>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ScrollbarStateInner { + fn with_drag_pos(&self, axis: Axis, pos: Point) -> Self { + let mut state = *self; + if axis.is_vertical() { + state.drag_pos.y = pos.y; + } else { + state.drag_pos.x = pos.x; + } + + state.dragged_axis = Some(axis); + state + } + + fn with_unset_drag_pos(&self, now: Instant) -> Self { + let mut state = *self; + state.dragged_axis = None; + state.last_scroll_time = Some(now); + state + } + + fn with_hovered(&self, axis: Option, now: Instant) -> Self { + let mut state = *self; + state.hovered_axis = axis; + state.last_scroll_time = Some(now); + state + } + + fn with_hovered_on_thumb(&self, axis: Option) -> Self { + let mut state = *self; + state.hovered_on_thumb = axis; + if self.is_scrollbar_visible() { + if axis.is_some() { + state.last_scroll_time = Some(Instant::now()); + } + } + state + } + + fn with_last_scroll( + &self, + last_scroll_offset: Point, + last_scroll_time: Option, + ) -> Self { + let mut state = *self; + state.last_scroll_offset = last_scroll_offset; + state.last_scroll_time = last_scroll_time; + state + } + + fn with_last_update(&self, t: Instant) -> Self { + let mut state = *self; + state.last_update = t; + state + } + + fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self { + let mut state = *self; + state.idle_timer_scheduled = scheduled; + state + } + + fn is_scrollbar_visible(&self) -> bool { + self.dragged_axis.is_some() || self.visibility.sample(Instant::now()).opacity > 0.0 + } +} + +/// Scrollbar axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollbarAxis { + /// Vertical scrollbar. + Vertical, + /// Horizontal scrollbar. + Horizontal, + /// Show both vertical and horizontal scrollbars. + Both, +} + +/// Paint-only styles for a scrollbar track. +#[derive(Clone, Default)] +pub struct ScrollbarTrackStyle { + background: Option, + border: Option, + width: Option, +} + +impl ScrollbarTrackStyle { + pub fn bg(mut self, background: Hsla) -> Self { + self.background = Some(background); + self + } + + pub fn border_color(mut self, border: Hsla) -> Self { + self.border = Some(border); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } +} + +impl FluentBuilder for ScrollbarTrackStyle {} + +/// Paint-only styles for a scrollbar thumb. +#[derive(Clone, Default)] +pub struct ScrollbarThumbStyle { + background: Option, + width: Option, + inset: Option, + radius: Option, + min_length: Option, +} + +impl ScrollbarThumbStyle { + pub fn bg(mut self, background: impl Into) -> Self { + self.background = Some(background.into()); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + pub fn inset(mut self, inset: impl Into) -> Self { + self.inset = Some(inset.into()); + self + } + + pub fn radius(mut self, radius: impl Into) -> Self { + self.radius = Some(radius.into()); + self + } + + pub fn min_length(mut self, min_length: impl Into) -> Self { + self.min_length = Some(min_length.into()); + self + } +} + +impl FluentBuilder for ScrollbarThumbStyle {} + +/// Typed paint styles supported by [`Scrollbar`]. +#[derive(Clone, Default)] +pub struct ScrollbarStyles { + track: ScrollbarTrackStyle, + track_hover: ScrollbarTrackStyle, + track_active: ScrollbarTrackStyle, + thumb: ScrollbarThumbStyle, + thumb_hover: ScrollbarThumbStyle, + thumb_active: ScrollbarThumbStyle, +} + +impl ScrollbarStyles { + pub fn track(mut self, build: impl FnOnce(ScrollbarTrackStyle) -> ScrollbarTrackStyle) -> Self { + self.track = build(self.track); + self + } + + pub fn track_hover( + mut self, + build: impl FnOnce(ScrollbarTrackStyle) -> ScrollbarTrackStyle, + ) -> Self { + self.track_hover = build(self.track_hover); + self + } + + pub fn track_active( + mut self, + build: impl FnOnce(ScrollbarTrackStyle) -> ScrollbarTrackStyle, + ) -> Self { + self.track_active = build(self.track_active); + self + } + + pub fn thumb(mut self, build: impl FnOnce(ScrollbarThumbStyle) -> ScrollbarThumbStyle) -> Self { + self.thumb = build(self.thumb); + self + } + + pub fn thumb_hover( + mut self, + build: impl FnOnce(ScrollbarThumbStyle) -> ScrollbarThumbStyle, + ) -> Self { + self.thumb_hover = build(self.thumb_hover); + self + } + + pub fn thumb_active( + mut self, + build: impl FnOnce(ScrollbarThumbStyle) -> ScrollbarThumbStyle, + ) -> Self { + self.thumb_active = build(self.thumb_active); + self + } +} + +impl FluentBuilder for ScrollbarStyles {} + +impl From for ScrollbarAxis { + fn from(axis: Axis) -> Self { + match axis { + Axis::Vertical => Self::Vertical, + Axis::Horizontal => Self::Horizontal, + } + } +} + +impl ScrollbarAxis { + /// Return true if the scrollbar axis is vertical. + #[inline] + pub fn is_vertical(&self) -> bool { + matches!(self, Self::Vertical) + } + + /// Return true if the scrollbar axis is horizontal. + #[inline] + pub fn is_horizontal(&self) -> bool { + matches!(self, Self::Horizontal) + } + + /// Return true if the scrollbar axis is both vertical and horizontal. + #[inline] + pub fn is_both(&self) -> bool { + matches!(self, Self::Both) + } + + /// Return true if the scrollbar has vertical axis. + #[inline] + pub fn has_vertical(&self) -> bool { + matches!(self, Self::Vertical | Self::Both) + } + + /// Return true if the scrollbar has horizontal axis. + #[inline] + pub fn has_horizontal(&self) -> bool { + matches!(self, Self::Horizontal | Self::Both) + } + + #[inline] + fn all(&self) -> Vec { + match self { + Self::Vertical => vec![Axis::Vertical], + Self::Horizontal => vec![Axis::Horizontal], + // This should keep Horizontal first, Vertical is the primary axis + // if Vertical not need display, then Horizontal will not keep right margin. + Self::Both => vec![Axis::Horizontal, Axis::Vertical], + } + } +} + +/// Scrollbar control for scroll-area or a uniform-list. +pub struct Scrollbar { + pub(crate) id: ElementId, + axis: ScrollbarAxis, + mode: Option, + scroll_handle: Rc, + scroll_size: Option>, + viewport_bounds: Option>, + use_layout_bounds: bool, + /// Maximum frames per second for scrolling by drag. Default is 120 FPS. + /// + /// This is used to limit the update rate of the scrollbar when it is + /// being dragged for some complex interactions for reducing CPU usage. + max_fps: usize, + styles: ScrollbarStyles, +} + +impl Scrollbar { + /// Create a new scrollbar. + /// + /// This will have both vertical and horizontal scrollbars. + #[track_caller] + pub fn new(scroll_handle: &H) -> Self { + let caller = Location::caller(); + Self { + id: ElementId::CodeLocation(*caller), + axis: ScrollbarAxis::Both, + mode: None, + scroll_handle: Rc::new(scroll_handle.clone()), + max_fps: 120, + scroll_size: None, + viewport_bounds: None, + use_layout_bounds: false, + styles: ScrollbarStyles::default(), + } + } + + /// Create with horizontal scrollbar. + #[track_caller] + pub fn horizontal(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal) + } + + /// Create with vertical scrollbar. + #[track_caller] + pub fn vertical(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Vertical) + } + + /// Set a specific element id, default is the [`Location::caller`]. + /// + /// NOTE: In most cases, you don't need to set a specific id for scrollbar. + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Set the scrollbar show mode [`ScrollbarMode`]. + /// + /// If unset, the current application theme projection is used. + pub fn mode(mut self, mode: ScrollbarMode) -> Self { + self.mode = Some(mode); + self + } + + /// Set a special scroll size of the content area, default is None. + /// + /// Default will sync the `content_size` from `scroll_handle`. + pub fn scroll_size(mut self, scroll_size: Size) -> Self { + self.scroll_size = Some(scroll_size); + self + } + + /// Override the viewport bounds that this scrollbar overlays. + /// + /// Most scroll containers should rely on the bounds reported by their + /// scroll handle. Custom-painted viewports, such as the text editor, can + /// use this when their visible bounds differ from the handle's layout + /// bounds. + pub fn viewport_bounds(mut self, bounds: Bounds) -> Self { + self.viewport_bounds = Some(bounds); + self + } + + /// Use the scrollbar element's layout bounds as its viewport. + /// + /// This is useful for composite widgets whose scrollbar viewport excludes + /// fixed headers or columns and is therefore defined by a positioned + /// overlay container rather than by the underlying scroll handle. + pub fn viewport_from_layout(mut self) -> Self { + self.use_layout_bounds = true; + self + } + + fn resolved_viewport_bounds(&self, layout_bounds: Bounds) -> Bounds { + self.viewport_bounds.unwrap_or_else(|| { + if self.use_layout_bounds { + layout_bounds + } else { + self.scroll_handle.viewport_bounds() + } + }) + } + + /// Set scrollbar axis. + pub fn axis(mut self, axis: impl Into) -> Self { + self.axis = axis.into(); + self + } + + pub fn styles(mut self, build: impl FnOnce(ScrollbarStyles) -> ScrollbarStyles) -> Self { + self.styles = build(self.styles); + self + } + + /// Set maximum frames per second for scrolling by drag. Default is 120 FPS. + /// + /// If you have very high CPU usage, consider reducing this value to improve performance. + /// + /// Available values: 30..120 + #[doc(hidden)] + pub fn max_fps(mut self, max_fps: usize) -> Self { + self.max_fps = max_fps.clamp(30, 120); + self + } + + // Get the width of the scrollbar. + #[doc(hidden)] + pub const fn width() -> Pixels { + WIDTH + } + + fn resolve_track( + &self, + cx: &App, + state: &ScrollbarTrackStyle, + global_state: &ScrollbarTrackStyle, + default_border: Hsla, + ) -> (Hsla, Hsla) { + let theme = cx.theme(); + let global = theme.scrollbar.styles(); + ( + state + .background + .or(self.styles.track.background) + .or(global_state.background) + .or(global.track.background) + .unwrap_or_else(gpui::transparent_black), + state + .border + .or(self.styles.track.border) + .or(global_state.border) + .or(global.track.border) + .unwrap_or(default_border), + ) + } + + fn resolve_thumb( + &self, + cx: &App, + state: &ScrollbarThumbStyle, + global_state: &ScrollbarThumbStyle, + defaults: ScrollbarThumbStyle, + ) -> (Background, Pixels, Pixels, Pixels, Pixels) { + let theme = cx.theme(); + let global = theme.scrollbar.styles(); + ( + state + .background + .or(self.styles.thumb.background) + .or(global_state.background) + .or(global.thumb.background) + .unwrap_or_else(|| defaults.background.unwrap()), + state + .width + .or(self.styles.thumb.width) + .or(global_state.width) + .or(global.thumb.width) + .unwrap_or_else(|| defaults.width.unwrap()), + state + .inset + .or(self.styles.thumb.inset) + .or(global_state.inset) + .or(global.thumb.inset) + .unwrap_or_else(|| defaults.inset.unwrap()), + state + .radius + .or(self.styles.thumb.radius) + .or(global_state.radius) + .or(global.thumb.radius) + .unwrap_or_else(|| defaults.radius.unwrap()), + state + .min_length + .or(self.styles.thumb.min_length) + .or(global_state.min_length) + .or(global.thumb.min_length) + .or(defaults.min_length) + .unwrap_or(MIN_THUMB_SIZE), + ) + } + + fn thumb_defaults( + background: Background, + width: Pixels, + inset: Pixels, + radius: Pixels, + ) -> ScrollbarThumbStyle { + ScrollbarThumbStyle { + background: Some(background), + width: Some(width), + inset: Some(inset), + radius: Some(radius), + min_length: Some(MIN_THUMB_SIZE), + } + } + + fn style_for_active( + &self, + cx: &App, + ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) { + let theme = cx.theme(); + let global = theme.scrollbar.styles(); + let (track, border) = self.resolve_track( + cx, + &self.styles.track_active, + &global.track_active, + gpui::transparent_black(), + ); + let (thumb, width, inset, radius, min_length) = self.resolve_thumb( + cx, + &self.styles.thumb_active, + &global.thumb_active, + Self::thumb_defaults( + gpui::black().alpha(0.55).into(), + THUMB_ACTIVE_WIDTH, + THUMB_ACTIVE_INSET, + THUMB_ACTIVE_RADIUS, + ), + ); + (thumb, track, border, width, inset, radius, min_length) + } + + fn style_for_hovered_thumb( + &self, + cx: &App, + ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) { + let theme = cx.theme(); + let global = theme.scrollbar.styles(); + let (track, border) = self.resolve_track( + cx, + &self.styles.track_active, + &global.track_active, + gpui::transparent_black(), + ); + let (thumb, width, inset, radius, min_length) = self.resolve_thumb( + cx, + &self.styles.thumb_hover, + &global.thumb_hover, + Self::thumb_defaults( + gpui::black().alpha(0.55).into(), + THUMB_ACTIVE_WIDTH, + THUMB_ACTIVE_INSET, + THUMB_ACTIVE_RADIUS, + ), + ); + (thumb, track, border, width, inset, radius, min_length) + } + + fn style_for_hovered_bar( + &self, + cx: &App, + ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) { + let theme = cx.theme(); + let global = theme.scrollbar.styles(); + let (track, border) = self.resolve_track( + cx, + &self.styles.track_hover, + &global.track_hover, + gpui::transparent_black(), + ); + let (thumb, width, inset, radius, min_length) = self.resolve_thumb( + cx, + &self.styles.thumb, + &global.thumb, + Self::thumb_defaults( + gpui::black().alpha(0.35).into(), + THUMB_WIDTH, + THUMB_INSET, + THUMB_RADIUS, + ), + ); + (thumb, track, border, width, inset, radius, min_length) + } + + fn style_for_normal( + &self, + cx: &App, + ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) { + let theme = cx.theme(); + let global = theme.scrollbar.styles(); + + let (track, border) = self.resolve_track( + cx, + &self.styles.track, + &global.track, + gpui::transparent_black(), + ); + let (thumb, width, inset, radius, min_length) = self.resolve_thumb( + cx, + &self.styles.thumb, + &global.thumb, + Self::thumb_defaults( + gpui::black().alpha(0.35).into(), + THUMB_WIDTH, + THUMB_INSET, + THUMB_RADIUS, + ), + ); + (thumb, track, border, width, inset, radius, min_length) + } +} + +impl IntoElement for Scrollbar { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +#[doc(hidden)] +pub struct PrepaintState { + hitbox: Hitbox, + scrollbar_state: ScrollbarState, + states: Vec, +} + +#[doc(hidden)] +pub struct AxisPrepaintState { + axis: Axis, + bar_hitbox: Hitbox, + bounds: Bounds, + radius: Pixels, + bg: Hsla, + border: Hsla, + thumb_bounds: Bounds, + // Bounds of thumb to be rendered. + thumb_fill_bounds: Bounds, + thumb_bg: Background, + scroll_size: Pixels, + container_size: Pixels, + thumb_size: Pixels, + margin_end: Pixels, + track_width: Pixels, + visibility_opacity: f32, + visibility_position: f32, + visibility_requested: bool, +} + +impl Element for Scrollbar { + type RequestLayoutState = (); + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.position = Position::Absolute; + style.flex_grow = 1.0; + style.flex_shrink = 1.0; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + + (window.request_layout(style, None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let bounds = self.resolved_viewport_bounds(bounds); + let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { + window.insert_hitbox(bounds, HitboxBehavior::Normal) + }); + + let state = window + .use_state(cx, |_, _| ScrollbarState::default()) + .read(cx) + .clone(); + + let now = Instant::now(); + let base_theme = cx.theme(); + let mode = self.mode.unwrap_or(base_theme.scrollbar.mode()); + let motion = base_theme.scrollbar.motion(); + // Always-visible scrollbars skip visibility motion but still animate + // their activity width. Reduced motion snaps every channel. + let reduce_motion = cx.reduce_motion(); + let (enter, exit) = if !mode.is_always() && !reduce_motion { + (motion.enter(), motion.exit()) + } else { + (Duration::ZERO, Duration::ZERO) + }; + let expand = if reduce_motion { + Duration::ZERO + } else { + motion.expand() + }; + + let mut inner = state.get(); + let current_offset = self.scroll_handle.offset(); + if current_offset != inner.last_scroll_offset { + inner = inner.with_last_scroll(current_offset, Some(now)); + } + + let is_hovered = inner.hovered_axis.is_some() || inner.hovered_on_thumb.is_some(); + let is_dragging = inner.dragged_axis.is_some(); + let is_currently_visible = inner.visibility.sample(now).opacity > 0.0; + let visible = hover_keeps_visible(mode, is_hovered, is_currently_visible) + || wants_visible( + mode, + is_hovered, + is_dragging, + inner.last_scroll_time, + motion.idle(), + now, + ); + inner.visibility.set_visible( + visible, + motion.entrance_for(mode, inner.hovered_on_thumb.is_some()), + enter, + exit, + now, + ); + let visibility = inner.visibility.sample(now); + if visibility.running { + window.request_animation_frame(); + } + + if !is_hovered && !is_dragging { + if let Some(last_time) = inner.last_scroll_time { + let elapsed = now.saturating_duration_since(last_time); + if elapsed < motion.idle() && !inner.idle_timer_scheduled { + inner.idle_timer_scheduled = true; + let state = state.clone(); + let current_view = window.current_view(); + let next_delay = motion.idle() - elapsed; + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(next_delay).await; + state.set(state.get().with_idle_timer_scheduled(false)); + cx.update(|_, cx| cx.notify(current_view)).ok(); + }) + .detach(); + } + } + } + state.set(inner); + + let mut states = vec![]; + let mut has_both = self.axis.is_both(); + let scroll_size = self + .scroll_size + .unwrap_or(self.scroll_handle.content_size()); + + for axis in self.axis.all().into_iter() { + let is_vertical = axis.is_vertical(); + let track_width = self + .styles + .track + .width + .or(cx.theme().scrollbar.styles().track.width) + .unwrap_or(WIDTH); + let (scroll_area_size, container_size, scroll_position) = if is_vertical { + ( + scroll_size.height, + hitbox.size.height, + self.scroll_handle.offset().y, + ) + } else { + ( + scroll_size.width, + hitbox.size.width, + self.scroll_handle.offset().x, + ) + }; + + // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. + let margin_end = if has_both && !is_vertical { + track_width + } else { + px(0.) + }; + + // Hide scrollbar, if the scroll area is smaller than the container. + if scroll_area_size <= container_size { + has_both = false; + continue; + } + + let bounds = Bounds { + origin: if is_vertical { + point( + hitbox.origin.x + hitbox.size.width - track_width, + hitbox.origin.y, + ) + } else { + point( + hitbox.origin.x, + hitbox.origin.y + hitbox.size.height - track_width, + ) + }, + size: gpui::Size { + width: if is_vertical { + track_width + } else { + hitbox.size.width + }, + height: if is_vertical { + hitbox.size.height + } else { + track_width + }, + }, + }; + + let is_always_to_show = mode.is_always(); + let is_hover_to_show = mode.is_hover(); + let is_hovered_on_bar = state.get().hovered_axis == Some(axis); + let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); + + let (thumb_bg, bar_bg, bar_border, mut thumb_width, inset, radius, min_length) = + if state.get().dragged_axis == Some(axis) { + self.style_for_active(cx) + } else if (is_hover_to_show || mode == ScrollbarMode::Scrolling) + && (is_hovered_on_bar || is_hovered_on_thumb) + { + if is_hovered_on_thumb { + self.style_for_hovered_thumb(cx) + } else { + self.style_for_hovered_bar(cx) + } + } else if is_always_to_show && (is_hovered_on_bar || is_hovered_on_thumb) { + if is_hovered_on_thumb { + self.style_for_hovered_thumb(cx) + } else { + self.style_for_hovered_bar(cx) + } + } else { + self.style_for_normal(cx) + }; + + let mut width_animation = if is_vertical { + state.get().vertical_width + } else { + state.get().horizontal_width + }; + let (animated_width, running) = width_animation.set_target(thumb_width, expand, now); + let mut updated = state.get(); + if is_vertical { + updated.vertical_width = width_animation; + } else { + updated.horizontal_width = width_animation; + } + state.set(updated); + thumb_width = animated_width; + if running { + window.request_animation_frame(); + } + + let thumb_size = (container_size / scroll_area_size * container_size).max(min_length); + let thumb_start = -(scroll_position / (scroll_area_size - container_size) + * (container_size - margin_end - thumb_size)); + let thumb_end = (thumb_start + thumb_size).min(container_size - margin_end); + + // The clickable area of the thumb + let thumb_length = thumb_end - thumb_start - inset * 2; + let thumb_bounds = if is_vertical { + Bounds::from_anchor_and_size( + Anchor::TopRight, + bounds.top_right() + point(-inset, inset + thumb_start), + size(track_width, thumb_length), + ) + } else { + Bounds::from_anchor_and_size( + Anchor::BottomLeft, + bounds.bottom_left() + point(inset + thumb_start, -inset), + size(thumb_length, track_width), + ) + }; + + // The actual render area of the thumb + let thumb_fill_bounds = if is_vertical { + Bounds::from_anchor_and_size( + Anchor::TopRight, + bounds.top_right() + point(-inset, inset + thumb_start), + size(thumb_width, thumb_length), + ) + } else { + Bounds::from_anchor_and_size( + Anchor::BottomLeft, + bounds.bottom_left() + point(inset + thumb_start, -inset), + size(thumb_length, thumb_width), + ) + }; + + let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { + window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) + }); + + states.push(AxisPrepaintState { + axis, + bar_hitbox, + bounds, + radius, + bg: bar_bg, + border: bar_border, + thumb_bounds, + thumb_fill_bounds, + thumb_bg, + scroll_size: scroll_area_size, + container_size, + thumb_size: thumb_length, + margin_end, + track_width, + visibility_opacity: visibility.opacity, + visibility_position: visibility.position, + visibility_requested: visible, + }) + } + + PrepaintState { + hitbox, + states, + scrollbar_state: state, + } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let scrollbar_state = &prepaint.scrollbar_state; + let theme = cx.theme(); + let mode = self.mode.unwrap_or(theme.scrollbar.mode()); + let view_id = window.current_view(); + let hitbox_bounds = prepaint.hitbox.bounds; + let is_hover_to_show = mode.is_hover(); + + window.with_content_mask( + Some(ContentMask { + bounds: hitbox_bounds, + }), + |window| { + for state in prepaint.states.iter() { + let axis = state.axis; + let mut radius = state.radius; + if theme.tokens.radius.md.is_zero() { + radius = px(0.); + } + radius = clamp_thumb_radius(radius, state.thumb_fill_bounds); + let bounds = state.bounds; + let thumb_bounds = state.thumb_bounds; + let scroll_area_size = state.scroll_size; + let container_size = state.container_size; + let thumb_size = state.thumb_size; + let margin_end = state.margin_end; + let is_vertical = axis.is_vertical(); + let visibility_opacity = state.visibility_opacity; + let is_visible = state.visibility_requested || visibility_opacity > 0.0; + let translation = + visibility_translation(axis, state.track_width, state.visibility_position); + let painted_bounds = state.bounds + translation; + let painted_thumb_bounds = state.thumb_fill_bounds + translation; + let painted_track_bg = state.bg.opacity(visibility_opacity); + let painted_border = state.border.opacity(visibility_opacity); + let painted_thumb_bg = state.thumb_bg.clone().opacity(visibility_opacity); + + window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox); + + window.paint_layer(hitbox_bounds, |cx| { + cx.paint_quad(fill(painted_bounds, painted_track_bg)); + + cx.paint_quad(PaintQuad { + bounds: painted_bounds, + corner_radii: (0.).into(), + background: gpui::transparent_black().into(), + border_widths: if is_vertical { + Edges { + top: px(0.), + right: px(0.), + bottom: px(0.), + left: px(0.), + } + } else { + Edges { + top: px(0.), + right: px(0.), + bottom: px(0.), + left: px(0.), + } + }, + border_color: painted_border, + border_style: BorderStyle::default(), + }); + + cx.paint_quad( + fill(painted_thumb_bounds, painted_thumb_bg).corner_radii(radius), + ); + }); + + window.on_mouse_event({ + let state = scrollbar_state.clone(); + let scroll_handle = self.scroll_handle.clone(); + + move |event: &ScrollWheelEvent, phase, _, cx| { + if phase.bubble() && hitbox_bounds.contains(&event.position) { + if scroll_handle.offset() != state.get().last_scroll_offset { + state.set(state.get().with_last_scroll( + scroll_handle.offset(), + Some(Instant::now()), + )); + cx.notify(view_id); + } + } + } + }); + + let safe_range = (-scroll_area_size + container_size)..px(0.); + + if is_visible { + window.on_mouse_event({ + let state = scrollbar_state.clone(); + let scroll_handle = self.scroll_handle.clone(); + + move |event: &MouseDownEvent, phase, _, cx| { + if phase.bubble() && bounds.contains(&event.position) { + cx.stop_propagation(); + + if thumb_bounds.contains(&event.position) { + // click on the thumb bar, set the drag position + let pos = event.position - thumb_bounds.origin; + + scroll_handle.start_drag(); + state.set(state.get().with_drag_pos(axis, pos)); + + cx.notify(view_id); + } else { + // click on the scrollbar, jump to the position + // Set the thumb bar center to the click position + let offset = scroll_handle.offset(); + let percentage = if is_vertical { + (event.position.y - thumb_size / 2. - bounds.origin.y) + / (bounds.size.height - thumb_size) + } else { + (event.position.x - thumb_size / 2. - bounds.origin.x) + / (bounds.size.width - thumb_size) + } + .min(1.); + + if is_vertical { + scroll_handle.set_offset(point( + offset.x, + (-scroll_area_size * percentage) + .clamp(safe_range.start, safe_range.end), + )); + } else { + scroll_handle.set_offset(point( + (-scroll_area_size * percentage) + .clamp(safe_range.start, safe_range.end), + offset.y, + )); + } + } + } + } + }); + } + + window.on_mouse_event({ + let scroll_handle = self.scroll_handle.clone(); + let state = scrollbar_state.clone(); + let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); + + move |event: &MouseMoveEvent, _, _, cx| { + let mut notify = false; + // When is hover to show mode or it was visible, + // we need to update the hovered state and increase the last_scroll_time. + let need_hover_to_update = is_hover_to_show || is_visible; + // Update hovered state for scrollbar + if bounds.contains(&event.position) && need_hover_to_update { + let hover_changed = state.get().hovered_axis != Some(axis); + state.set(state.get().with_hovered(Some(axis), Instant::now())); + notify |= hover_changed; + } else if state.get().hovered_axis == Some(axis) { + state.set(state.get().with_hovered(None, Instant::now())); + notify = true; + } + + // Update hovered state for scrollbar thumb + if tracks_thumb_hover(mode, is_visible) + && thumb_bounds.contains(&event.position) + { + if state.get().hovered_on_thumb != Some(axis) { + state.set(state.get().with_hovered_on_thumb(Some(axis))); + notify = true; + } + } else { + if state.get().hovered_on_thumb == Some(axis) { + state.set(state.get().with_hovered_on_thumb(None)); + notify = true; + } + } + + // Move thumb position on dragging + if state.get().dragged_axis == Some(axis) && event.dragging() { + // Stop the event propagation to avoid selecting text or other side effects. + cx.stop_propagation(); + + // drag_pos is the position of the mouse down event + // We need to keep the thumb bar still at the origin down position + let drag_pos = state.get().drag_pos; + + let percentage = (if is_vertical { + (event.position.y - drag_pos.y - bounds.origin.y) + / (bounds.size.height - thumb_size) + } else { + (event.position.x - drag_pos.x - bounds.origin.x) + / (bounds.size.width - thumb_size - margin_end) + }) + .clamp(0., 1.); + + let offset = if is_vertical { + point( + scroll_handle.offset().x, + (-(scroll_area_size - container_size) * percentage) + .clamp(safe_range.start, safe_range.end), + ) + } else { + point( + (-(scroll_area_size - container_size) * percentage) + .clamp(safe_range.start, safe_range.end), + scroll_handle.offset().y, + ) + }; + + if (scroll_handle.offset().y - offset.y).abs() > px(1.) + || (scroll_handle.offset().x - offset.x).abs() > px(1.) + { + // Limit update rate + if state.get().last_update.elapsed() > max_fps_duration { + scroll_handle.set_offset(offset); + state.set(state.get().with_last_update(Instant::now())); + notify = true; + } + } + } + + if notify { + cx.notify(view_id); + } + } + }); + + window.on_mouse_event({ + let state = scrollbar_state.clone(); + let scroll_handle = self.scroll_handle.clone(); + + move |_event: &MouseUpEvent, phase, _, cx| { + if phase.bubble() && state.get().dragged_axis == Some(axis) { + scroll_handle.end_drag(); + state.set(state.get().with_unset_drag_pos(Instant::now())); + cx.notify(view_id); + } + } + }); + } + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::cell::Cell; + + use gpui::{ + Context, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, div, + }; + + #[test] + fn thumb_radius_is_limited_by_its_actual_bounds() { + let vertical_thumb = Bounds::new(Point::default(), size(px(8.), px(80.))); + let horizontal_thumb = Bounds::new(Point::default(), size(px(80.), px(6.))); + + assert_eq!(clamp_thumb_radius(px(6.), vertical_thumb), px(4.)); + assert_eq!(clamp_thumb_radius(px(6.), horizontal_thumb), px(3.)); + assert_eq!( + clamp_thumb_radius(Pixels::ZERO, vertical_thumb), + Pixels::ZERO + ); + } + + /// Timing standing in for what a styled layer projects. Base itself ships + /// none of these; see [`motionless_base_snaps_every_transition`]. + const ENTER: Duration = Duration::from_millis(300); + const EXIT: Duration = Duration::from_millis(500); + const EXPAND: Duration = Duration::from_millis(300); + + #[test] + fn visibility_animation_uses_direction_specific_curves_and_durations() { + let start = Instant::now(); + let mut animation = VisibilityAnimation::hidden(start); + + animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start); + assert_eq!(animation.sample(start).opacity, 0.0); + let entering = animation.sample(start + ENTER / 2).position; + assert!(entering > 0.5, "ease-out must advance quickly"); + let entered = animation.sample(start + ENTER); + assert_eq!(entered.opacity, 1.0); + assert_eq!(entered.position, 1.0); + + animation.set_visible( + false, + ScrollbarEntrance::SlideAndFade, + ENTER, + EXIT, + start + ENTER, + ); + let exiting = animation.sample(start + ENTER + EXIT / 2).opacity; + assert!(exiting > 0.5, "ease-in must remain visible early in exit"); + assert_eq!(animation.sample(start + ENTER + EXIT).opacity, 0.0); + } + + #[test] + fn entrance_fades_linearly_while_position_eases_out() { + let start = Instant::now(); + let mut animation = VisibilityAnimation::hidden(start); + animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start); + + let halfway = animation.sample(start + ENTER / 2); + assert!((halfway.opacity - 0.5).abs() < 0.001); + assert!(halfway.position > halfway.opacity); + } + + #[test] + fn fade_entrance_snaps_position_and_animates_opacity() { + let start = Instant::now(); + let mut animation = VisibilityAnimation::hidden(start); + animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, start); + + let initial = animation.sample(start); + assert_eq!(initial.opacity, 0.0); + assert_eq!(initial.position, 1.0); + let halfway = animation.sample(start + ENTER / 2); + assert!((halfway.opacity - 0.5).abs() < 0.001); + assert_eq!(halfway.position, 1.0); + } + + #[test] + fn active_visibility_adopts_a_changed_entrance_policy() { + let start = Instant::now(); + let halfway = start + ENTER / 2; + let mut animation = VisibilityAnimation::hidden(start); + animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start); + let before = animation.sample(halfway); + + animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, halfway); + let after = animation.sample(halfway); + + assert_eq!( + after.opacity, before.opacity, + "policy changes must not flash" + ); + assert_eq!(after.position, 1.0, "fade entrance must stop stale sliding"); + } + + #[test] + fn base_ships_no_motion_of_its_own() { + let motion = ScrollbarMotion::default(); + assert_eq!(motion.enter(), Duration::ZERO); + assert_eq!(motion.exit(), Duration::ZERO); + assert_eq!(motion.expand(), Duration::ZERO); + assert_eq!(motion.entrance(), ScrollbarEntrance::Fade); + assert_eq!(motion.thumb_hover_entrance(), ScrollbarEntrance::Fade); + assert_eq!( + motion.idle(), + DEFAULT_IDLE, + "the visibility hold is behavior, not motion, and must stay usable" + ); + } + + #[test] + fn hover_mode_slides_only_when_the_thumb_is_hovered() { + let motion = ScrollbarMotion::default() + .with_entrance(ScrollbarEntrance::Fade) + .with_thumb_hover_entrance(ScrollbarEntrance::SlideAndFade); + + assert_eq!( + motion.entrance_for(ScrollbarMode::Hover, false), + ScrollbarEntrance::Fade + ); + assert_eq!( + motion.entrance_for(ScrollbarMode::Hover, true), + ScrollbarEntrance::SlideAndFade + ); + assert_eq!( + motion.entrance_for(ScrollbarMode::Scrolling, true), + ScrollbarEntrance::Fade + ); + } + + #[test] + fn hidden_scrolling_mode_does_not_track_thumb_hover() { + assert!(!tracks_thumb_hover(ScrollbarMode::Scrolling, false)); + assert!(tracks_thumb_hover(ScrollbarMode::Scrolling, true)); + assert!(tracks_thumb_hover(ScrollbarMode::Hover, false)); + } + + #[test] + fn visible_scrolling_mode_stays_visible_while_hovered() { + assert!(!hover_keeps_visible(ScrollbarMode::Scrolling, true, false)); + assert!(hover_keeps_visible(ScrollbarMode::Scrolling, true, true)); + assert!(hover_keeps_visible(ScrollbarMode::Hover, true, false)); + } + + #[test] + fn motionless_base_snaps_every_transition() { + let now = Instant::now(); + let motion = ScrollbarMotion::default(); + let mut visibility = VisibilityAnimation::hidden(now); + + visibility.set_visible(true, motion.entrance(), motion.enter(), motion.exit(), now); + let shown = visibility.sample(now); + assert_eq!(shown.opacity, 1.0); + assert_eq!(shown.position, 1.0); + assert!(!shown.running, "a motionless theme must request no frames"); + assert_eq!( + visibility_translation(Axis::Vertical, px(16.), shown.position), + Point::default() + ); + + visibility.set_visible(false, motion.entrance(), motion.enter(), motion.exit(), now); + let hidden = visibility.sample(now); + assert_eq!(hidden.opacity, 0.0); + assert_eq!(hidden.position, 0.0); + assert!(!hidden.running); + + let mut width = WidthAnimation::new(now); + assert_eq!( + width.set_target(px(8.), motion.expand(), now), + (px(8.), false) + ); + } + + #[test] + fn a_zero_duration_settles_a_transition_already_in_flight() { + let start = Instant::now(); + let mut animation = VisibilityAnimation::hidden(start); + animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start); + + // Reduced motion turns on midway through the entrance. + let midway = start + ENTER / 2; + animation.set_visible( + true, + ScrollbarEntrance::SlideAndFade, + Duration::ZERO, + Duration::ZERO, + midway, + ); + let settled = animation.sample(midway); + assert_eq!(settled.opacity, 1.0); + assert_eq!(settled.position, 1.0); + assert!(!settled.running); + } + + #[test] + fn thumb_expansion_animates_in_both_directions() { + let start = Instant::now(); + let mut animation = WidthAnimation::new(start); + assert_eq!(animation.set_target(px(6.), EXPAND, start), (px(6.), false)); + + let (initial, running) = animation.set_target(px(8.), EXPAND, start); + assert_eq!(initial, px(6.)); + assert!(running); + let (expanded_halfway, _) = animation.sample(start + EXPAND / 2); + assert!(expanded_halfway > px(7.)); + + let reversal = start + EXPAND / 2; + let before = animation.sample(reversal).0; + let (after_reversal, running) = animation.set_target(px(6.), EXPAND, reversal); + assert_eq!(after_reversal, before); + assert!(running); + assert_eq!(animation.sample(reversal + EXPAND).0, px(6.)); + } + + #[test] + fn reduced_motion_snaps_thumb_expansion() { + let now = Instant::now(); + let mut animation = WidthAnimation::new(now); + assert_eq!( + animation.set_target(px(8.), Duration::ZERO, now), + (px(8.), false) + ); + } + + #[test] + fn visibility_translation_moves_toward_the_nearest_edge() { + assert_eq!( + visibility_translation(Axis::Vertical, px(16.), 0.0), + point(px(16.), px(0.)) + ); + assert_eq!( + visibility_translation(Axis::Horizontal, px(16.), 0.0), + point(px(0.), px(16.)) + ); + assert_eq!( + visibility_translation(Axis::Vertical, px(16.), 1.0), + Point::default() + ); + } + + #[test] + fn visibility_animation_reverses_from_current_progress() { + let start = Instant::now(); + let mut animation = VisibilityAnimation::hidden(start); + animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start); + let reversal_time = start + Duration::from_millis(60); + let before = animation.sample(reversal_time); + + animation.set_visible( + false, + ScrollbarEntrance::SlideAndFade, + ENTER, + EXIT, + reversal_time, + ); + let reversed = animation.sample(reversal_time); + assert_eq!(reversed.opacity, before.opacity); + assert_eq!(reversed.position, before.position); + let after = animation.sample(reversal_time + Duration::from_millis(10)); + assert!(after.opacity < before.opacity); + assert!(after.position < before.position); + } + + #[test] + fn always_hover_drag_and_recent_scroll_request_visibility() { + let now = Instant::now(); + let idle = DEFAULT_IDLE; + assert!(wants_visible( + ScrollbarMode::Always, + false, + false, + None, + idle, + now + )); + assert!(wants_visible( + ScrollbarMode::Hover, + true, + false, + None, + idle, + now + )); + assert!(wants_visible( + ScrollbarMode::Scrolling, + false, + true, + None, + idle, + now + )); + assert!(wants_visible( + ScrollbarMode::Scrolling, + false, + false, + Some(now - idle + Duration::from_millis(1)), + idle, + now, + )); + assert!(!wants_visible( + ScrollbarMode::Scrolling, + false, + false, + Some(now - idle), + idle, + now, + )); + + let mut settled = VisibilityAnimation::hidden(now - ENTER); + settled.set_visible( + true, + ScrollbarEntrance::SlideAndFade, + ENTER, + EXIT, + now - ENTER, + ); + assert!(!settled.sample(now).running, "idle hold must not animate"); + } + + #[test] + fn leaving_hover_starts_a_fresh_idle_hold() { + let entered_at = Instant::now(); + let left_at = entered_at + Duration::from_secs(5); + let state = ScrollbarState::default().get(); + + let hovered = state.with_hovered(Some(Axis::Vertical), entered_at); + assert_eq!(hovered.last_scroll_time, Some(entered_at)); + let left = hovered.with_hovered(None, left_at); + assert_eq!(left.last_scroll_time, Some(left_at)); + assert!(wants_visible( + ScrollbarMode::Hover, + false, + false, + left.last_scroll_time, + DEFAULT_IDLE, + left_at + DEFAULT_IDLE - Duration::from_millis(1), + )); + } + + #[test] + fn idle_boundary_starts_the_exit_without_a_jump() { + let activity = Instant::now(); + let exit_start = activity + DEFAULT_IDLE; + let mut animation = VisibilityAnimation::hidden(activity - ENTER); + animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, activity - ENTER); + assert!(!wants_visible( + ScrollbarMode::Scrolling, + false, + false, + Some(activity), + DEFAULT_IDLE, + exit_start, + )); + + animation.set_visible(false, ScrollbarEntrance::Fade, ENTER, EXIT, exit_start); + let start = animation.sample(exit_start); + assert_eq!(start.opacity, 1.0); + assert_eq!(start.position, 1.0); + assert_eq!(animation.sample(exit_start + EXIT).opacity, 0.0); + } + + #[derive(Clone)] + struct TestHandle { + offset: Rc>>, + content_size: Size, + drag_starts: Rc>, + drag_ends: Rc>, + } + + impl TestHandle { + fn new(content_size: Size) -> Self { + Self { + offset: Rc::new(Cell::new(Point::default())), + content_size, + drag_starts: Rc::new(Cell::new(0)), + drag_ends: Rc::new(Cell::new(0)), + } + } + } + + impl ScrollbarHandle for TestHandle { + fn viewport_bounds(&self) -> Bounds { + Bounds::new(Point::default(), size(px(100.), px(100.))) + } + + fn offset(&self) -> Point { + self.offset.get() + } + + fn set_offset(&self, offset: Point) { + self.offset.set(offset); + } + + fn content_size(&self) -> Size { + self.content_size + } + + fn start_drag(&self) { + self.drag_starts.set(self.drag_starts.get() + 1); + } + + fn end_drag(&self) { + self.drag_ends.set(self.drag_ends.get() + 1); + } + } + + struct ScrollbarHarness { + handle: TestHandle, + axis: ScrollbarAxis, + mode: ScrollbarMode, + } + + impl Render for ScrollbarHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .relative() + .size(px(100.)) + .child(Scrollbar::new(&self.handle).axis(self.axis).mode(self.mode)) + } + } + + fn harness( + cx: &mut TestAppContext, + axis: ScrollbarAxis, + mode: ScrollbarMode, + content_size: Size, + ) -> (&mut VisualTestContext, TestHandle) { + let handle = TestHandle::new(content_size); + let (_, cx) = cx.add_window_view({ + let handle = handle.clone(); + move |_, _| ScrollbarHarness { handle, axis, mode } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, handle) + } + + #[test] + fn explicit_viewport_bounds_override_handle_bounds() { + let expected = Bounds::new(point(px(12.), px(24.)), size(px(240.), px(96.))); + let scrollbar = Scrollbar::vertical(&TestHandle::new(size(px(240.), px(480.)))) + .viewport_bounds(expected); + + assert_eq!( + scrollbar.resolved_viewport_bounds(Bounds::default()), + expected + ); + } + + #[test] + fn layout_viewport_uses_current_element_bounds() { + let expected = Bounds::new(point(px(20.), px(30.)), size(px(180.), px(12.))); + let scrollbar = + Scrollbar::horizontal(&TestHandle::new(size(px(600.), px(12.)))).viewport_from_layout(); + + assert_eq!(scrollbar.resolved_viewport_bounds(expected), expected); + } + + #[test] + fn typed_styles_are_fluent_and_include_geometry() { + let track = gpui::hsla(0.1, 0.2, 0.3, 1.0); + let border = gpui::hsla(0.2, 0.3, 0.4, 1.0); + let thumb = gpui::hsla(0.3, 0.4, 0.5, 1.0); + let hover = gpui::hsla(0.4, 0.5, 0.6, 1.0); + let active = gpui::hsla(0.5, 0.6, 0.7, 1.0); + let scrollbar = Scrollbar::new(&TestHandle::new(Size::default())).styles(|styles| { + styles + .track(|style| { + style + .width(px(14.)) + .bg(track) + .border_color(border) + .when(false, |style| style.width(px(99.))) + }) + .thumb(|style| { + style + .width(px(7.)) + .inset(px(3.)) + .radius(px(3.5)) + .min_length(px(40.)) + .bg(thumb) + }) + .thumb_hover(|style| style.width(px(9.)).bg(hover)) + .thumb_active(|style| style.radius(px(4.5)).bg(active)) + }); + + assert_eq!(scrollbar.styles.track.background, Some(track)); + assert_eq!(scrollbar.styles.track.border, Some(border)); + assert_eq!(scrollbar.styles.track.width, Some(px(14.))); + assert!(scrollbar.styles.thumb.background.is_some()); + assert_eq!(scrollbar.styles.thumb.width, Some(px(7.))); + assert_eq!(scrollbar.styles.thumb.inset, Some(px(3.))); + assert_eq!(scrollbar.styles.thumb.radius, Some(px(3.5))); + assert_eq!(scrollbar.styles.thumb.min_length, Some(px(40.))); + assert!(scrollbar.styles.thumb_hover.background.is_some()); + assert_eq!(scrollbar.styles.thumb_hover.width, Some(px(9.))); + assert!(scrollbar.styles.thumb_active.background.is_some()); + assert_eq!(scrollbar.styles.thumb_active.radius, Some(px(4.5))); + } + + #[gpui::test] + fn instance_styles_override_theme_scrollbar_defaults(cx: &mut TestAppContext) { + cx.update(|cx| { + let theme_track = gpui::hsla(0.1, 0.2, 0.3, 1.0); + let theme_thumb = gpui::hsla(0.2, 0.3, 0.4, 1.0); + let instance_thumb = gpui::hsla(0.3, 0.4, 0.5, 1.0); + + crate::Theme::global_mut(cx).scrollbar = crate::ScrollbarTheme::new() + .with_mode(ScrollbarMode::Always) + .with_motion(ScrollbarMotion::default()) + .with_styles( + ScrollbarStyles::default() + .track(|style| style.width(px(13.)).bg(theme_track)) + .thumb(|style| style.width(px(7.)).bg(theme_thumb)), + ); + + let scrollbar = Scrollbar::new(&TestHandle::new(Size::default())) + .styles(|styles| styles.thumb(|style| style.bg(instance_thumb))); + let (thumb, track, _, width, _, _, _) = scrollbar.style_for_normal(cx); + + assert_eq!(thumb, Background::from(instance_thumb)); + assert_eq!(track, theme_track); + assert_eq!(width, px(7.)); + assert_eq!(cx.theme().scrollbar.styles().track.width, Some(px(13.))); + }); + } + + #[gpui::test] + fn auto_hide_modes_use_a_six_pixel_resting_thumb(cx: &mut TestAppContext) { + cx.update(|cx| { + let handle = TestHandle::new(Size::default()); + let scrolling = Scrollbar::new(&handle).mode(ScrollbarMode::Scrolling); + let always = Scrollbar::new(&handle).mode(ScrollbarMode::Always); + + assert_eq!(scrolling.style_for_normal(cx).3, px(6.)); + assert_eq!(always.style_for_normal(cx).3, px(6.)); + }); + } + + #[gpui::test] + fn every_mode_expands_only_for_thumb_hover(cx: &mut TestAppContext) { + cx.update(|cx| { + let handle = TestHandle::new(Size::default()); + for mode in [ + ScrollbarMode::Scrolling, + ScrollbarMode::Hover, + ScrollbarMode::Always, + ] { + let scrollbar = Scrollbar::new(&handle).mode(mode); + assert_eq!(scrollbar.style_for_normal(cx).3, px(6.)); + assert_eq!(scrollbar.style_for_hovered_bar(cx).3, px(6.)); + assert_eq!(scrollbar.style_for_hovered_thumb(cx).3, px(8.)); + } + }); + } + + #[gpui::test] + fn vertical_track_click_updates_vertical_offset(cx: &mut TestAppContext) { + let (cx, vertical) = harness( + cx, + ScrollbarAxis::Vertical, + ScrollbarMode::Always, + size(px(100.), px(500.)), + ); + cx.simulate_click(point(px(95.), px(80.)), Modifiers::default()); + assert!(vertical.offset().y < px(0.)); + assert_eq!(vertical.offset().x, px(0.)); + } + + #[gpui::test] + fn horizontal_track_click_updates_horizontal_offset(cx: &mut TestAppContext) { + let (cx, horizontal) = harness( + cx, + ScrollbarAxis::Horizontal, + ScrollbarMode::Always, + size(px(500.), px(100.)), + ); + cx.simulate_click(point(px(80.), px(95.)), Modifiers::default()); + assert!(horizontal.offset().x < px(0.)); + assert_eq!(horizontal.offset().y, px(0.)); + } + + #[gpui::test] + fn no_overflow_has_no_interactive_track(cx: &mut TestAppContext) { + let (cx, handle) = harness( + cx, + ScrollbarAxis::Both, + ScrollbarMode::Always, + size(px(100.), px(100.)), + ); + cx.simulate_click(point(px(95.), px(80.)), Modifiers::default()); + assert_eq!(handle.offset(), Point::default()); + } + + #[gpui::test] + fn hidden_hover_scrollbar_ignores_track_click(cx: &mut TestAppContext) { + let (cx, handle) = harness( + cx, + ScrollbarAxis::Vertical, + ScrollbarMode::Hover, + size(px(100.), px(500.)), + ); + cx.simulate_click(point(px(95.), px(80.)), Modifiers::default()); + assert_eq!(handle.offset(), Point::default()); + } + + #[gpui::test] + fn hidden_hover_scrollbar_ignores_thumb_drag(cx: &mut TestAppContext) { + let (cx, handle) = harness( + cx, + ScrollbarAxis::Vertical, + ScrollbarMode::Hover, + size(px(100.), px(500.)), + ); + cx.simulate_mouse_down( + point(px(95.), px(20.)), + MouseButton::Left, + Modifiers::default(), + ); + cx.simulate_mouse_move( + point(px(95.), px(70.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(95.), px(70.)), + MouseButton::Left, + Modifiers::default(), + ); + + assert_eq!(handle.drag_starts.get(), 0); + assert_eq!(handle.drag_ends.get(), 0); + assert_eq!(handle.offset(), Point::default()); + } + + #[gpui::test] + fn hovering_reveals_scrollbar_for_track_interaction(cx: &mut TestAppContext) { + let (cx, handle) = harness( + cx, + ScrollbarAxis::Vertical, + ScrollbarMode::Hover, + size(px(100.), px(500.)), + ); + // Base ships no motion, so the reveal is immediate. + cx.simulate_mouse_move(point(px(95.), px(50.)), None, Modifiers::default()); + cx.run_until_parked(); + + cx.simulate_click(point(px(95.), px(80.)), Modifiers::default()); + assert!(handle.offset().y < px(0.)); + } + + #[gpui::test] + fn thumb_drag_notifies_handle_start_and_end(cx: &mut TestAppContext) { + let (cx, handle) = harness( + cx, + ScrollbarAxis::Vertical, + ScrollbarMode::Always, + size(px(100.), px(500.)), + ); + cx.simulate_mouse_down( + point(px(95.), px(20.)), + MouseButton::Left, + Modifiers::default(), + ); + cx.simulate_mouse_move( + point(px(95.), px(70.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(95.), px(70.)), + MouseButton::Left, + Modifiers::default(), + ); + + assert_eq!(handle.drag_starts.get(), 1); + assert_eq!(handle.drag_ends.get(), 1); + } +} diff --git a/vendor/gpui-base/src/select.rs b/vendor/gpui-base/src/select.rs new file mode 100644 index 00000000..4dbd4fca --- /dev/null +++ b/vendor/gpui-base/src/select.rs @@ -0,0 +1,395 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, + ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, +}; + +use crate::StyledExt as _; +use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; + +const CONTEXT: &str = "Select"; + +#[doc(hidden)] +pub fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + KeyBinding::new( + "secondary-enter", + Confirm { secondary: true }, + Some(CONTEXT), + ), + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + ]); +} + +type OpenChangeHandler = Rc; +type ActionHandler = Rc; + +/// An unstyled controlled select root. +/// +/// Applications own the trigger and popup presentation, the option collection, +/// and the selected value. This root owns combobox accessibility semantics, +/// keyboard opening and dismissal, and focus transfer between the trigger and +/// popup content. +/// +/// GPUI marks the active option on the option element itself rather than on the +/// container, so the application marks its highlighted option with +/// `aria_active_descendant()`; this root cannot do it on the caller's behalf. +#[derive(IntoElement)] +pub struct Select { + id: ElementId, + open: bool, + disabled: bool, + focus_handle: Option, + content_focus_handle: Option, + accessibility_label: Option, + style: StyleRefinement, + children: Vec, + on_open_change: Option, + key_context: &'static str, + on_dismiss: Option, + on_confirm: Option, +} + +impl Select { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + open: false, + disabled: false, + focus_handle: None, + content_focus_handle: None, + accessibility_label: None, + style: StyleRefinement::default(), + children: Vec::new(), + on_open_change: None, + key_context: CONTEXT, + on_dismiss: None, + on_confirm: None, + } + } + + /// Sets the application-controlled open state. + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + /// Prevents keyboard interaction and removes the trigger from tab traversal. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Supplies the focus handle for the select trigger. + pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self { + self.focus_handle = Some(focus_handle.clone()); + self + } + + /// Supplies the focus handle that receives keyboard navigation while open. + pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self { + self.content_focus_handle = Some(focus_handle.clone()); + self + } + + /// Sets the accessible name exposed by the controlled root. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Handles requests to update the controlled open state. + pub fn on_open_change( + mut self, + handler: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_change = Some(Rc::new(handler)); + self + } + + #[doc(hidden)] + pub fn key_context(mut self, key_context: &'static str) -> Self { + self.key_context = key_context; + self + } + + /// Handles a dismissal requested through the Cancel action. + /// + /// This runs before the controlled open state is asked to close, so a + /// caller that commits its pending value on dismissal can still read that + /// value here. + pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_dismiss = Some(Rc::new(handler)); + self + } + + /// Handles the Confirm action while the select is open. + /// + /// Confirming a closed select opens it instead, so this never runs for + /// that case. + pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_confirm = Some(Rc::new(handler)); + self + } +} + +impl Styled for Select { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Select { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Select { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let open = self.open; + let disabled = self.disabled; + let focus_handle = self.focus_handle; + let content_focus_handle = self.content_focus_handle; + let on_open_change = self.on_open_change; + let on_dismiss = self.on_dismiss; + let on_confirm = self.on_confirm; + + div() + .id(self.id) + .role(Role::ComboBox) + .aria_expanded(open) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .key_context(self.key_context) + .when_some( + focus_handle.clone().filter(|_| !disabled), + |this, handle| this.track_focus(&handle.tab_stop(true)), + ) + .on_action({ + let on_open_change = on_open_change.clone(); + let content_focus_handle = content_focus_handle.clone(); + move |_: &SelectUp, window, cx| { + if disabled { + cx.propagate(); + return; + } + + if !open { + if let Some(handler) = on_open_change.as_ref() { + handler(true, window, cx); + } + } + + if let Some(handle) = content_focus_handle.as_ref() { + handle.focus(window, cx); + } + cx.propagate(); + } + }) + .on_action({ + let on_open_change = on_open_change.clone(); + let content_focus_handle = content_focus_handle.clone(); + move |_: &SelectDown, window, cx| { + if disabled { + cx.propagate(); + return; + } + + if !open { + if let Some(handler) = on_open_change.as_ref() { + handler(true, window, cx); + } + } + + if let Some(handle) = content_focus_handle.as_ref() { + handle.focus(window, cx); + } + cx.propagate(); + } + }) + .on_action({ + let on_open_change = on_open_change.clone(); + move |_: &Confirm, window, cx| { + if disabled { + cx.propagate(); + return; + } + + cx.propagate(); + if open { + if let Some(handler) = on_confirm.as_ref() { + handler(window, cx); + } + } else if let Some(handler) = on_open_change.as_ref() { + handler(true, window, cx); + } + + if let Some(handle) = content_focus_handle.as_ref() { + handle.focus(window, cx); + } + } + }) + .on_action(move |_: &Cancel, window, cx| { + if !open { + cx.propagate(); + return; + } + + cx.stop_propagation(); + if let Some(handler) = on_dismiss.as_ref() { + handler(window, cx); + } + if let Some(handler) = on_open_change.as_ref() { + handler(false, window, cx); + } + if let Some(handle) = focus_handle.as_ref() { + handle.focus(window, cx); + } + }) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Focusable, Render, TestAppContext, VisualTestContext, px}; + use std::sync::{Arc, Mutex}; + + struct SelectHarness { + open: bool, + disabled: bool, + focus_handle: FocusHandle, + content_focus_handle: FocusHandle, + changes: Arc>>, + } + + impl SelectHarness { + fn new(disabled: bool, cx: &mut Context) -> Self { + Self { + open: false, + disabled, + focus_handle: cx.focus_handle(), + content_focus_handle: cx.focus_handle(), + changes: Arc::new(Mutex::new(Vec::new())), + } + } + } + + impl Focusable for SelectHarness { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for SelectHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = cx.entity(); + let changes = self.changes.clone(); + + Select::new("select") + .open(self.open) + .disabled(self.disabled) + .focus_handle(&self.focus_handle) + .content_focus_handle(&self.content_focus_handle) + .on_open_change(move |open, _, cx| { + changes.lock().unwrap().push(open); + state.update(cx, |state, cx| { + state.open = open; + cx.notify(); + }); + }) + .child(div().track_focus(&self.content_focus_handle).size(px(20.))) + } + } + + fn harness( + cx: &mut TestAppContext, + disabled: bool, + ) -> (&mut VisualTestContext, gpui::Entity) { + cx.update(crate::init); + let (state, cx) = cx.add_window_view(move |_, cx| SelectHarness::new(disabled, cx)); + cx.update(|window, cx| { + state.focus_handle(cx).focus(window, cx); + window.draw(cx).clear(cx); + }); + (cx, state) + } + + #[gpui::test] + fn arrows_open_and_transfer_focus_to_content(cx: &mut TestAppContext) { + let (cx, state) = harness(cx, false); + + cx.simulate_keystrokes("down"); + cx.update(|window, cx| { + assert!(state.read(cx).open); + assert!(state.read(cx).content_focus_handle.is_focused(window)); + }); + assert_eq!( + &*state + .read_with(cx, |state, _| state.changes.clone()) + .lock() + .unwrap(), + &[true] + ); + } + + #[gpui::test] + fn confirm_opens_a_closed_select(cx: &mut TestAppContext) { + let (cx, state) = harness(cx, false); + + cx.simulate_keystrokes("enter"); + cx.update(|window, cx| { + assert!(state.read(cx).open); + assert!(state.read(cx).content_focus_handle.is_focused(window)); + }); + } + + #[gpui::test] + fn escape_closes_and_restores_trigger_focus(cx: &mut TestAppContext) { + let (cx, state) = harness(cx, false); + + cx.simulate_keystrokes("down escape"); + cx.update(|window, cx| { + assert!(!state.read(cx).open); + assert!(state.read(cx).focus_handle.is_focused(window)); + }); + assert_eq!( + &*state + .read_with(cx, |state, _| state.changes.clone()) + .lock() + .unwrap(), + &[true, false] + ); + } + + #[gpui::test] + fn disabled_select_is_not_keyboard_interactive(cx: &mut TestAppContext) { + let (cx, state) = harness(cx, true); + + cx.simulate_keystrokes("down enter"); + assert!(!state.read_with(cx, |state, _| state.open)); + assert!( + state + .read_with(cx, |state, _| state.changes.clone()) + .lock() + .unwrap() + .is_empty() + ); + } + + #[test] + fn accepts_application_owned_accessible_label() { + let _ = Select::new("a11y-select") + .open(true) + .accessibility_label("Country"); + } +} diff --git a/vendor/gpui-base/src/sheet.rs b/vendor/gpui-base/src/sheet.rs new file mode 100644 index 00000000..a99d854f --- /dev/null +++ b/vendor/gpui-base/src/sheet.rs @@ -0,0 +1,268 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, RenderOnce, StyleRefinement, Styled, Window, anchored, div, + point, prelude::FluentBuilder as _, px, +}; + +use crate::{FocusTrapElement as _, StyledExt as _, actions::Cancel}; + +const CONTEXT: &str = "Sheet"; + +type CloseHandler = Rc; +type CloseRequest = Rc; + +fn close(request: &CloseRequest, notify: &CloseHandler, window: &mut Window, cx: &mut App) { + let event = ClickEvent::default(); + request(window, cx); + notify(&event, window, cx); +} + +pub fn init(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]); +} + +/// An unstyled modal sheet host. +/// +/// Applications provide the overlay and surface. The host owns focus trapping, +/// Escape handling, overlay dismissal, and close callback ordering. +#[derive(IntoElement)] +pub struct Sheet { + base: gpui::Div, + style: StyleRefinement, + focus: FocusHandle, + overlay_interactive: bool, + overlay_closable: bool, + dismiss_before_y: Option, + overlay: Option, + surface: Option, + request_close: CloseRequest, + on_close: CloseHandler, +} + +impl Sheet { + pub fn new(cx: &mut App) -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + focus: cx.focus_handle(), + overlay_interactive: true, + overlay_closable: true, + dismiss_before_y: None, + overlay: None, + surface: None, + request_close: Rc::new(|_, _| {}), + on_close: Rc::new(|_, _, _| {}), + } + } + + pub fn overlay(mut self, overlay: impl IntoElement) -> Self { + self.overlay = Some(overlay.into_any_element()); + self + } + + pub fn surface(mut self, surface: impl IntoElement) -> Self { + self.surface = Some(surface.into_any_element()); + self + } + + pub fn overlay_closable(mut self, closable: bool) -> Self { + self.overlay_closable = closable; + self + } + + #[doc(hidden)] + pub fn overlay_interactive(mut self, interactive: bool) -> Self { + self.overlay_interactive = interactive; + self + } + + pub fn on_close( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_close = Rc::new(handler); + self + } + + #[doc(hidden)] + pub fn focus_handle(mut self, focus: FocusHandle) -> Self { + self.focus = focus; + self + } + + #[doc(hidden)] + pub fn dismiss_before_y(mut self, y: Pixels) -> Self { + self.dismiss_before_y = Some(y); + self + } + + #[doc(hidden)] + pub fn request_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.request_close = Rc::new(handler); + self + } +} + +impl Styled for Sheet { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Sheet { + fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { + let viewport = window.viewport_size(); + let request_close = self.request_close; + let on_close = self.on_close; + let escape_request = request_close.clone(); + let escape_notify = on_close.clone(); + + anchored().position(point(px(0.), px(0.))).child( + self.base + .id("sheet-host") + .absolute() + .top_0() + .left_0() + .w(viewport.width) + .h(viewport.height) + .key_context(CONTEXT) + .track_focus(&self.focus) + .focus_trap("sheet", &self.focus) + .on_action(move |_: &Cancel, window, cx| { + cx.propagate(); + close(&escape_request, &escape_notify, window, cx); + }) + .when_some(self.overlay, |this, overlay| { + let request_close = request_close.clone(); + let on_close = on_close.clone(); + let dismiss_before_y = self.dismiss_before_y; + let overlay_interactive = self.overlay_interactive; + let overlay_closable = self.overlay_closable; + this.child(overlay).child(div().absolute().inset_0().when( + overlay_interactive, + |this| { + this.on_any_mouse_down(move |event, window, cx| { + if !overlay_interactive { + return; + } + if dismiss_before_y.is_some_and(|top| event.position.y < top) { + return; + } + cx.stop_propagation(); + if overlay_closable && event.button == MouseButton::Left { + close(&request_close, &on_close, window, cx); + } + }) + }, + )) + }) + .children(self.surface) + .refine_style(&self.style), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, point, px}; + use std::{cell::RefCell, rc::Rc}; + + struct Harness { + closable: bool, + cutoff: Option, + focus: FocusHandle, + events: Rc>>, + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let requested = self.events.clone(); + let closed = self.events.clone(); + Sheet::new(cx) + .focus_handle(self.focus.clone()) + .overlay_closable(self.closable) + .request_close(move |_, _| requested.borrow_mut().push("request")) + .on_close(move |_, _, _| closed.borrow_mut().push("closed")) + .overlay(div().absolute().inset_0().occlude()) + .surface( + div() + .absolute() + .right_0() + .top_0() + .h_full() + .w(px(80.)) + .occlude(), + ) + .when_some(self.cutoff, |this, cutoff| this.dismiss_before_y(cutoff)) + } + } + + fn harness( + cx: &mut gpui::TestAppContext, + closable: bool, + ) -> (&mut gpui::VisualTestContext, Rc>>) { + cx.update(crate::init); + let focus = cx.update(|cx| cx.focus_handle()); + let events = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let events = events.clone(); + let focus = focus.clone(); + move |_, _| Harness { + closable, + cutoff: None, + focus, + events, + } + }); + cx.update(|window, cx| focus.focus(window, cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, events) + } + + #[gpui::test] + fn overlay_close_requests_then_notifies(cx: &mut gpui::TestAppContext) { + let (cx, events) = harness(cx, true); + cx.simulate_click(point(px(20.), px(20.)), Default::default()); + assert_eq!(&*events.borrow(), &["request", "closed"]); + } + + #[gpui::test] + fn non_closable_overlay_does_not_request_close(cx: &mut gpui::TestAppContext) { + let (cx, events) = harness(cx, false); + cx.simulate_click(point(px(20.), px(20.)), Default::default()); + assert!(events.borrow().is_empty()); + } + + #[gpui::test] + fn escape_uses_the_same_close_order_and_registers_focus_trap(cx: &mut gpui::TestAppContext) { + let (cx, events) = harness(cx, true); + assert!(cx.update(|window, cx| crate::active_focus_trap(window, cx).is_some())); + cx.dispatch_action(Cancel); + assert_eq!(&*events.borrow(), &["request", "closed"]); + } + + #[gpui::test] + fn pointer_above_the_dismiss_cutoff_is_ignored(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let focus = cx.update(|cx| cx.focus_handle()); + let events = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let focus = focus.clone(); + let events = events.clone(); + move |_, _| Harness { + closable: true, + cutoff: Some(px(50.)), + focus, + events, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(20.), px(20.)), Default::default()); + assert!(events.borrow().is_empty()); + cx.simulate_click(point(px(20.), px(80.)), Default::default()); + assert_eq!(&*events.borrow(), &["request", "closed"]); + } +} diff --git a/vendor/gpui-base/src/slider.rs b/vendor/gpui-base/src/slider.rs new file mode 100644 index 00000000..1e5b28cd --- /dev/null +++ b/vendor/gpui-base/src/slider.rs @@ -0,0 +1,835 @@ +use std::ops::Range; + +use gpui::{ + AccessibleAction, Along, AnyElement, App, AppContext as _, Axis, Bounds, Context, Div, + DragMoveEvent, Empty, Entity, EntityId, EventEmitter, InteractiveElement, IntoElement, + MouseButton, MouseDownEvent, Orientation, ParentElement, Pixels, Point, Render, RenderOnce, + Role, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, px, +}; + +use crate::{element_ext::ElementExt, geometry::AxisExt}; + +/// Events emitted by the [`SliderState`]. +pub enum SliderEvent { + /// Emitted continuously while the slider value is being changed by the user. + Change(SliderValue), + /// Emitted once when the user releases the slider after a drag or click. + Release(SliderValue), +} + +/// The value of the slider, can be a single value or a range of values. +/// +/// - Can from a f32 value, which will be treated as a single value. +/// - Or from a (f32, f32) tuple, which will be treated as a range of values. +/// +/// The default value is `SliderValue::Single(0.0)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SliderValue { + Single(f32), + Range(f32, f32), +} + +impl std::fmt::Display for SliderValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SliderValue::Single(value) => write!(f, "{}", value), + SliderValue::Range(start, end) => write!(f, "{}..{}", start, end), + } + } +} + +impl From for SliderValue { + fn from(value: f32) -> Self { + SliderValue::Single(value) + } +} + +impl From<(f32, f32)> for SliderValue { + fn from(value: (f32, f32)) -> Self { + SliderValue::Range(value.0, value.1) + } +} + +impl From> for SliderValue { + fn from(value: Range) -> Self { + SliderValue::Range(value.start, value.end) + } +} + +impl Default for SliderValue { + fn default() -> Self { + SliderValue::Single(0.) + } +} + +impl SliderValue { + /// Clamp the value to the given range. + pub fn clamp(self, min: f32, max: f32) -> Self { + match self { + SliderValue::Single(value) => SliderValue::Single(value.clamp(min, max)), + SliderValue::Range(start, end) => { + SliderValue::Range(start.clamp(min, max), end.clamp(min, max)) + } + } + } + + /// Check if the value is a single value. + #[inline] + pub fn is_single(&self) -> bool { + matches!(self, SliderValue::Single(_)) + } + + /// Check if the value is a range of values. + #[inline] + pub fn is_range(&self) -> bool { + matches!(self, SliderValue::Range(_, _)) + } + + /// Get the start value. + pub fn start(&self) -> f32 { + match self { + SliderValue::Single(value) => *value, + SliderValue::Range(start, _) => *start, + } + } + + /// Get the end value. + pub fn end(&self) -> f32 { + match self { + SliderValue::Single(value) => *value, + SliderValue::Range(_, end) => *end, + } + } + + fn set_start(&mut self, value: f32) { + if let SliderValue::Range(_, end) = self { + *self = SliderValue::Range(value.min(*end), *end); + } else { + *self = SliderValue::Single(value); + } + } + + fn set_end(&mut self, value: f32) { + if let SliderValue::Range(start, _) = self { + *self = SliderValue::Range(*start, value.max(*start)); + } else { + *self = SliderValue::Single(value); + } + } +} + +/// The scale mode of the slider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SliderScale { + /// Linear scale where values change uniformly across the slider range. + /// This is the default mode. + #[default] + Linear, + /// Logarithmic scale where the distance between values increases exponentially. + /// + /// This is useful for parameters that have a large range of values where smaller + /// changes are more significant at lower values. Common examples include: + /// + /// - Volume controls (human hearing perception is logarithmic) + /// - Frequency controls (musical notes follow a logarithmic scale) + /// - Zoom levels + /// - Any parameter where you want finer control at lower values + /// + /// # For example + /// + /// ``` + /// use gpui_base::slider::{SliderScale, SliderState}; + /// + /// let slider = SliderState::new() + /// .min(1.0) // Must be > 0 for logarithmic scale + /// .max(1000.0) + /// .scale(SliderScale::Logarithmic); + /// ``` + /// + /// - Moving the slider 1/3 of the way will yield ~10 + /// - Moving it 2/3 of the way will yield ~100 + /// - The full range covers 3 orders of magnitude evenly + Logarithmic, +} + +impl SliderScale { + #[inline] + pub fn is_linear(&self) -> bool { + matches!(self, SliderScale::Linear) + } + + #[inline] + pub fn is_logarithmic(&self) -> bool { + matches!(self, SliderScale::Logarithmic) + } +} + +/// State of the [`Slider`]. +pub struct SliderState { + min: f32, + max: f32, + step: f32, + value: SliderValue, + /// When is single value mode, only `end` is used, the start is always 0.0. + percentage: Range, + /// The bounds of the slider after rendered. + bounds: Bounds, + scale: SliderScale, + /// Tracks whether the user is currently interacting with the slider so we + /// only emit [`SliderEvent::Release`] after a real press/drag. + dragging: bool, +} + +impl SliderState { + /// Create a new [`SliderState`]. + pub fn new() -> Self { + Self { + min: 0.0, + max: 100.0, + step: 1.0, + value: SliderValue::default(), + percentage: (0.0..0.0), + bounds: Bounds::default(), + scale: SliderScale::default(), + dragging: false, + } + } + + /// Set the minimum value of the slider, default: 0.0 + pub fn min(mut self, min: f32) -> Self { + if self.scale.is_logarithmic() { + assert!( + min > 0.0, + "`min` must be greater than 0 for SliderScale::Logarithmic" + ); + assert!( + min < self.max, + "`min` must be less than `max` for Logarithmic scale" + ); + } + self.min = min; + self.update_thumb_pos(); + self + } + + /// Set the maximum value of the slider, default: 100.0 + pub fn max(mut self, max: f32) -> Self { + if self.scale.is_logarithmic() { + assert!( + max > self.min, + "`max` must be greater than `min` for Logarithmic scale" + ); + } + self.max = max; + self.update_thumb_pos(); + self + } + + /// Set the step value of the slider, default: 1.0 + pub fn step(mut self, step: f32) -> Self { + self.step = step; + self + } + + /// Set the scale of the slider, default: [`SliderScale::Linear`]. + pub fn scale(mut self, scale: SliderScale) -> Self { + if scale.is_logarithmic() { + assert!( + self.min > 0.0, + "`min` must be greater than 0 for Logarithmic scale" + ); + assert!( + self.max > self.min, + "`max` must be greater than `min` for Logarithmic scale" + ); + } + self.scale = scale; + self.update_thumb_pos(); + self + } + + /// Set the default value of the slider, default: 0.0 + pub fn default_value(mut self, value: impl Into) -> Self { + self.value = value.into(); + self.update_thumb_pos(); + self + } + + /// Set the value of the slider. + pub fn set_value( + &mut self, + value: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.value = value.into(); + self.update_thumb_pos(); + cx.notify(); + } + + /// Get the value of the slider. + pub fn value(&self) -> SliderValue { + self.value + } + + /// Get the minimum value. + pub fn min_value(&self) -> f32 { + self.min + } + + /// Get the maximum value. + pub fn max_value(&self) -> f32 { + self.max + } + + /// Get the step value. + pub fn step_value(&self) -> f32 { + self.step + } + + /// Converts a value between 0.0 and 1.0 to a value between the minimum and maximum value, + /// depending on the chosen scale. + fn percentage_to_value(&self, percentage: f32) -> f32 { + match self.scale { + SliderScale::Linear => self.min + (self.max - self.min) * percentage, + SliderScale::Logarithmic => { + // when percentage is 0, this simplifies to (max/min)^0 * min = 1 * min = min + // when percentage is 1, this simplifies to (max/min)^1 * min = (max*min)/min = max + // we clamp just to make sure we don't have issue with floating point precision + let base = self.max / self.min; + (base.powf(percentage) * self.min).clamp(self.min, self.max) + } + } + } + + /// Converts a value between the minimum and maximum value to a value between 0.0 and 1.0, + /// depending on the chosen scale. + fn value_to_percentage(&self, value: f32) -> f32 { + match self.scale { + SliderScale::Linear => { + let range = self.max - self.min; + if range <= 0.0 { + 0.0 + } else { + (value - self.min) / range + } + } + SliderScale::Logarithmic => { + let base = self.max / self.min; + (value / self.min).log(base).clamp(0.0, 1.0) + } + } + } + + fn update_thumb_pos(&mut self) { + match self.value { + SliderValue::Single(value) => { + let percentage = self.value_to_percentage(value.clamp(self.min, self.max)); + self.percentage = 0.0..percentage; + } + SliderValue::Range(start, end) => { + let clamped_start = start.clamp(self.min, self.max); + let clamped_end = end.clamp(self.min, self.max); + self.percentage = + self.value_to_percentage(clamped_start)..self.value_to_percentage(clamped_end); + } + } + } + + /// Update value by mouse position + #[doc(hidden)] + pub fn update_value_by_position( + &mut self, + axis: Axis, + position: Point, + is_start: bool, + _: &mut Window, + cx: &mut Context, + ) { + self.dragging = true; + let bounds = self.bounds; + let step = self.step; + + let inner_pos = if axis.is_horizontal() { + position.x - bounds.left() + } else { + bounds.bottom() - position.y + }; + let total_size = bounds.size.along(axis); + let percentage = inner_pos.clamp(px(0.), total_size) / total_size; + + let percentage = if is_start { + percentage.clamp(0.0, self.percentage.end) + } else { + percentage.clamp(self.percentage.start, 1.0) + }; + + let value = self.percentage_to_value(percentage); + let value = (value / step).round() * step; + + if is_start { + self.percentage.start = percentage; + self.value.set_start(value); + } else { + self.percentage.end = percentage; + self.value.set_end(value); + } + cx.emit(SliderEvent::Change(self.value)); + cx.notify(); + } + + /// Emit [`SliderEvent::Release`] if the user was actively interacting + /// with the slider. Called on mouse-up both inside and outside the slider. + #[doc(hidden)] + pub fn handle_release(&mut self, cx: &mut Context) { + if !self.dragging { + return; + } + self.dragging = false; + cx.emit(SliderEvent::Release(self.value)); + } +} + +#[derive(Clone)] +struct DragThumb((EntityId, bool)); + +impl Render for DragThumb { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +#[derive(Clone)] +struct DragSlider(EntityId); + +impl Render for DragSlider { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +/// An unstyled slider behavior root. +/// +/// Applications provide the track, range, and thumb presentation as children. +#[derive(IntoElement)] +pub struct Slider { + state: Entity, + axis: Axis, + disabled: bool, + base: Div, + children: Vec, +} + +impl Slider { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + axis: Axis::Horizontal, + disabled: false, + base: div(), + children: Vec::new(), + } + } + + pub fn horizontal(mut self) -> Self { + self.axis = Axis::Horizontal; + self + } + + pub fn vertical(mut self) -> Self { + self.axis = Axis::Vertical; + self + } + + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl ParentElement for Slider { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for Slider { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for Slider { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let axis = self.axis; + let entity_id = self.state.entity_id(); + let state = self.state.read(cx); + let slider_state = self.state.clone(); + + self.base + .id(("slider", entity_id)) + .role(Role::Slider) + .aria_numeric_value(state.value().end() as f64) + .aria_min_numeric_value(state.min_value() as f64) + .aria_max_numeric_value(state.max_value() as f64) + .aria_numeric_value_step(state.step_value() as f64) + .aria_orientation(if axis.is_vertical() { + Orientation::Vertical + } else { + Orientation::Horizontal + }) + .on_a11y_action(AccessibleAction::Increment, { + let state = slider_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + let value = + (state.value().end() + state.step_value()).min(state.max_value()); + state.set_value(value, window, cx); + }); + } + }) + .on_a11y_action(AccessibleAction::Decrement, { + let state = slider_state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + let value = + (state.value().end() - state.step_value()).max(state.min_value()); + state.set_value(value, window, cx); + }); + } + }) + .when(!self.disabled, |this| { + this.on_mouse_up( + MouseButton::Left, + window.listener_for(&self.state, |state, _, _, cx| state.handle_release(cx)), + ) + .on_mouse_up_out( + MouseButton::Left, + window.listener_for(&self.state, |state, _, _, cx| state.handle_release(cx)), + ) + }) + .children(self.children) + } +} + +/// An unstyled track that records the geometry used to map pointer positions. +#[derive(IntoElement)] +pub struct SliderTrack { + state: Entity, + axis: Axis, + disabled: bool, + base: Div, + children: Vec, +} + +impl SliderTrack { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + axis: Axis::Horizontal, + disabled: false, + base: div(), + children: Vec::new(), + } + } + + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl ParentElement for SliderTrack { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for SliderTrack { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl InteractiveElement for SliderTrack { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} + +impl RenderOnce for SliderTrack { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let axis = self.axis; + let entity_id = self.state.entity_id(); + let state = self.state.read(cx); + let is_range = state.value().is_range(); + let percentage = state.percentage(); + self.base + .id("slider-bar-container") + .children(self.children) + .when(!self.disabled, |this| { + this.on_mouse_down( + MouseButton::Left, + window.listener_for( + &self.state, + move |state, event: &MouseDownEvent, window, cx| { + let is_start = if is_range { + let size = state.bounds().size.along(axis); + let position = if axis.is_horizontal() { + event.position.x - state.bounds().left() + } else { + state.bounds().bottom() - event.position.y + }; + let center = ((percentage.end - percentage.start) / 2. + + percentage.start) + * size; + position < center + } else { + false + }; + state.update_value_by_position( + axis, + event.position, + is_start, + window, + cx, + ); + }, + ), + ) + .when(!is_range, |this| { + this.on_drag(DragSlider(entity_id), |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_drag_move(window.listener_for( + &self.state, + move |state, event: &DragMoveEvent, window, cx| { + let DragSlider(id) = event.drag(cx); + if *id == entity_id { + state.update_value_by_position( + axis, + event.event.position, + false, + window, + cx, + ); + } + }, + )) + }) + }) + } +} + +/// An unstyled slider indicator that records the value-mapping bounds. +#[derive(IntoElement)] +pub struct SliderIndicator { + state: Entity, + base: Div, + children: Vec, +} + +impl SliderIndicator { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + base: div(), + children: Vec::new(), + } + } +} + +impl ParentElement for SliderIndicator { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for SliderIndicator { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl InteractiveElement for SliderIndicator { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for SliderIndicator {} + +impl RenderOnce for SliderIndicator { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .id("slider-bar") + .children(self.children) + .on_prepaint({ + let state = self.state; + move |bounds, _, cx| state.update(cx, |state, _| state.set_bounds(bounds)) + }) + } +} + +/// An unstyled draggable slider thumb. +#[derive(IntoElement)] +pub struct SliderThumb { + state: Entity, + axis: Axis, + start: bool, + disabled: bool, + base: Div, + children: Vec, +} + +impl SliderThumb { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + axis: Axis::Horizontal, + start: false, + disabled: false, + base: div(), + children: Vec::new(), + } + } + + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + pub fn start(mut self, start: bool) -> Self { + self.start = start; + self + } + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl ParentElement for SliderThumb { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for SliderThumb { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl InteractiveElement for SliderThumb { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for SliderThumb {} + +impl RenderOnce for SliderThumb { + fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { + let entity_id = self.state.entity_id(); + let axis = self.axis; + let start = self.start; + self.base + .id(("slider-thumb", start as u32)) + .children(self.children) + .when(!self.disabled, |this| { + this.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_drag(DragThumb((entity_id, start)), |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_drag_move(window.listener_for( + &self.state, + move |state, event: &DragMoveEvent, window, cx| { + let DragThumb((id, start)) = event.drag(cx); + if *id == entity_id { + state.update_value_by_position( + axis, + event.event.position, + *start, + window, + cx, + ); + } + }, + )) + }) + } +} + +impl EventEmitter for SliderState {} + +impl SliderState { + #[doc(hidden)] + pub fn percentage(&self) -> Range { + self.percentage.clone() + } + + #[doc(hidden)] + pub fn bounds(&self) -> Bounds { + self.bounds + } + + #[doc(hidden)] + pub fn set_bounds(&mut self, bounds: Bounds) { + self.bounds = bounds; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_value_conversions_and_clamping_are_preserved() { + assert_eq!(SliderValue::from(5.), SliderValue::Single(5.)); + assert_eq!(SliderValue::from((2., 8.)), SliderValue::Range(2., 8.)); + assert_eq!(SliderValue::from(2.0..8.0), SliderValue::Range(2., 8.)); + assert_eq!( + SliderValue::Range(-1., 12.).clamp(0., 10.), + SliderValue::Range(0., 10.) + ); + } + + #[test] + fn legacy_linear_state_keeps_percentage_and_range_ordering() { + let state = SliderState::new() + .min(0.) + .max(200.) + .default_value((50., 150.)); + assert_eq!(state.value(), SliderValue::Range(50., 150.)); + assert_eq!(state.percentage(), 0.25..0.75); + } + + #[test] + fn legacy_logarithmic_state_keeps_mapping() { + let state = SliderState::new() + .min(1.) + .max(1000.) + .scale(SliderScale::Logarithmic) + .default_value(10.); + let percentage = state.percentage().end; + assert!((percentage - (1. / 3.)).abs() < 0.0001); + } + + #[test] + #[should_panic(expected = "`min` must be greater than 0")] + fn legacy_logarithmic_validation_is_preserved() { + let _ = SliderState::new().scale(SliderScale::Logarithmic); + } +} diff --git a/vendor/gpui-base/src/state_style.rs b/vendor/gpui-base/src/state_style.rs new file mode 100644 index 00000000..055cf859 --- /dev/null +++ b/vendor/gpui-base/src/state_style.rs @@ -0,0 +1,103 @@ +use gpui::{Refineable as _, StyleRefinement, Styled, prelude::FluentBuilder}; + +/// A semantic-state style builder. +/// +/// Visual modifiers come from GPUI's [`Styled`] trait. Unlike a bare +/// [`StyleRefinement`], this wrapper also supports [`FluentBuilder`] helpers +/// such as `when`, `when_some`, and `when_none`. +#[derive(Default)] +pub struct StateStyle { + refinement: StyleRefinement, +} + +impl StateStyle { + pub(crate) fn into_refinement(self) -> StyleRefinement { + self.refinement + } +} + +impl Styled for StateStyle { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.refinement + } +} + +impl FluentBuilder for StateStyle {} + +/// Resolves the final style of a control from its instance style and the +/// semantic-state styles that are currently active. +/// +/// The layering order is fixed for every Base control: +/// +/// 1. the instance style supplied through GPUI's [`Styled`] builder chain, +/// 2. value states such as `checked`, `pressed`, `selected`, or `focused`, +/// 3. `disabled`, which is always resolved last. +/// +/// Semantic states therefore override the builder chain, matching how GPUI +/// layers `hover`, `active`, and `focus_visible` on top of an element's base +/// style. Callers pass their active states in the order above; inactive states +/// are simply omitted from the iterator. +/// +/// Every control routes through this function so the ordering cannot drift +/// apart between controls again. +pub(crate) fn resolve_style<'a>( + instance: &StyleRefinement, + active_states: impl IntoIterator, +) -> StyleRefinement { + let mut style = StyleRefinement::default(); + style.refine(instance); + for state in active_states { + style.refine(state); + } + style +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(build: impl FnOnce(StateStyle) -> StateStyle) -> StyleRefinement { + build(StateStyle::default()).into_refinement() + } + + #[test] + fn instance_style_is_the_baseline_when_no_state_is_active() { + let instance = state(|style| style.opacity(0.9)); + + let resolved = resolve_style(&instance, []); + + assert_eq!(resolved.opacity, Some(0.9)); + } + + #[test] + fn an_active_state_overrides_the_instance_style() { + let instance = state(|style| style.opacity(0.9)); + let checked = state(|style| style.opacity(0.8)); + + let resolved = resolve_style(&instance, [&checked]); + + assert_eq!(resolved.opacity, Some(0.8)); + } + + #[test] + fn later_states_override_earlier_states() { + let instance = state(|style| style.opacity(0.9)); + let checked = state(|style| style.opacity(0.8)); + let disabled = state(|style| style.opacity(0.5)); + + let resolved = resolve_style(&instance, [&checked, &disabled]); + + assert_eq!(resolved.opacity, Some(0.5)); + } + + #[test] + fn states_only_override_the_fields_they_set() { + let instance = state(|style| style.opacity(0.9).border_1()); + let disabled = state(|style| style.opacity(0.5)); + + let resolved = resolve_style(&instance, [&disabled]); + + assert_eq!(resolved.opacity, Some(0.5)); + assert_eq!(instance.border_widths, resolved.border_widths); + } +} diff --git a/vendor/gpui-base/src/styled.rs b/vendor/gpui-base/src/styled.rs new file mode 100644 index 00000000..ed4b4b49 --- /dev/null +++ b/vendor/gpui-base/src/styled.rs @@ -0,0 +1,181 @@ +use gpui::{ + App, BoxShadow, Corners, DefiniteLength, Div, Edges, FocusHandle, Hsla, Pixels, + Refineable as _, Role, StyleRefinement, Styled, Window, div, hsla, point, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum RoleOverride { + #[default] + Implicit, + Presentational, + Role(Role), +} + +impl RoleOverride { + pub fn resolve(self, default: impl FnOnce() -> Role) -> Option { + match self { + Self::Implicit => Some(default()), + Self::Presentational => None, + Self::Role(role) => Some(role), + } + } +} +impl From for RoleOverride { + fn from(role: Role) -> Self { + Self::Role(role) + } +} +impl From> for RoleOverride { + fn from(role: Option) -> Self { + role.map_or(Self::Presentational, Self::Role) + } +} + +pub fn h_flex() -> Div { + div().h_flex() +} +pub fn v_flex() -> Div { + div().v_flex() +} + +pub fn box_shadow( + x: impl Into, + y: impl Into, + blur: impl Into, + spread: impl Into, + color: Hsla, +) -> BoxShadow { + BoxShadow { + offset: point(x.into(), y.into()), + blur_radius: blur.into(), + spread_radius: spread.into(), + inset: false, + color, + } +} + +macro_rules! font_weight { + ($method:ident, $weight:ident) => { + fn $method(self) -> Self { + self.font_weight(gpui::FontWeight::$weight) + } + }; +} + +#[cfg_attr( + any(feature = "inspector", debug_assertions), + gpui_macros::derive_inspector_reflection +)] +pub trait StyledExt: Styled + Sized { + fn refine_style(mut self, style: &StyleRefinement) -> Self { + self.style().refine(style); + self + } + + fn h_flex(self) -> Self { + self.flex().flex_row().items_center() + } + + fn v_flex(self) -> Self { + self.flex().flex_col() + } + + fn paddings(self, paddings: impl Into>) -> Self + where + L: Into + Clone + Default + std::fmt::Debug + PartialEq, + { + let paddings = paddings.into(); + self.pt(paddings.top.into()) + .pb(paddings.bottom.into()) + .pl(paddings.left.into()) + .pr(paddings.right.into()) + } + + fn margins(self, margins: impl Into>) -> Self + where + L: Into + Clone + Default + std::fmt::Debug + PartialEq, + { + let margins = margins.into(); + self.mt(margins.top.into()) + .mb(margins.bottom.into()) + .ml(margins.left.into()) + .mr(margins.right.into()) + } + + fn debug_red(self) -> Self { + if cfg!(debug_assertions) { + self.border_1().border_color(hsl(0., 72.2, 50.6)) + } else { + self + } + } + + fn debug_blue(self) -> Self { + if cfg!(debug_assertions) { + self.border_1().border_color(hsl(217.2, 91.2, 59.8)) + } else { + self + } + } + + fn debug_yellow(self) -> Self { + if cfg!(debug_assertions) { + self.border_1().border_color(hsl(47.9, 95.8, 53.1)) + } else { + self + } + } + + fn debug_green(self) -> Self { + if cfg!(debug_assertions) { + self.border_1().border_color(hsl(142.1, 70.6, 45.3)) + } else { + self + } + } + + fn debug_pink(self) -> Self { + if cfg!(debug_assertions) { + self.border_1().border_color(hsl(330.4, 81.2, 60.4)) + } else { + self + } + } + + fn debug_focused(self, focus_handle: &FocusHandle, window: &Window, cx: &App) -> Self { + if cfg!(debug_assertions) && focus_handle.contains_focused(window, cx) { + self.debug_blue() + } else { + self + } + } + + font_weight!(font_thin, THIN); + font_weight!(font_extralight, EXTRA_LIGHT); + font_weight!(font_light, LIGHT); + font_weight!(font_normal, NORMAL); + font_weight!(font_medium, MEDIUM); + font_weight!(font_semibold, SEMIBOLD); + font_weight!(font_bold, BOLD); + font_weight!(font_extrabold, EXTRA_BOLD); + font_weight!(font_black, BLACK); + + fn corner_radii(self, radius: Corners) -> Self { + self.rounded_tl(radius.top_left) + .rounded_tr(radius.top_right) + .rounded_bl(radius.bottom_left) + .rounded_br(radius.bottom_right) + } +} + +impl StyledExt for E {} + +#[cfg(any(feature = "inspector", debug_assertions))] +pub fn styled_ext_reflection_methods() +-> Vec> { + styled_ext_reflection::methods::() +} + +fn hsl(hue: f32, saturation: f32, lightness: f32) -> Hsla { + hsla(hue / 360., saturation / 100., lightness / 100., 1.) +} diff --git a/vendor/gpui-base/src/switch.rs b/vendor/gpui-base/src/switch.rs new file mode 100644 index 00000000..ac3a25ff --- /dev/null +++ b/vendor/gpui-base/src/switch.rs @@ -0,0 +1,728 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString, + Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{StateStyle, StyledExt as _}; + +type ChangeHandler = Rc; + +/// An unstyled binary control that owns switch interaction and semantics. +/// +/// The checked value is controlled by the application. Activation reports the +/// next value through [`Switch::on_change`]; the application must render that +/// value back through [`Switch::checked`]. Children and all visual states remain +/// application-owned. +#[derive(IntoElement)] +pub struct Switch { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + semantic_styles: SwitchStyles, + checked: bool, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_change: Option, + accessibility_label: Option, + tab_index: isize, + tab_stop: bool, +} + +/// Semantic root styles supported by [`Switch`]. +#[derive(Default)] +pub struct SwitchStyles { + checked: StyleRefinement, + disabled: StyleRefinement, +} + +impl SwitchStyles { + pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.checked + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +/// An unstyled switch track with typed checked and disabled state projection. +#[derive(IntoElement)] +pub struct SwitchTrack { + base: Stateful
, + style: StyleRefinement, + semantic_styles: SwitchTrackStyles, + checked: bool, + disabled: bool, + children: SmallVec<[AnyElement; 1]>, +} + +/// Semantic styles supported by [`SwitchTrack`]. +#[derive(Default)] +pub struct SwitchTrackStyles { + checked: StyleRefinement, + disabled: StyleRefinement, +} + +impl SwitchTrackStyles { + pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.checked + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl SwitchTrack { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + semantic_styles: SwitchTrackStyles::default(), + checked: false, + disabled: false, + children: SmallVec::new(), + } + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn styles(mut self, build: impl FnOnce(SwitchTrackStyles) -> SwitchTrackStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.checked.then_some(&self.semantic_styles.checked), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +impl Styled for SwitchTrack { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for SwitchTrack { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for SwitchTrack { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for SwitchTrack {} + +impl RenderOnce for SwitchTrack { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let style = self.resolved_style(); + self.base.children(self.children).refine_style(&style) + } +} + +/// An unstyled switch thumb with typed checked and disabled state projection. +#[derive(IntoElement)] +pub struct SwitchThumb { + base: Div, + style: StyleRefinement, + semantic_styles: SwitchThumbStyles, + checked: bool, + disabled: bool, + children: SmallVec<[AnyElement; 1]>, +} + +/// Semantic styles supported by [`SwitchThumb`]. +#[derive(Default)] +pub struct SwitchThumbStyles { + checked: StyleRefinement, + disabled: StyleRefinement, +} + +impl SwitchThumbStyles { + pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.checked + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl SwitchThumb { + pub fn new(checked: bool) -> Self { + Self { + base: div(), + style: StyleRefinement::default(), + semantic_styles: SwitchThumbStyles::default(), + checked, + disabled: false, + children: SmallVec::new(), + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn styles(mut self, build: impl FnOnce(SwitchThumbStyles) -> SwitchThumbStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.checked.then_some(&self.semantic_styles.checked), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +impl Styled for SwitchThumb { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for SwitchThumb { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for SwitchThumb { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let style = self.resolved_style(); + self.base.children(self.children).refine_style(&style) + } +} + +impl Switch { + pub fn new(id: impl Into) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + semantic_styles: SwitchStyles::default(), + checked: false, + disabled: false, + children: SmallVec::new(), + on_change: None, + accessibility_label: None, + tab_index: 0, + tab_stop: true, + } + } + + /// Sets the application-controlled checked value. + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + /// Sets whether pointer and keyboard activation are ignored. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Configures application-owned styles for the switch's semantic states. + pub fn styles(mut self, build: impl FnOnce(SwitchStyles) -> SwitchStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.checked.then_some(&self.semantic_styles.checked), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } + + /// Handles activation with the next checked value and its input event. + pub fn on_change( + mut self, + handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Sets the name exposed to accessibility clients. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Sets the focus traversal index. Use this within a GPUI tab group. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + /// Sets whether the switch participates in keyboard focus traversal. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle { + window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + } +} + +impl Styled for Switch { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Switch { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Switch { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Switch {} + +impl RenderOnce for Switch { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle(window, cx); + let checked = self.checked; + let disabled = self.disabled; + let style = self.resolved_style(); + + self.base + .role(Role::Switch) + .aria_toggled(if checked { + Toggled::True + } else { + Toggled::False + }) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .when(!disabled, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when(disabled, |this| { + this.on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + }) + .when_some( + (!disabled).then_some(self.on_change).flatten(), + |this, on_change| { + this.on_click(move |event, window, cx| { + on_change(!checked, event, window, cx); + }) + }, + ) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::Cell, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + TestAppContext, VisualTestContext, accesskit, canvas, point, px, + }; + + #[test] + fn track_projects_checked_disabled_and_instance_style_priority() { + let normal_color = gpui::hsla(0.0, 0.0, 0.2, 1.0); + let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0); + let disabled_color = gpui::hsla(0.6, 0.7, 0.5, 0.5); + let track = |checked, disabled| { + SwitchTrack::new(("track", usize::from(checked) * 2 + usize::from(disabled))) + .checked(checked) + .disabled(disabled) + .when(!checked, |this| this.bg(normal_color)) + .styles(|styles| { + styles + .checked(|style| style.bg(checked_color)) + .disabled(|style| style.when(checked, |style| style.bg(disabled_color))) + }) + }; + + assert_eq!( + track(false, false).resolved_style().background, + Some(normal_color.into()) + ); + assert_eq!( + track(false, true).resolved_style().background, + Some(normal_color.into()) + ); + assert_eq!( + track(true, false).resolved_style().background, + Some(checked_color.into()) + ); + assert_eq!( + track(true, true).resolved_style().background, + Some(disabled_color.into()) + ); + // Semantic states layer over the instance chain, so an instance + // background does not defeat the disabled state. + assert_eq!( + track(true, true) + .bg(normal_color) + .resolved_style() + .background, + Some(disabled_color.into()) + ); + } + + #[test] + fn track_identity_is_scoped_to_its_switch() { + fn track(switch_id: &'static str) -> SwitchTrack { + SwitchTrack::new((ElementId::from(switch_id), "track")) + } + + let first = track("first-switch"); + let second = track("second-switch"); + + assert_eq!( + gpui::Element::id(&first.base), + Some((ElementId::from("first-switch"), "track").into()) + ); + assert_eq!( + gpui::Element::id(&second.base), + Some((ElementId::from("second-switch"), "track").into()) + ); + assert_ne!( + gpui::Element::id(&first.base), + gpui::Element::id(&second.base) + ); + } + + #[test] + fn thumb_projects_checked_disabled_and_instance_style_priority() { + let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0); + let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5); + let thumb = |checked, disabled| { + SwitchThumb::new(checked) + .disabled(disabled) + .styles(|styles| { + styles + .checked(|style| style.bg(checked_color)) + .disabled(|style| style.bg(disabled_color)) + }) + }; + + assert_eq!( + thumb(true, false).resolved_style().background, + Some(checked_color.into()) + ); + assert_eq!( + thumb(true, true).resolved_style().background, + Some(disabled_color.into()) + ); + assert_eq!( + thumb(true, true) + .bg(checked_color) + .resolved_style() + .background, + Some(disabled_color.into()) + ); + } + + struct SwitchHarness { + checked: bool, + disabled: bool, + toggles: Rc>, + keyboard_events: Rc>, + last_value: Rc>, + parent_clicks: Rc>, + } + + impl Render for SwitchHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let toggles = self.toggles.clone(); + let keyboard_events = self.keyboard_events.clone(); + let last_value = self.last_value.clone(); + let parent_clicks = self.parent_clicks.clone(); + + div() + .id("switch-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Switch::new("switch") + .checked(self.checked) + .disabled(self.disabled) + .size_full() + .on_change(move |value, event, _, _| { + toggles.set(toggles.get() + 1); + last_value.set(value); + if matches!(event, ClickEvent::Keyboard(_)) { + keyboard_events.set(keyboard_events.get() + 1); + } + }), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + checked: bool, + disabled: bool, + ) -> ( + &mut VisualTestContext, + Rc>, + Rc>, + Rc>, + Rc>, + ) { + let toggles = Rc::new(Cell::new(0)); + let keyboard_events = Rc::new(Cell::new(0)); + let last_value = Rc::new(Cell::new(checked)); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let toggles = toggles.clone(); + let keyboard_events = keyboard_events.clone(); + let last_value = last_value.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| SwitchHarness { + checked, + disabled, + toggles, + keyboard_events, + last_value, + parent_clicks, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, toggles, keyboard_events, last_value, parent_clicks) + } + + fn activate_key(cx: &mut VisualTestContext, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + #[gpui::test] + fn pointer_reports_the_next_value_once(cx: &mut TestAppContext) { + let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + + assert_eq!(toggles.get(), 1); + assert_eq!(keyboard_events.get(), 0); + assert!(last_value.get()); + } + + #[gpui::test] + fn enter_and_space_report_one_native_keyboard_activation_each(cx: &mut TestAppContext) { + let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + toggles.set(0); + cx.update(|window, cx| { + assert!(window.focused(cx).is_some()); + window.draw(cx).clear(cx); + }); + + activate_key(cx, "enter"); + activate_key(cx, "space"); + + assert_eq!(toggles.get(), 2); + assert_eq!(keyboard_events.get(), 2); + assert!(last_value.get()); + } + + #[gpui::test] + fn disabled_switch_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) { + let (cx, toggles, _, _, parent_clicks) = harness(cx, false, true); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + activate_key(cx, "enter"); + activate_key(cx, "space"); + + assert_eq!(toggles.get(), 0); + assert_eq!(parent_clicks.get(), 0); + } + + #[test] + fn application_owned_state_styles_are_available() { + let _ = Switch::new("states") + .styles(|styles| { + styles + .checked(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }) + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.8)) + .focus_visible(|style| style.opacity(0.7)); + } + + #[test] + fn semantic_root_styles_follow_switch_priority() { + let styled = |switch: Switch| { + switch.styles(|styles| { + styles + .checked(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }) + }; + + assert_eq!(styled(Switch::new("normal")).resolved_style().opacity, None); + assert_eq!( + styled(Switch::new("checked").checked(true)) + .resolved_style() + .opacity, + Some(0.8) + ); + assert_eq!( + styled(Switch::new("checked-disabled").checked(true).disabled(true)) + .resolved_style() + .opacity, + Some(0.5) + ); + assert_eq!( + styled( + Switch::new("state-over-instance") + .checked(true) + .disabled(true) + .opacity(0.9), + ) + .resolved_style() + .opacity, + Some(0.5) + ); + } + + #[gpui::test] + fn accessibility_exposes_switch_role_label_and_toggled_state(cx: &mut TestAppContext) { + type Captured = Arc>>; + + struct A11yProbe { + captured: Captured, + } + + impl Render for A11yProbe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.captured.clone(); + canvas( + move |_, window, cx| { + let mut info = |switch: Switch| { + let mut node = accesskit::Node::new(Role::Switch); + switch + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + node + }; + let enabled = info( + Switch::new("enabled") + .checked(true) + .accessibility_label("Airplane mode") + .on_change(|_, _, _, _| {}), + ); + let disabled = info( + Switch::new("disabled") + .checked(false) + .disabled(true) + .accessibility_label("Airplane mode") + .on_change(|_, _, _, _| {}), + ); + *captured.lock().unwrap() = Some((enabled, disabled)); + }, + |_, _, _, _| {}, + ) + } + } + + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let (enabled, disabled) = result.lock().unwrap().take().unwrap(); + + assert_eq!(enabled.role(), Role::Switch); + assert_eq!(enabled.label(), Some("Airplane mode")); + assert_eq!(enabled.toggled(), Some(Toggled::True)); + assert!(enabled.supports_action(accesskit::Action::Click)); + + assert_eq!(disabled.role(), Role::Switch); + assert_eq!(disabled.toggled(), Some(Toggled::False)); + assert!(!disabled.supports_action(accesskit::Action::Click)); + // GPUI currently has no aria-disabled setter. Keep the limitation + // explicit instead of claiming an AccessKit disabled state. + assert!(!disabled.is_disabled()); + } +} diff --git a/vendor/gpui-base/src/table.rs b/vendor/gpui-base/src/table.rs new file mode 100644 index 00000000..73a1f854 --- /dev/null +++ b/vendor/gpui-base/src/table.rs @@ -0,0 +1,372 @@ +use gpui::{ + AnyElement, App, Div, ElementId, InteractiveElement, Interactivity, IntoElement, ParentElement, + RenderOnce, Role, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, +}; + +use crate::StyledExt as _; + +macro_rules! table_part { + ($name:ident, $role:expr, $docs:literal) => { + #[doc = $docs] + #[derive(IntoElement)] + pub struct $name { + base: Stateful
, + style: StyleRefinement, + children: Vec, + } + + impl $name { + #[doc = concat!("Create ", $docs)] + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + children: Vec::new(), + } + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + + impl ParentElement for $name { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } + } + + impl InteractiveElement for $name { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } + } + + impl StatefulInteractiveElement for $name {} + + impl RenderOnce for $name { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role($role) + .children(self.children) + .refine_style(&self.style) + } + } + }; +} + +/// An unstyled semantic table root. +#[derive(IntoElement)] +pub struct Table { + base: Stateful
, + style: StyleRefinement, + children: Vec, + row_count: Option, + column_count: Option, +} + +impl Table { + /// Create an unstyled semantic table root. + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + children: Vec::new(), + row_count: None, + column_count: None, + } + } + + /// Sets the total number of rows, including rows outside the rendered + /// range. Assistive technology needs this to announce "row 5 of 200" for a + /// virtualized table whose rendered rows are only a window onto the data. + pub fn row_count(mut self, count: usize) -> Self { + self.row_count = Some(count); + self + } + + /// Sets the total number of columns, including columns outside the + /// rendered range. + pub fn column_count(mut self, count: usize) -> Self { + self.column_count = Some(count); + self + } +} + +impl Styled for Table { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Table { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } +} + +impl InteractiveElement for Table { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Table {} + +impl RenderOnce for Table { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::Table) + .when_some(self.row_count, |this, count| this.aria_row_count(count)) + .when_some(self.column_count, |this, count| { + this.aria_column_count(count) + }) + .children(self.children) + .refine_style(&self.style) + } +} +table_part!( + TableHeader, + Role::RowGroup, + "An unstyled table header group." +); +table_part!(TableBody, Role::RowGroup, "An unstyled table body group."); + +/// An unstyled semantic table row. +#[derive(IntoElement)] +pub struct TableRow { + base: Stateful
, + style: StyleRefinement, + row_index: usize, + children: Vec, +} + +impl TableRow { + /// Create an unstyled semantic table row with a one-based accessibility index. + pub fn new(id: impl Into, row_index: usize) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + row_index, + children: Vec::new(), + } + } +} + +impl Styled for TableRow { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for TableRow { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } +} + +impl InteractiveElement for TableRow { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for TableRow {} + +impl RenderOnce for TableRow { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::Row) + .aria_row_index(self.row_index) + .children(self.children) + .refine_style(&self.style) + } +} + +macro_rules! table_cell { + ($name:ident, $role:expr, $docs:literal) => { + #[doc = $docs] + #[derive(IntoElement)] + pub struct $name { + base: Stateful
, + style: StyleRefinement, + column_index: usize, + children: Vec, + } + + impl $name { + #[doc = concat!("Create ", $docs, " with a one-based accessibility index.")] + pub fn new(id: impl Into, column_index: usize) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + column_index, + children: Vec::new(), + } + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } + } + + impl ParentElement for $name { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } + } + + impl InteractiveElement for $name { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } + } + + impl StatefulInteractiveElement for $name {} + + impl RenderOnce for $name { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role($role) + .aria_column_index(self.column_index) + .children(self.children) + .refine_style(&self.style) + } + } + }; +} + +table_cell!( + TableHead, + Role::ColumnHeader, + "An unstyled table column header." +); +table_cell!(TableCell, Role::Cell, "An unstyled table data cell."); + +/// An unstyled table caption slot. +#[derive(IntoElement)] +pub struct TableCaption { + base: Stateful
, + style: StyleRefinement, + children: Vec, +} + +impl TableCaption { + /// Create an unstyled table caption slot. + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + children: Vec::new(), + } + } +} + +impl Styled for TableCaption { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for TableCaption { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } +} + +impl InteractiveElement for TableCaption { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for TableCaption {} + +impl RenderOnce for TableCaption { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base.children(self.children).refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Element as _, Modifiers, Render, TestAppContext, accesskit, point, px}; + use std::{cell::Cell, rc::Rc}; + + #[gpui::test] + fn row_and_cells_project_accessibility_indices(cx: &mut TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + let mut row = accesskit::Node::new(Role::Row); + TableRow::new("row", 3) + .render(window, cx) + .into_element() + .write_a11y_info(&mut row); + assert_eq!(row.row_index(), Some(3)); + + let mut head = accesskit::Node::new(Role::GenericContainer); + TableHead::new("head", 2) + .render(window, cx) + .into_element() + .write_a11y_info(&mut head); + assert_eq!(head.column_index(), Some(2)); + + let mut cell = accesskit::Node::new(Role::Cell); + TableCell::new("cell", 4) + .render(window, cx) + .into_element() + .write_a11y_info(&mut cell); + assert_eq!(cell.column_index(), Some(4)); + }); + } + + struct TableHarness { + clicks: Rc>, + } + + impl Render for TableHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + Table::new("table") + .debug_selector(|| "base-table".into()) + .w(px(120.)) + .h(px(60.)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)) + .child( + TableBody::new("body").child( + TableRow::new("row", 1) + .child(TableCell::new("cell", 1).child( + div().debug_selector(|| "table-child".into()).size(px(20.)), + )), + ), + ) + } + } + + #[gpui::test] + fn table_forwards_children_instance_style_and_pointer_interaction(cx: &mut TestAppContext) { + let clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let clicks = clicks.clone(); + move |_, _| TableHarness { clicks } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let table = cx.debug_bounds("base-table").expect("table is rendered"); + assert_eq!(table.size.width, px(120.)); + assert_eq!(table.size.height, px(60.)); + let child = cx.debug_bounds("table-child").expect("child is rendered"); + assert_eq!(child.size.width, px(20.)); + assert_eq!(child.size.height, px(20.)); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.simulate_click(point(px(100.), px(50.)), Modifiers::default()); + assert_eq!(clicks.get(), 2); + } +} diff --git a/vendor/gpui-base/src/tabs.rs b/vendor/gpui-base/src/tabs.rs new file mode 100644 index 00000000..9f6b195a --- /dev/null +++ b/vendor/gpui-base/src/tabs.rs @@ -0,0 +1,369 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, Interactivity, IntoElement, + MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{StateStyle, StyledExt as _}; + +type ClickHandler = Rc; + +/// An unstyled tab that owns pointer activation and accessibility behavior. +/// +/// Tabs do not participate in keyboard focus by themselves. A compound tab +/// list may add keyboard navigation when that behavior is introduced. +#[derive(IntoElement)] +pub struct Tab { + id: ElementId, + base: Div, + style: StyleRefinement, + semantic_styles: TabStyles, + selected: bool, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_click: Option, + accessibility_label: Option, + position_in_set: Option, + size_of_set: Option, +} + +impl Tab { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + base: div(), + style: StyleRefinement::default(), + semantic_styles: TabStyles::default(), + selected: false, + disabled: false, + children: SmallVec::new(), + on_click: None, + accessibility_label: None, + position_in_set: None, + size_of_set: None, + } + } + + /// Updates the element identity used when the tab is rendered. + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Sets this tab's one-based position and the tab list's total size, so + /// assistive technology can announce "tab 2 of 5". + pub fn set_position(mut self, position: usize, size: usize) -> Self { + self.position_in_set = Some(position); + self.size_of_set = Some(size); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } + + pub fn styles(mut self, build: impl FnOnce(TabStyles) -> TabStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.selected.then_some(&self.semantic_styles.selected), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } +} + +#[derive(Default)] +pub struct TabStyles { + selected: StyleRefinement, + disabled: StyleRefinement, +} + +impl TabStyles { + pub fn selected(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.selected + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl Styled for Tab { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Tab { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Tab { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Tab {} + +impl RenderOnce for Tab { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let disabled = self.disabled; + let style = self.resolved_style(); + + self.base + .id(self.id) + .role(Role::Tab) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .aria_selected(self.selected) + .when_some(self.position_in_set, |this, position| { + this.aria_position_in_set(position) + }) + .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .when_some( + (!disabled).then_some(self.on_click).flatten(), + |this, on_click| { + this.on_click(move |event, window, cx| on_click(event, window, cx)) + }, + ) + .children(self.children) + .refine_style(&style) + } +} + +/// An unstyled collection root for [`Tab`] elements. +/// +/// Controlled selection and activation are expressed directly by its child +/// tabs. The root supplies collection accessibility without introducing a +/// context hierarchy or new keyboard behavior. +#[derive(IntoElement)] +pub struct Tabs { + base: gpui::Stateful
, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl Tabs { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Styled for Tabs { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Tabs { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Tabs { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Tabs {} + +impl RenderOnce for Tabs { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::TabList) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::Cell, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + Context, Element as _, Modifiers, Render, Role, VisualTestContext, accesskit, canvas, hsla, + point, px, + }; + + struct TabHarness { + disabled: bool, + clicks: Rc>, + } + + impl Render for TabHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + Tab::new("tab") + .disabled(self.disabled) + .size(px(100.)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)) + } + } + + fn harness( + cx: &mut gpui::TestAppContext, + disabled: bool, + ) -> (&mut VisualTestContext, Rc>) { + let clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let clicks = clicks.clone(); + move |_, _| TabHarness { disabled, clicks } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, clicks) + } + + #[gpui::test] + fn pointer_activation_and_disabled_gating_match_tabs(cx: &mut gpui::TestAppContext) { + let (cx, clicks) = harness(cx, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(clicks.get(), 1); + + let (cx, clicks) = harness(cx, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(clicks.get(), 0); + } + + #[gpui::test] + fn exposes_tab_accessibility_state(cx: &mut gpui::TestAppContext) { + type Captured = Arc>>; + + struct Probe(Captured); + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.0.clone(); + canvas( + move |_, window, cx| { + let mut info = |tab: Tab| { + let mut node = accesskit::Node::new(Role::Tab); + tab.render(window, cx) + .into_element() + .write_a11y_info(&mut node); + node + }; + let selected = info( + Tab::new("selected") + .selected(true) + .accessibility_label("Account") + .on_click(|_, _, _| {}), + ); + let disabled = + info(Tab::new("disabled").disabled(true).on_click(|_, _, _| {})); + *captured.lock().unwrap() = Some((selected, disabled)); + }, + |_, _, _, _| {}, + ) + } + } + + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Probe(captured)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let (selected, disabled) = result.lock().unwrap().take().unwrap(); + + assert_eq!(selected.role(), Role::Tab); + assert_eq!(selected.label(), Some("Account")); + assert_eq!(selected.is_selected(), Some(true)); + assert!(selected.supports_action(accesskit::Action::Click)); + assert!(!disabled.supports_action(accesskit::Action::Click)); + } + + #[gpui::test] + fn semantic_styles_preserve_the_legacy_state_priority(_cx: &mut gpui::TestAppContext) { + let expected = hsla(0.3, 0.4, 0.5, 1.0); + let mut tab = Tab::new("tab") + .selected(true) + .disabled(true) + .styles(|styles| { + styles + .selected(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }) + .opacity(0.9); + + assert_eq!(tab.resolved_style().opacity, Some(0.5)); + tab.style().background = Some(expected.into()); + assert_eq!(tab.resolved_style().background, Some(expected.into())); + } + + #[gpui::test] + fn tabs_exposes_tab_list_role(cx: &mut gpui::TestAppContext) { + type Captured = Arc>>; + struct Probe(Captured); + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.0.clone(); + canvas( + move |_, window, cx| { + let mut node = accesskit::Node::new(Role::TabList); + Tabs::new("tabs") + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + *captured.lock().unwrap() = Some(node); + }, + |_, _, _, _| {}, + ) + } + } + + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Probe(captured)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(result.lock().unwrap().take().unwrap().role(), Role::TabList); + } +} diff --git a/vendor/gpui-base/src/text_boundary.rs b/vendor/gpui-base/src/text_boundary.rs new file mode 100644 index 00000000..83d15a0a --- /dev/null +++ b/vendor/gpui-base/src/text_boundary.rs @@ -0,0 +1,82 @@ +use std::ops::Range; + +#[derive(Clone, Copy)] +enum CharacterKind { + Word, + Whitespace, + Newline, + Other, +} + +impl From for CharacterKind { + fn from(character: char) -> Self { + if character == '_' + || character.is_ascii_alphanumeric() + || matches!(character, '\u{00C0}'..='\u{024F}' | '\u{0400}'..='\u{04FF}' | '\u{1E00}'..='\u{1EFF}' | '\u{0300}'..='\u{036F}') + { + Self::Word + } else if matches!(character, '\n' | '\r') { + Self::Newline + } else if character.is_whitespace() { + Self::Whitespace + } else { + Self::Other + } + } +} + +pub(crate) fn word_range_from_chars( + offset: usize, + character: char, + previous: impl Iterator, + following: impl Iterator, +) -> Range { + let kind = CharacterKind::from(character); + let connects = |next| { + matches!( + (kind, CharacterKind::from(next)), + (CharacterKind::Word, CharacterKind::Word) + | (CharacterKind::Whitespace, CharacterKind::Whitespace) + ) + }; + let start = previous + .take(128) + .take_while(|character| connects(*character)) + .fold(offset, |offset, character| offset - character.len_utf8()); + let end = following + .take(128) + .take_while(|character| connects(*character)) + .fold(offset + character.len_utf8(), |offset, character| { + offset + character.len_utf8() + }); + start..end +} + +pub(crate) fn word_range_at(text: &str, offset: usize) -> Option> { + let offset = clip_offset_left(text, offset); + let character = text[offset..].chars().next()?; + let end = offset + character.len_utf8(); + Some(word_range_from_chars( + offset, + character, + text[..offset].chars().rev(), + text[end..].chars(), + )) +} + +pub(crate) fn line_range_at(text: &str, offset: usize) -> Range { + let offset = clip_offset_left(text, offset); + let start = text[..offset].rfind('\n').map_or(0, |newline| newline + 1); + let end = text[offset..] + .find('\n') + .map_or(text.len(), |newline| offset + newline); + start..end +} + +fn clip_offset_left(text: &str, offset: usize) -> usize { + let mut offset = offset.min(text.len()); + while !text.is_char_boundary(offset) { + offset -= 1; + } + offset +} diff --git a/vendor/gpui-base/src/text_selection.rs b/vendor/gpui-base/src/text_selection.rs new file mode 100644 index 00000000..144ec846 --- /dev/null +++ b/vendor/gpui-base/src/text_selection.rs @@ -0,0 +1,3427 @@ +use std::{ + collections::HashMap, + ops::Range, + rc::Rc, + sync::atomic::{AtomicU64, Ordering}, +}; + +use gpui::{ + App, AppContext as _, Bounds, Context, Element, ElementId, Entity, EntityId, EventEmitter, + Global, GlobalElementId, Half, Hitbox, InspectorElementId, IntoElement, LayoutId, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, + Style, Subscription, TextLayout, WeakEntity, Window, +}; + +use crate::text_boundary::{line_range_at, word_range_at}; +use crate::{AutoScroll, GlobalState}; + +/// An opaque selection layer identifier. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct TextSelectionScopeId(u64); + +impl TextSelectionScopeId { + /// Allocates a process-unique scope identifier. + /// + /// Keep the returned identifier for the semantic lifetime of the scope; + /// do not allocate a new identifier on every frame. + pub fn new() -> Self { + static NEXT_SCOPE_ID: AtomicU64 = AtomicU64::new(1); + let value = NEXT_SCOPE_ID + .try_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .expect("text selection scope identifiers exhausted"); + Self(value) + } + + #[cfg(test)] + const fn from_raw(value: u64) -> Self { + Self(value) + } +} + +/// Stable participant-defined identity for virtualized participant content. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TextSelectionContentKey(u64); + +impl TextSelectionContentKey { + /// Creates a key from a participant-defined stable content identity. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Returns the participant-defined value. + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A selection endpoint anchored to a participant's content coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextSelectionEndpoint { + entity_id: Option, + point: Point, + content_key: Option, +} + +impl TextSelectionEndpoint { + /// Creates an endpoint at a participant-relative content point. + pub(crate) const fn new(entity_id: Option, point: Point) -> Self { + Self { + entity_id, + point, + content_key: None, + } + } + + /// Sets participant-defined endpoint metadata. + pub(crate) const fn with_content_key(mut self, content_key: TextSelectionContentKey) -> Self { + self.content_key = Some(content_key); + self + } + + /// Returns the participant which owns this endpoint, when it hit one. + pub const fn entity_id(&self) -> Option { + self.entity_id + } + + /// Returns the participant-relative content point. + pub const fn content_point(&self) -> Point { + self.point + } + + /// Returns participant-defined endpoint metadata captured when it hit a participant. + pub const fn content_key(&self) -> Option { + self.content_key + } +} + +/// Window-coordinate anchor and cursor points for painting a selection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextSelectionWindowPoints { + anchor: Point, + cursor: Point, +} + +impl TextSelectionWindowPoints { + /// Returns the stable anchor in window coordinates. + pub const fn anchor(&self) -> Point { + self.anchor + } + + /// Returns the moving cursor in window coordinates. + pub const fn cursor(&self) -> Point { + self.cursor + } +} + +/// Participant-relative selection endpoints with an optional rendering projection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextSelectionSnapshot { + anchor: TextSelectionEndpoint, + cursor: TextSelectionEndpoint, + is_selecting: bool, + window_points: Option, + coverage: TextSelectionCoverage, +} + +/// How much of one participant participates in a window selection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextSelectionCoverage { + /// Only the interval between this participant's two endpoints is selected. + #[default] + Bounded, + /// The participant is selected from its beginning through its endpoint. + FromStart, + /// The participant is selected from its endpoint through its end. + ToEnd, + /// The entire participant lies between endpoints in other participants. + Full, +} + +impl TextSelectionSnapshot { + /// Creates a snapshot from stable participant-relative endpoints. + pub(crate) const fn new(anchor: TextSelectionEndpoint, cursor: TextSelectionEndpoint) -> Self { + Self { + anchor, + cursor, + is_selecting: false, + window_points: None, + coverage: TextSelectionCoverage::Bounded, + } + } + + /// Sets whether the pointer gesture is still active. + pub(crate) const fn with_selecting(mut self, is_selecting: bool) -> Self { + self.is_selecting = is_selecting; + self + } + + /// Sets the current window-coordinate rendering projection. + pub(crate) const fn with_window_points( + mut self, + window_points: Option, + ) -> Self { + self.window_points = window_points; + self + } + + /// Sets the portion of the receiving participant covered by this selection. + #[cfg(test)] + pub(crate) const fn with_coverage(mut self, coverage: TextSelectionCoverage) -> Self { + self.coverage = coverage; + self + } + + /// Returns the stable anchor endpoint. + pub const fn anchor(&self) -> TextSelectionEndpoint { + self.anchor + } + + /// Returns the moving cursor endpoint. + pub const fn cursor(&self) -> TextSelectionEndpoint { + self.cursor + } + + /// Returns whether the pointer gesture is still active. + pub const fn is_selecting(&self) -> bool { + self.is_selecting + } + + /// Returns the window-coordinate endpoints for participants that need them. + pub const fn window_points(&self) -> Option { + self.window_points + } + + /// Returns the portion of the receiving participant covered by this selection. + pub const fn coverage(&self) -> TextSelectionCoverage { + self.coverage + } +} + +/// Per-frame geometry reported by a [`TextSelectionHandle`] participant. +pub struct TextSelectionRegistration { + hitbox: Hitbox, + bounds: Bounds, + scroll_offset: Point, + scope: TextSelectionScopeId, + document_order: u64, + text_bounds: Vec>, +} + +impl TextSelectionRegistration { + /// Creates a registration with default scope, order, and scroll offset. + pub fn new(hitbox: Hitbox, bounds: Bounds) -> Self { + Self { + hitbox, + bounds, + scroll_offset: Point::default(), + scope: TextSelectionScopeId::default(), + document_order: 0, + text_bounds: Vec::new(), + } + } + + /// Sets the participant's content scroll offset. + pub fn with_scroll_offset(mut self, scroll_offset: Point) -> Self { + self.scroll_offset = scroll_offset; + self + } + + /// Sets the opaque selection scope. + pub fn with_scope(mut self, scope: TextSelectionScopeId) -> Self { + self.scope = scope; + self + } + + /// Sets the stable logical document order. + pub fn with_document_order(mut self, document_order: u64) -> Self { + self.document_order = document_order; + self + } + + /// Sets the glyph-bearing bounds used to reject blank-only gestures. + pub fn with_text_bounds(mut self, text_bounds: Vec>) -> Self { + self.text_bounds = text_bounds; + self + } + + /// Returns the participant hitbox. + pub fn hitbox(&self) -> &Hitbox { + &self.hitbox + } + + /// Returns the participant's window-coordinate bounds. + pub const fn bounds(&self) -> Bounds { + self.bounds + } + + /// Returns the participant's content scroll offset. + pub const fn scroll_offset(&self) -> Point { + self.scroll_offset + } + + /// Returns the opaque selection scope. + pub const fn scope(&self) -> TextSelectionScopeId { + self.scope + } + + /// Returns the stable logical document order. + pub const fn document_order(&self) -> u64 { + self.document_order + } + + /// Returns the glyph-bearing bounds used to reject blank-only gestures. + pub fn text_bounds(&self) -> &[Bounds] { + &self.text_bounds + } +} + +/// Laid-out text reported by a plain selection participant during paint. +#[derive(Clone)] +pub struct TextSelectionRun { + /// Logical order within the containing participant. + document_order: u64, + /// The exact text used to produce `layout`. + text: SharedString, + /// Laid-out glyph geometry in window coordinates. + layout: TextLayout, + /// The run's window-coordinate paint bounds. + bounds: Bounds, +} + +impl TextSelectionRun { + /// Creates a laid-out text run. + pub fn new(text: impl Into, layout: TextLayout, bounds: Bounds) -> Self { + Self { + document_order: 0, + text: text.into(), + layout, + bounds, + } + } + + /// Sets the run's logical order within the participant. + pub const fn with_document_order(mut self, document_order: u64) -> Self { + self.document_order = document_order; + self + } + + /// Returns the run's logical order within its participant. + pub const fn document_order(&self) -> u64 { + self.document_order + } + + /// Returns the exact text used to produce the layout. + pub fn text(&self) -> &SharedString { + &self.text + } + + /// Returns the laid-out glyph geometry. + pub fn layout(&self) -> &TextLayout { + &self.layout + } + + /// Returns the run's window-coordinate paint bounds. + pub const fn bounds(&self) -> Bounds { + self.bounds + } +} + +/// Selection projected onto a participant's laid-out text runs. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextSelectionProjection { + /// Selected UTF-8 byte ranges paired with the input runs. + ranges: Vec>>, + /// Whether the participant participates in the current selection. + is_active: bool, +} + +impl TextSelectionProjection { + /// Returns selected UTF-8 byte ranges paired with the input runs. + pub fn ranges(&self) -> &[Option>] { + &self.ranges + } + + /// Returns whether the participant participates in the selection. + pub const fn is_active(&self) -> bool { + self.is_active + } +} + +/// Projects a participant selection snapshot onto laid-out plain-text runs. +/// +/// The returned states retain the input order so callers can pair every state +/// with its run. The ranges are always character boundaries; `order` is used +/// only when a participant caches selected text for copying. +fn project_ranges( + snapshot: Option, + runs: &[TextSelectionRun], +) -> TextSelectionProjection { + let Some(snapshot) = snapshot else { + return TextSelectionProjection { + ranges: vec![None; runs.len()], + is_active: false, + }; + }; + let Some(window_points) = snapshot.window_points() else { + return TextSelectionProjection { + ranges: vec![None; runs.len()], + is_active: true, + }; + }; + + TextSelectionProjection { + ranges: runs + .iter() + .map(|run| selection_range_for_run(run, window_points.anchor, window_points.cursor)) + .collect(), + is_active: true, + } +} + +fn selection_range_for_run( + run: &TextSelectionRun, + selection_start: Point, + selection_end: Point, +) -> Option> { + if run.text.len() != run.layout.len() { + return None; + } + + let line_height = run.layout.line_height(); + let mut range = None; + for (offset, character) in run.text.char_indices() { + let next_offset = offset + character.len_utf8(); + let Some(position) = run.layout.position_for_index(offset) else { + continue; + }; + + let char_width = run + .layout + .position_for_index(next_offset) + .filter(|next| next.y == position.y) + .map_or_else(|| line_height.half(), |next| next.x - position.x); + + if point_in_selection_band( + position, + char_width, + selection_start, + selection_end, + line_height, + ) { + range.get_or_insert(offset..offset).end = next_offset; + } + } + range +} + +fn points_for_multi_click( + runs: &[TextSelectionRun], + position: Point, + click_count: usize, +) -> Option<(Point, Point)> { + let run = runs.iter().find(|run| run.bounds.contains(&position))?; + if run.text.len() != run.layout.len() { + return None; + } + let offset = run.layout.index_for_position(position).ok()?; + let range = match click_count { + 2 => word_range_at(&run.text, offset)?, + 3.. => line_range_at(&run.text, offset), + _ => return None, + }; + if range.is_empty() { + return None; + } + Some(( + run.layout.position_for_index(range.start)?, + run.layout.position_for_index(range.end)?, + )) +} + +fn point_in_selection_band( + position: Point, + char_width: Pixels, + selection_start: Point, + selection_end: Point, + line_height: Pixels, +) -> bool { + let point_in_line = + |point: Point| point.y >= position.y && point.y < position.y + line_height; + let top = selection_start.y.min(selection_end.y); + let bottom = selection_start.y.max(selection_end.y); + let x = position.x + char_width.half(); + + if position.y + line_height <= top || position.y > bottom { + return false; + } + + if point_in_line(selection_start) && point_in_line(selection_end) { + let left = selection_start.x.min(selection_end.x); + let right = selection_start.x.max(selection_end.x); + return x >= left && x <= right; + } + + let (top_point, bottom_point) = if selection_start.y < selection_end.y { + (selection_start, selection_end) + } else { + (selection_end, selection_start) + }; + if point_in_line(top_point) { + x >= top_point.x + } else if point_in_line(bottom_point) { + x <= bottom_point.x + } else { + true + } +} + +type FocusCallback = Rc; +type ClearHandler = Rc; +type CopyCallback = Rc String>; +type ContentKeyResolver = Rc, &App) -> Option>; + +/// Notifications emitted by a text-selection participant. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TextSelectionEvent { + /// The participant's window-selection projection changed. + SelectionChanged(Option), + /// The active drag requests vertical auto-scroll, or `None` to stop. + AutoScroll(Option), + /// Window selection cleared the participant's participant-local state. + Cleared, +} + +struct CopyItem { + document_order: u64, + callback: Option, + fallback: String, +} + +fn resolve_copy_items(mut items: Vec, cx: &mut App) -> String { + items.sort_by_key(|item| item.document_order); + items + .into_iter() + .map(|item| { + item.callback + .map(|callback| callback(cx)) + .unwrap_or(item.fallback) + }) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n") +} + +fn dispatch_clear_handlers(handlers: Vec, cx: &mut App) { + for handler in handlers { + handler(cx); + } +} + +struct SelectableTextState { + fallback_copy_text: String, + projected_copy_text: Option, + runs: Vec, + local_selection: bool, + snapshot: Option, + on_focus: Option, + clear: Option, + copy: Option, + content_key_resolver: Option, +} + +impl EventEmitter for SelectableTextState {} + +impl SelectableTextState { + fn new(fallback_copy_text: impl Into) -> Self { + Self { + fallback_copy_text: fallback_copy_text.into(), + projected_copy_text: None, + runs: Vec::new(), + local_selection: false, + snapshot: None, + on_focus: None, + clear: None, + copy: None, + content_key_resolver: None, + } + } + + /// The current geometry selection snapshot for this participant. + fn snapshot(&self) -> Option { + self.snapshot + } + + /// Sets the text copied by this participant when it participates in selection. + fn set_fallback_copy_text(&mut self, text: impl Into) { + self.fallback_copy_text = text.into(); + self.projected_copy_text = None; + } + + /// Marks participant-local selection (for example select-all) as active. + fn set_local_selection(&mut self, active: bool) { + self.local_selection = active; + } + + /// Projects this participant's current snapshot onto plain-text runs and caches + /// their selected substrings for the window selection query. + /// + /// Call this once per painted run. A snapshot change or + /// Clearing window selection invalidates the cache immediately, so copy + /// never returns text from a previous projection while waiting to repaint. + fn update_runs(&mut self, runs: &[TextSelectionRun]) -> TextSelectionProjection { + self.runs = runs.to_vec(); + let states = project_ranges(self.snapshot, runs); + let mut selected_runs = runs + .iter() + .zip(states.ranges()) + .enumerate() + .filter_map(|(index, (run, state))| { + state.as_ref().map(|range| { + debug_assert!(run.text.is_char_boundary(range.start)); + debug_assert!(run.text.is_char_boundary(range.end)); + ( + run.document_order, + index, + run.text[range.clone()].to_string(), + ) + }) + }) + .collect::>(); + selected_runs.sort_by_key(|(order, index, _)| (*order, *index)); + self.projected_copy_text = + Some(selected_runs.into_iter().map(|(_, _, text)| text).collect()); + states + } + + /// Installs the callback which focuses the participant when a drag begins in it. + fn set_focus_handler(&mut self, callback: impl Fn(&mut Window, &mut App) + 'static) { + self.on_focus = Some(Rc::new(callback)); + } + + fn clear_with(&mut self, callback: impl Fn(&mut App) + 'static) { + self.clear = Some(Rc::new(callback)); + } + + /// Installs a participant-specific copy projection. + fn copy_with(&mut self, callback: impl Fn(&mut App) -> String + 'static) { + self.copy = Some(Rc::new(callback)); + } + + /// Installs a participant-specific lookup for stable virtualized content keys. + fn resolve_content_key_with( + &mut self, + callback: impl Fn(Point, &App) -> Option + 'static, + ) { + self.content_key_resolver = Some(Rc::new(callback)); + } + + fn set_snapshot(&mut self, snapshot: Option, cx: &mut Context) { + if self.snapshot == snapshot { + return; + } + self.snapshot = snapshot; + self.projected_copy_text = None; + cx.emit(TextSelectionEvent::SelectionChanged(snapshot)); + } + + fn clear_state(&mut self, cx: &mut Context) -> Option { + self.snapshot = None; + self.projected_copy_text = None; + self.local_selection = false; + cx.emit(TextSelectionEvent::Cleared); + cx.emit(TextSelectionEvent::SelectionChanged(None)); + self.clear.clone() + } + + fn set_auto_scroll(&self, delta: Option, cx: &mut Context) { + cx.emit(TextSelectionEvent::AutoScroll(delta)); + } + + fn focus(&self, window: &mut Window, cx: &mut App) { + if let Some(callback) = self.on_focus.clone() { + window.defer(cx, move |window, cx| callback(window, cx)); + } + } + + fn copy_item(&self, document_order: u64) -> Option { + (self.snapshot.is_some() || self.local_selection).then(|| CopyItem { + document_order, + callback: self.copy.clone(), + fallback: self + .projected_copy_text + .clone() + .unwrap_or_else(|| self.fallback_copy_text.clone()), + }) + } +} + +/// A stable, participant-neutral handle for text that participates in window selection. +#[derive(Clone)] +pub struct TextSelectionHandle(Entity); + +impl TextSelectionHandle { + /// Creates a selection participant handle with fallback text for copying. + pub fn new(fallback_copy_text: impl Into, cx: &mut App) -> Self { + Self(cx.new(|_| SelectableTextState::new(fallback_copy_text))) + } + + /// Returns this participant's stable identity. + pub fn entity_id(&self) -> EntityId { + self.0.entity_id() + } + + /// Returns the current geometry selection snapshot for this participant. + pub fn snapshot(&self, cx: &App) -> Option { + self.0.read(cx).snapshot() + } + + /// Sets the fallback text copied while this participant participates. + pub fn set_fallback_copy_text(&self, text: impl Into, cx: &mut App) { + self.0 + .update(cx, |state, _| state.set_fallback_copy_text(text)); + } + + /// Marks participant-local selection, such as select-all, as active. + pub fn set_local_selection(&self, active: bool, cx: &mut App) { + self.0 + .update(cx, |state, _| state.set_local_selection(active)); + } + + /// Returns whether participant-local selection is active. + pub fn has_local_selection(&self, cx: &App) -> bool { + self.0.read(cx).local_selection + } + + /// Registers this participant and its geometry for the current frame. + pub fn register( + &self, + mut registration: TextSelectionRegistration, + window: &mut Window, + cx: &mut App, + ) { + if let Some(scope) = current_text_selection_scope(window.window_handle().window_id(), cx) { + registration.scope = scope; + } + let Some(state) = WindowSelectionState::existing(window, cx) else { + return; + }; + state.update(cx, |state, cx| { + state.register_participant(self.clone(), registration, cx) + }); + } + + /// Projects the current snapshot onto plain-text runs and caches their copy text. + pub fn update_runs(&self, runs: &[TextSelectionRun], cx: &mut App) -> TextSelectionProjection { + self.0.update(cx, |state, _| state.update_runs(runs)) + } + + /// Subscribes to participant selection notifications. + pub fn subscribe( + &self, + mut callback: impl FnMut(&TextSelectionEvent, &mut App) + 'static, + cx: &mut App, + ) -> Subscription { + cx.subscribe(&self.0, move |_, event, cx| callback(event, cx)) + } + + /// Subscribes `window` to refresh whenever this participant's selection changes. + #[must_use = "retain the subscription or explicitly detach it"] + pub fn refresh_window_on_change(&self, window: &Window, cx: &mut App) -> Subscription { + let window = window.window_handle(); + self.subscribe( + move |event, cx| { + if matches!(event, TextSelectionEvent::SelectionChanged(_)) { + _ = window.update(cx, |_, window, _| window.refresh()); + } + }, + cx, + ) + } + + /// Sets the callback which focuses the participant when a drag begins in it. + pub fn focus_with(&self, callback: impl Fn(&mut Window, &mut App) + 'static, cx: &mut App) { + self.0 + .update(cx, |state, _| state.set_focus_handler(callback)); + } + + /// Sets the synchronous participant cleanup command used by window clear. + pub fn clear_with(&self, callback: impl Fn(&mut App) + 'static, cx: &mut App) { + self.0.update(cx, |state, _| state.clear_with(callback)); + } + + /// Sets a participant-specific copy projection. + pub fn copy_with(&self, callback: impl Fn(&mut App) -> String + 'static, cx: &mut App) { + self.0.update(cx, |state, _| state.copy_with(callback)); + } + + /// Sets a participant-specific lookup for stable virtualized content keys. + pub fn resolve_content_key_with( + &self, + callback: impl Fn(Point, &App) -> Option + 'static, + cx: &mut App, + ) { + self.0 + .update(cx, |state, _| state.resolve_content_key_with(callback)); + } + + fn downgrade(&self) -> WeakEntity { + self.0.downgrade() + } +} + +#[derive(Clone)] +struct ParticipantRegistration { + participant: WeakEntity, + registration: Rc, + generation: u64, +} + +#[derive(Clone)] +struct SelectionEndpoint { + participant: Option>, + point: Point, + inside: bool, + inside_text: bool, + content_key: Option, + content_key_resolver: Option<(ContentKeyResolver, Point)>, +} + +impl SelectionEndpoint { + fn snapshot(&self) -> TextSelectionEndpoint { + let snapshot = TextSelectionEndpoint::new(self.entity_id(), self.point); + if let Some(content_key) = self.content_key { + snapshot.with_content_key(content_key) + } else { + snapshot + } + } + + fn resolve( + &self, + participants: &HashMap, + ) -> Option> { + let participant = self.participant.as_ref()?; + let registration = participants.get(&participant.entity_id())?; + participant.upgrade()?; + Some( + self.point + + registration.registration.scroll_offset + + registration.registration.bounds.origin, + ) + } + + fn entity_id(&self) -> Option { + self.participant + .as_ref() + .map(|participant| participant.entity_id()) + } +} + +/// Window-local generic text-selection state. +#[derive(Default)] +struct WindowSelectionState { + participants: HashMap, + active_scope: TextSelectionScopeId, + anchor: Option, + cursor: Option, + pending_extension_anchor: Option, + is_selecting: bool, + did_hit_text: bool, + frame_generation: u64, + finish_frame_scheduled: bool, + mouse_down_prepared: bool, +} + +impl WindowSelectionState { + fn resolve_content_keys(state: &Entity, cx: &mut App) { + let pending = state.update(cx, |state, _| { + [ + state + .anchor + .as_ref() + .and_then(|endpoint| endpoint.content_key_resolver.clone()), + state + .cursor + .as_ref() + .and_then(|endpoint| endpoint.content_key_resolver.clone()), + ] + }); + let resolved = + pending.map(|pending| pending.and_then(|(callback, point)| callback(point, cx))); + state.update(cx, |state, cx| { + if let (Some(endpoint), Some(key)) = (state.anchor.as_mut(), resolved[0]) { + endpoint.content_key = Some(key); + endpoint.content_key_resolver = None; + } + if let (Some(endpoint), Some(key)) = (state.cursor.as_mut(), resolved[1]) { + endpoint.content_key = Some(key); + endpoint.content_key_resolver = None; + } + state.publish_snapshots(cx); + }); + } + fn acquire(window_id: gpui::WindowId, cx: &mut App) -> Entity { + if !cx.has_global::() { + cx.set_global(SelectionStateRegistry::default()); + } + if let Some(state) = cx + .global::() + .0 + .get(&window_id) + .and_then(WeakEntity::upgrade) + { + return state; + } + + let active_scope = if cx.has_global::() { + cx.global_mut::() + .0 + .remove(&window_id) + .unwrap_or_default() + } else { + TextSelectionScopeId::default() + }; + + let state = cx.new(move |cx| { + let entity_id = cx.entity_id(); + cx.on_release(move |state: &mut WindowSelectionState, cx| { + let handlers = state.clear_state(cx); + if cx.has_global::() { + let registry = &mut cx.global_mut::().0; + if registry + .get(&window_id) + .is_some_and(|state| state.entity_id() == entity_id) + { + registry.remove(&window_id); + } + } + if !handlers.is_empty() { + cx.defer(move |cx| dispatch_clear_handlers(handlers, cx)); + } + }) + .detach(); + Self { + active_scope, + ..Self::default() + } + }); + cx.global_mut::() + .0 + .insert(window_id, state.downgrade()); + state + } + + #[cfg(test)] + fn ensure(window: &Window, cx: &mut App) -> Entity { + Self::acquire(window.window_handle().window_id(), cx) + } + + fn existing(window: &Window, cx: &App) -> Option> { + if !cx.has_global::() { + return None; + } + cx.global::() + .0 + .get(&window.window_handle().window_id()) + .and_then(WeakEntity::upgrade) + } + + /// Updates the active scope. Participants from other scopes cannot participate. + #[cfg(test)] + fn set_active_scope(&mut self, scope: TextSelectionScopeId, cx: &mut App) { + let handlers = self.set_active_scope_state(scope, cx); + dispatch_clear_handlers(handlers, cx); + } + + fn set_active_scope_state( + &mut self, + scope: TextSelectionScopeId, + cx: &mut App, + ) -> Vec { + if self.active_scope == scope { + return Vec::new(); + } + let handlers = self.clear_state(cx); + self.active_scope = scope; + self.publish_snapshots(cx); + handlers + } + + /// Sweeps participants after a rendered frame has completed. + /// + /// Registrations are stamped with the current generation while any sibling + /// is painting. Sweeping only after paint makes registration independent of + /// whether a participant or the lifecycle element paints first. + pub fn finish_frame(&mut self, cx: &mut App) -> Vec { + self.finish_frame_scheduled = false; + let stale = self + .participants + .iter() + .filter_map(|(id, registration)| { + (registration.generation != self.frame_generation) + .then(|| (*id, registration.participant.clone())) + }) + .collect::>(); + let mut handlers = Vec::new(); + for (id, participant) in stale { + self.participants.remove(&id); + if let Some(participant) = participant.upgrade() { + if let Some(handler) = participant.update(cx, |state, cx| state.clear_state(cx)) { + handlers.push(handler); + } + } + } + self.publish_snapshots(cx); + self.frame_generation = self.frame_generation.wrapping_add(1); + handlers + } + + fn schedule_finish_frame(&mut self) -> bool { + if self.finish_frame_scheduled { + return false; + } + self.finish_frame_scheduled = true; + true + } + + /// Registers this frame's geometry for a participant. + pub fn register_participant( + &mut self, + selection: TextSelectionHandle, + registration: TextSelectionRegistration, + cx: &mut App, + ) { + self.prune_dead_participants(); + self.participants.insert( + selection.entity_id(), + ParticipantRegistration { + participant: selection.downgrade(), + registration: Rc::new(registration), + generation: self.frame_generation, + }, + ); + self.publish_snapshots(cx); + } + + /// Starts a selection gesture using bounds hit testing (useful to adapters/tests). + #[cfg(test)] + fn begin(&mut self, position: Point, extend: bool, cx: &mut App) { + self.begin_impl(position, extend, false, None, cx); + } + + /// Updates the current gesture using bounds hit testing. + #[cfg(test)] + fn update(&mut self, position: Point, cx: &mut App) { + self.update_impl(position, None, cx); + } + + /// Ends the current gesture and keeps its selection visible. + pub fn end(&mut self, cx: &mut App) { + self.pending_extension_anchor = None; + if !self.is_selecting { + return; + } + self.is_selecting = false; + if !self.did_hit_text { + self.anchor = None; + self.cursor = None; + } + self.stop_anchor_auto_scroll(cx); + self.publish_snapshots(cx); + } + + /// Clears both window selection and every participant's local selection. + pub fn clear(&mut self, cx: &mut App) { + let handlers = self.clear_state(cx); + dispatch_clear_handlers(handlers, cx); + } + + fn clear_state(&mut self, cx: &mut App) -> Vec { + self.stop_anchor_auto_scroll(cx); + self.anchor = None; + self.cursor = None; + self.pending_extension_anchor = None; + self.is_selecting = false; + self.did_hit_text = false; + self.prune_dead_participants(); + self.participants + .values() + .filter_map(|registration| registration.participant.upgrade()) + .filter_map(|participant| participant.update(cx, |state, cx| state.clear_state(cx))) + .collect() + } + + fn copy_items(&self, cx: &App) -> Vec { + self.participants + .values() + .filter_map(|registration| { + let participant = registration.participant.upgrade()?; + participant + .read(cx) + .copy_item(registration.registration.document_order) + }) + .collect() + } + + #[cfg(test)] + fn selected_text(&self, cx: &mut App) -> String { + resolve_copy_items(self.copy_items(cx), cx) + } + + /// Returns whether a drag or a participant-local selection is active. + pub fn has_selection(&self, cx: &App) -> bool { + self.snapshot().is_some() + || self.participants.values().any(|registration| { + registration + .participant + .upgrade() + .is_some_and(|participant| participant.read(cx).local_selection) + }) + } + + /// Returns the current resolved selection endpoints. + pub fn snapshot(&self) -> Option { + if !self.did_hit_text { + return None; + } + let anchor_endpoint = self.anchor.as_ref()?; + let cursor_endpoint = self.cursor.as_ref()?; + let anchor = anchor_endpoint.resolve(&self.participants)?; + let cursor = cursor_endpoint.resolve(&self.participants)?; + (anchor != cursor).then(|| { + TextSelectionSnapshot::new(anchor_endpoint.snapshot(), cursor_endpoint.snapshot()) + .with_selecting(self.is_selecting) + .with_window_points(Some(TextSelectionWindowPoints { anchor, cursor })) + }) + } + + /// Returns whether a drag is currently in progress. + #[cfg(test)] + fn is_selecting(&self) -> bool { + self.is_selecting + } + + fn prepare_for_mouse_down(&mut self, extend: bool, cx: &mut App) -> Vec { + let pending_extension_anchor = extend.then(|| self.anchor.clone()).flatten(); + self.stop_anchor_auto_scroll(cx); + self.anchor = None; + self.cursor = None; + self.pending_extension_anchor = None; + self.is_selecting = false; + self.did_hit_text = false; + self.prune_dead_participants(); + let handlers = self + .participants + .values() + .filter_map(|registration| registration.participant.upgrade()) + .filter_map(|participant| participant.update(cx, |state, cx| state.clear_state(cx))) + .collect(); + self.pending_extension_anchor = pending_extension_anchor; + handlers + } + + fn begin_in_window( + &mut self, + position: Point, + extend: bool, + window: &mut Window, + cx: &mut App, + ) { + self.begin_impl(position, extend, true, Some(window), cx); + } + + fn update_in_window(&mut self, position: Point, window: &Window, cx: &mut App) { + self.update_in_window_with_active_drag(position, cx.has_active_drag(), window, cx); + } + + fn select_at( + &mut self, + position: Point, + click_count: usize, + window: &mut Window, + cx: &mut App, + ) { + GlobalState::init(cx); + if GlobalState::is_text_selection_suppressed(cx) { + return; + } + let hit = self.endpoint(position, Some(window), cx); + if !hit.inside_text { + return; + } + let Some(participant) = hit + .participant + .and_then(|participant| participant.upgrade()) + else { + return; + }; + let points = points_for_multi_click(&participant.read(cx).runs, position, click_count); + let Some((anchor, cursor)) = points else { + return; + }; + let Some(registration) = self.participants.get(&participant.entity_id()) else { + return; + }; + let content_key_resolver = participant.read(cx).content_key_resolver.clone(); + let to_endpoint = |point: Point| { + let content_point = point + - registration.registration.bounds.origin + - registration.registration.scroll_offset; + SelectionEndpoint { + participant: Some(participant.downgrade()), + point: content_point, + inside: true, + inside_text: true, + content_key: None, + content_key_resolver: content_key_resolver + .clone() + .map(|resolver| (resolver, content_point)), + } + }; + self.anchor = Some(to_endpoint(anchor)); + self.cursor = Some(to_endpoint(cursor)); + self.did_hit_text = true; + self.is_selecting = false; + participant.update(cx, |state, cx| state.focus(window, cx)); + self.publish_snapshots(cx); + } + + fn update_in_window_with_active_drag( + &mut self, + position: Point, + active_drag: bool, + window: &Window, + cx: &mut App, + ) { + if !active_drag { + self.update_impl(position, Some(window), cx); + } + } + + fn begin_impl( + &mut self, + position: Point, + extend: bool, + already_prepared: bool, + window: Option<&mut Window>, + cx: &mut App, + ) { + GlobalState::init(cx); + if GlobalState::is_text_selection_suppressed(cx) { + self.pending_extension_anchor = None; + return; + } + let previous_anchor = extend + .then(|| { + self.pending_extension_anchor + .take() + .or_else(|| self.anchor.clone()) + }) + .flatten() + .filter(|anchor| anchor.resolve(&self.participants).is_some()); + if !extend && !already_prepared { + self.clear(cx); + } + let endpoint = self.endpoint(position, window.as_deref(), cx); + let focus_participant = endpoint + .inside + .then(|| endpoint.participant.clone()) + .flatten(); + let anchor = previous_anchor.unwrap_or_else(|| endpoint.clone()); + self.anchor = Some(anchor.clone()); + self.cursor = Some(endpoint.clone()); + self.did_hit_text = anchor.inside_text || endpoint.inside_text; + self.is_selecting = true; + if let Some(participant) = focus_participant.and_then(|participant| participant.upgrade()) { + if let Some(window) = window { + participant.update(cx, |state, cx| state.focus(window, cx)); + } + } + self.publish_snapshots(cx); + } + + fn update_impl(&mut self, position: Point, window: Option<&Window>, cx: &mut App) { + if !self.is_selecting { + return; + } + let endpoint = self.endpoint(position, window, cx); + self.did_hit_text |= endpoint.inside_text; + self.cursor = Some(endpoint); + self.update_anchor_auto_scroll(position, cx); + self.publish_snapshots(cx); + } + + fn endpoint( + &mut self, + position: Point, + window: Option<&Window>, + cx: &App, + ) -> SelectionEndpoint { + self.prune_dead_participants(); + let mut hit: Option<( + WeakEntity, + Rc, + f32, + )> = None; + let mut predecessor: Option<( + WeakEntity, + Rc, + )> = None; + let mut first: Option<( + WeakEntity, + Rc, + )> = None; + + for registration in self.participants.values() { + if registration.registration.scope != self.active_scope + || registration.participant.upgrade().is_none() + { + continue; + } + let participant_geometry = ®istration.registration; + let hovered = window.map_or_else( + || participant_geometry.bounds.contains(&position), + |window| participant_geometry.hitbox.is_hovered(window), + ); + if hovered { + let area = f32::from(participant_geometry.bounds.size.width) + * f32::from(participant_geometry.bounds.size.height); + if hit.as_ref().is_none_or(|(_, best, best_area)| { + area < *best_area + || (area == *best_area + && participant_geometry.document_order < best.document_order) + }) { + hit = Some(( + registration.participant.clone(), + participant_geometry.clone(), + area, + )); + } + } + if participant_geometry.bounds.top() <= position.y + && predecessor.as_ref().is_none_or(|(_, best)| { + participant_geometry.bounds.top() > best.bounds.top() + || (participant_geometry.bounds.top() == best.bounds.top() + && participant_geometry.document_order < best.document_order) + }) + { + predecessor = Some(( + registration.participant.clone(), + participant_geometry.clone(), + )); + } + if first.as_ref().is_none_or(|(_, best)| { + participant_geometry.bounds.top() < best.bounds.top() + || (participant_geometry.bounds.top() == best.bounds.top() + && participant_geometry.document_order < best.document_order) + }) { + first = Some(( + registration.participant.clone(), + participant_geometry.clone(), + )); + } + } + + let selection = hit + .map(|(participant, registration, _)| (participant, registration, true)) + .or_else(|| { + predecessor + .or(first) + .map(|(participant, registration)| (participant, registration, false)) + }); + match selection { + Some((participant, registration, inside)) => { + let point = position - registration.bounds.origin - registration.scroll_offset; + let content_key_resolver = participant.upgrade().and_then(|participant| { + participant + .read(cx) + .content_key_resolver + .clone() + .map(|callback| (callback, point)) + }); + SelectionEndpoint { + point, + participant: Some(participant), + inside, + inside_text: inside + && registration + .text_bounds + .iter() + .any(|bounds| bounds.contains(&position)), + content_key: None, + content_key_resolver, + } + } + None => SelectionEndpoint { + participant: None, + point: position, + inside: false, + inside_text: false, + content_key: None, + content_key_resolver: None, + }, + } + } + + fn publish_snapshots(&mut self, cx: &mut App) { + self.prune_dead_participants(); + let snapshot = self.snapshot(); + let single_participant = self.single_participant(); + for (id, registration) in &self.participants { + let Some(participant) = registration.participant.upgrade() else { + continue; + }; + let participant_snapshot = (registration.registration.scope == self.active_scope + && self.participates(*id, registration) + && single_participant.is_none_or(|single| single == *id)) + .then_some(snapshot) + .flatten() + .map(|mut snapshot| { + snapshot.coverage = self.coverage_for(*id); + snapshot + }); + participant.update(cx, |state, cx| state.set_snapshot(participant_snapshot, cx)); + } + } + + fn coverage_for(&self, id: EntityId) -> TextSelectionCoverage { + let Some(anchor) = self.anchor.as_ref().and_then(SelectionEndpoint::entity_id) else { + return TextSelectionCoverage::Bounded; + }; + let Some(cursor) = self.cursor.as_ref().and_then(SelectionEndpoint::entity_id) else { + return TextSelectionCoverage::Bounded; + }; + if anchor == cursor { + return TextSelectionCoverage::Bounded; + } + let anchor_order = self.participants[&anchor].registration.document_order; + let cursor_order = self.participants[&cursor].registration.document_order; + if id != anchor && id != cursor { + TextSelectionCoverage::Full + } else if (id == anchor) == (anchor_order < cursor_order) { + TextSelectionCoverage::ToEnd + } else { + TextSelectionCoverage::FromStart + } + } + + fn single_participant(&self) -> Option { + let anchor = self.anchor.as_ref()?.entity_id()?; + let cursor = self.cursor.as_ref()?.entity_id()?; + (anchor == cursor).then_some(anchor) + } + + fn participates(&self, id: EntityId, registration: &ParticipantRegistration) -> bool { + let Some(anchor) = self.anchor.as_ref().and_then(SelectionEndpoint::entity_id) else { + return false; + }; + let Some(cursor) = self.cursor.as_ref().and_then(SelectionEndpoint::entity_id) else { + return false; + }; + let Some(anchor_registration) = self.participants.get(&anchor) else { + return false; + }; + let Some(cursor_registration) = self.participants.get(&cursor) else { + return false; + }; + let start = anchor_registration + .registration + .document_order + .min(cursor_registration.registration.document_order); + let end = anchor_registration + .registration + .document_order + .max(cursor_registration.registration.document_order); + (start..=end).contains(®istration.registration.document_order) + || id == anchor + || id == cursor + } + + fn update_anchor_auto_scroll(&self, position: Point, cx: &mut App) { + let Some(anchor) = self.anchor.as_ref().filter(|anchor| anchor.inside) else { + return; + }; + let Some(participant) = anchor.participant.as_ref().and_then(WeakEntity::upgrade) else { + return; + }; + let Some(registration) = self.participants.get(&participant.entity_id()) else { + return; + }; + let delta = AutoScroll::compute_delta(position.y, registration.registration.bounds); + participant.update(cx, |state, cx| state.set_auto_scroll(delta, cx)); + } + + fn stop_anchor_auto_scroll(&self, cx: &mut App) { + let Some(participant) = self + .anchor + .as_ref() + .filter(|anchor| anchor.inside) + .and_then(|anchor| anchor.participant.as_ref()) + .and_then(WeakEntity::upgrade) + else { + return; + }; + participant.update(cx, |state, cx| state.set_auto_scroll(None, cx)); + } + + fn prune_dead_participants(&mut self) { + self.participants + .retain(|_, registration| registration.participant.upgrade().is_some()); + } +} + +#[derive(Default)] +/// Non-owning window locator; retained [`TextSelection`] element state owns +/// each live selection entity. +struct SelectionStateRegistry(HashMap>); + +impl Global for SelectionStateRegistry {} + +#[derive(Default)] +struct PendingTextSelectionScopes(HashMap); + +impl Global for PendingTextSelectionScopes {} + +#[derive(Default)] +struct TextSelectionScopeStacks(HashMap>); + +impl Global for TextSelectionScopeStacks {} + +fn push_text_selection_scope(window_id: gpui::WindowId, scope: TextSelectionScopeId, cx: &mut App) { + if !cx.has_global::() { + cx.set_global(TextSelectionScopeStacks::default()); + } + cx.global_mut::() + .0 + .entry(window_id) + .or_default() + .push(scope); +} + +fn pop_text_selection_scope(window_id: gpui::WindowId, cx: &mut App) { + let stacks = &mut cx.global_mut::().0; + let remove_stack = stacks.get_mut(&window_id).is_some_and(|stack| { + stack.pop(); + stack.is_empty() + }); + if remove_stack { + stacks.remove(&window_id); + } +} + +fn current_text_selection_scope( + window_id: gpui::WindowId, + cx: &App, +) -> Option { + cx.has_global::() + .then(|| { + cx.global::() + .0 + .get(&window_id) + .and_then(|stack| stack.last().copied()) + }) + .flatten() +} + +fn with_text_selection_scope( + window_id: gpui::WindowId, + scope: TextSelectionScopeId, + cx: &mut App, + callback: impl FnOnce(&mut App) -> T, +) -> T { + push_text_selection_scope(window_id, scope, cx); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(cx))); + pop_text_selection_scope(window_id, cx); + match result { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + } +} + +/// Window-level operations for text selection. +pub struct TextSelection; + +impl TextSelection { + /// Returns the currently selected text in logical document order. + pub fn selected_text(window: &mut Window, cx: &mut App) -> String { + let Some(state) = live_text_selection_state(window, cx) else { + return String::new(); + }; + let items = state.read(cx).copy_items(cx); + resolve_copy_items(items, cx) + } + + /// Returns whether the window has a geometry selection or any participant + /// has an active participant-local selection such as select-all. + pub fn has_selection(window: &mut Window, cx: &mut App) -> bool { + live_text_selection_state(window, cx).is_some_and(|state| state.read(cx).has_selection(cx)) + } + + /// Clears window selection and every participant's local selection. + pub fn clear(window: &mut Window, cx: &mut App) { + if let Some(state) = live_text_selection_state(window, cx) { + let handlers = state.update(cx, |state, cx| state.clear_state(cx)); + dispatch_clear_handlers(handlers, cx); + } + } + + /// Clears selection for a known window identifier. + /// + /// Prefer [`Self::clear`] when a window reference is available. This + /// narrow entry point supports hosts retiring deprecated window wrappers. + pub fn clear_for_window(window_id: gpui::WindowId, cx: &mut App) { + clear_window_text_selection(window_id, cx); + } + + /// Ends the current drag while leaving its selection visible. + pub fn end(window: &mut Window, cx: &mut App) { + if let Some(state) = live_text_selection_state(window, cx) { + state.update(cx, |state, cx| state.end(cx)); + } + } + + /// Activates the opaque selection scope for this window. + pub fn activate_scope(scope: TextSelectionScopeId, window: &mut Window, cx: &mut App) { + let Some(state) = WindowSelectionState::existing(window, cx) else { + if !cx.has_global::() { + cx.set_global(PendingTextSelectionScopes::default()); + } + cx.global_mut::() + .0 + .insert(window.window_handle().window_id(), scope); + return; + }; + let handlers = state.update(cx, |state, cx| state.set_active_scope_state(scope, cx)); + dispatch_clear_handlers(handlers, cx); + } +} + +/// A zero-sized root layer which enables text selection for a window. +/// +/// Mount one as the root's first child. Its stable `"window-text-selection"` +/// element identity retains the window-local selection entity across frames. +pub struct TextSelectionLayer; + +pub(crate) fn text_selection_scope( + scope: TextSelectionScopeId, + element: impl IntoElement, +) -> impl IntoElement { + TextSelectionScopeMarker { + scope, + element: element.into_element(), + } +} + +struct TextSelectionScopeMarker { + scope: TextSelectionScopeId, + element: E, +} + +impl IntoElement for TextSelectionScopeMarker { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TextSelectionScopeMarker { + type RequestLayoutState = E::RequestLayoutState; + type PrepaintState = E::PrepaintState; + + fn id(&self) -> Option { + self.element.id() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + self.element.source_location() + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let window_id = window.window_handle().window_id(); + with_text_selection_scope(window_id, self.scope, cx, |cx| { + self.element.request_layout(id, inspector_id, window, cx) + }) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let window_id = window.window_handle().window_id(); + with_text_selection_scope(window_id, self.scope, cx, |cx| { + self.element + .prepaint(id, inspector_id, bounds, request_layout, window, cx) + }) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let window_id = window.window_handle().window_id(); + with_text_selection_scope(window_id, self.scope, cx, |cx| { + self.element.paint( + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + }); + } +} + +#[doc(hidden)] +pub struct TextSelectionLayerPrepaintState(Entity); + +impl IntoElement for TextSelectionLayer { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TextSelectionLayer { + type RequestLayoutState = (); + type PrepaintState = TextSelectionLayerPrepaintState; + + fn id(&self) -> Option { + Some("window-text-selection".into()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + TextSelectionLayerPrepaintState(retain_text_selection_state(global_id, window, cx)) + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + state: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + paint_text_selection(&state.0, window, cx); + } +} + +fn retain_text_selection_state( + global_id: Option<&GlobalElementId>, + window: &mut Window, + cx: &mut App, +) -> Entity { + let window_id = window.window_handle().window_id(); + let state = window.with_element_state::, _>( + global_id.expect("TextSelection has a stable element id"), + |retained, _| { + let state = retained.unwrap_or_else(|| WindowSelectionState::acquire(window_id, cx)); + (state.clone(), state) + }, + ); + if !cx.has_global::() { + cx.set_global(SelectionStateRegistry::default()); + } + cx.global_mut::() + .0 + .insert(window_id, state.downgrade()); + state +} + +fn paint_text_selection(state: &Entity, window: &mut Window, cx: &mut App) { + if state.update(cx, |state, _| state.schedule_finish_frame()) { + let state = state.downgrade(); + window.on_next_frame(move |_, cx| { + let Some(state) = state.upgrade() else { + return; + }; + let handlers = state.update(cx, |state, cx| state.finish_frame(cx)); + dispatch_clear_handlers(handlers, cx); + }); + } + + let mouse_down_state = state.downgrade(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if event.button != MouseButton::Left { + return; + } + let Some(state) = mouse_down_state.upgrade() else { + return; + }; + if phase.capture() { + GlobalState::init(cx); + GlobalState::reset_text_selection_suppression(cx); + let handlers = state.update(cx, |state, cx| { + if state.mouse_down_prepared { + return Vec::new(); + } + state.mouse_down_prepared = true; + state.prepare_for_mouse_down(event.click_count == 1 && event.modifiers.shift, cx) + }); + dispatch_clear_handlers(handlers, cx); + } else if event.click_count == 1 { + if GlobalState::is_text_selection_suppressed(cx) { + state.update(cx, |state, _| state.pending_extension_anchor = None); + return; + } + state.update(cx, |state, cx| { + if !state.is_selecting { + state.begin_in_window(event.position, event.modifiers.shift, window, cx) + } + }); + WindowSelectionState::resolve_content_keys(&state, cx); + } else if event.click_count >= 2 { + if GlobalState::is_text_selection_suppressed(cx) { + return; + } + state.update(cx, |state, cx| { + state.select_at(event.position, event.click_count, window, cx) + }); + WindowSelectionState::resolve_content_keys(&state, cx); + } + }); + + let mouse_move_state = state.downgrade(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { + if phase.bubble() + && let Some(state) = mouse_move_state.upgrade() + { + state.update(cx, |state, cx| { + state.update_in_window(event.position, window, cx) + }); + WindowSelectionState::resolve_content_keys(&state, cx); + } + }); + + let mouse_up_state = state.downgrade(); + window.on_mouse_event(move |_: &MouseUpEvent, phase, _, cx| { + if phase.bubble() + && let Some(state) = mouse_up_state.upgrade() + { + state.update(cx, |state, cx| { + state.mouse_down_prepared = false; + state.end(cx) + }); + } + }); + + let scroll_state = state.downgrade(); + window.on_mouse_event(move |_: &ScrollWheelEvent, phase, window, cx| { + if phase.bubble() + && let Some(state) = scroll_state.upgrade() + { + let position = window.mouse_position(); + state.update(cx, |state, cx| state.update_in_window(position, window, cx)); + WindowSelectionState::resolve_content_keys(&state, cx); + } + }); +} + +fn live_text_selection_state( + window: &Window, + cx: &mut App, +) -> Option> { + WindowSelectionState::existing(window, cx) +} + +pub(crate) fn clear_window_text_selection(window_id: gpui::WindowId, cx: &mut App) { + if !cx.has_global::() { + return; + } + let Some(state) = cx + .global::() + .0 + .get(&window_id) + .and_then(WeakEntity::upgrade) + else { + return; + }; + let handlers = state.update(cx, |state, cx| state.clear_state(cx)); + dispatch_clear_handlers(handlers, cx); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ElementExt as _; + use gpui::{ + Bounds, ContentMask, Context, Hitbox, HitboxBehavior, HitboxId, InteractiveElement as _, + IntoElement, ParentElement as _, Render, SharedString, Styled as _, StyledText, + TestAppContext, TextLayout, Window, div, point, prelude::FluentBuilder as _, px, size, + }; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; + + struct FakeParticipant { + selection: TextSelectionHandle, + } + + struct WindowSelectionView { + selection: TextSelectionHandle, + } + + struct SelectionElementOnlyView; + struct ToggleSelectionElementView { + enabled: bool, + selection: TextSelectionHandle, + } + + struct DoubleSelectionElementView { + selection: TextSelectionHandle, + } + + struct WindowOwnedSelectionView { + selection: TextSelectionHandle, + } + + struct FirstFrameScopedSelectionView { + selection: TextSelectionHandle, + } + + struct PlainRunLayoutView { + texts: Vec, + layouts: Vec, + } + + impl Render for WindowSelectionView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + impl Render for SelectionElementOnlyView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .child(TextSelectionLayer) + .child( + div() + .size_full() + .on_mouse_down(MouseButton::Left, |_, _, cx| { + GlobalState::suppress_text_selection(cx); + }), + ) + } + } + + impl Render for ToggleSelectionElementView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let selection = self.selection.clone(); + div().when(self.enabled, |this| { + this.child(TextSelectionLayer) + .child(div().size_full().on_prepaint(move |bounds, window, cx| { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + selection.register( + TextSelectionRegistration::new(hitbox, bounds) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + })) + }) + } + } + + impl Render for DoubleSelectionElementView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let selection = self.selection.clone(); + div() + .size_full() + .child(TextSelectionLayer) + .child(TextSelectionLayer) + .on_prepaint(move |bounds, window, cx| { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + selection.register( + TextSelectionRegistration::new(hitbox, bounds) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + }) + } + } + + impl Render for WindowOwnedSelectionView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let selection = self.selection.clone(); + div() + .size_full() + .child(TextSelectionLayer) + .child(div().size_full().on_prepaint(move |bounds, window, cx| { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + selection.register( + TextSelectionRegistration::new(hitbox, bounds) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + })) + } + } + + impl Render for FirstFrameScopedSelectionView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let scope = TextSelectionScopeId::from_raw(23); + TextSelection::activate_scope(scope, window, cx); + let selection = self.selection.clone(); + + div().child(TextSelectionLayer).child( + div() + .size_full() + .on_prepaint(move |bounds, window, cx| { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + selection.register( + TextSelectionRegistration::new(hitbox, bounds) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + }) + .text_selection_scope(scope), + ) + } + } + + impl Render for PlainRunLayoutView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.layouts.clear(); + let children = self + .texts + .iter() + .enumerate() + .map(|(index, text)| { + let text = StyledText::new(text.clone()); + self.layouts.push(text.layout().clone()); + div().absolute().top(px(index as f32 * 40.)).child(text) + }) + .collect::>(); + div().size_full().children(children) + } + } + + impl FakeParticipant { + fn new(text: &str, cx: &mut gpui::App) -> Self { + let selection = TextSelectionHandle::new(text, cx); + Self { selection } + } + + fn register( + &self, + selection_state: &mut WindowSelectionState, + y: f32, + scope: TextSelectionScopeId, + document_order: u64, + cx: &mut gpui::App, + ) { + let bounds = Bounds::new(point(px(0.), px(y)), size(px(100.), px(10.))); + selection_state.register_participant( + self.selection.clone(), + TextSelectionRegistration::new( + Hitbox { + id: HitboxId::placeholder(), + bounds, + content_mask: ContentMask { bounds }, + behavior: HitboxBehavior::Normal, + }, + bounds, + ) + .with_scope(scope) + .with_document_order(document_order) + .with_text_bounds(vec![bounds]), + cx, + ); + } + } + + fn laid_out_runs(texts: &[&str], cx: &mut TestAppContext) -> Vec<(SharedString, TextLayout)> { + let texts = texts + .iter() + .map(|text| SharedString::from(*text)) + .collect::>(); + let view = cx.add_window({ + let texts = texts.clone(); + move |_, _| PlainRunLayoutView { + texts, + layouts: Vec::new(), + } + }); + cx.update_window(*view, |_, window, cx| { + let _ = window.draw(cx); + }) + .unwrap(); + let layouts = cx.update(|cx| view.read(cx).unwrap().layouts.clone()); + texts.into_iter().zip(layouts).collect() + } + + fn plain_snapshot(anchor: Point, cursor: Point) -> TextSelectionSnapshot { + TextSelectionSnapshot::new( + TextSelectionEndpoint::new(None, anchor), + TextSelectionEndpoint::new(None, cursor), + ) + .with_window_points(Some(TextSelectionWindowPoints { anchor, cursor })) + } + + #[gpui::test] + fn scope_stack_is_cleaned_after_panicking_subtree(cx: &mut TestAppContext) { + let window_id = { + let (_, window_cx) = cx.add_window_view(|_, _| SelectionElementOnlyView); + window_cx.update(|window, _| window.window_handle().window_id()) + }; + let scope = TextSelectionScopeId::from_raw(41); + + cx.update(|cx| { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + with_text_selection_scope(window_id, scope, cx, |_| panic!("subtree failed")); + })); + + assert!(result.is_err()); + assert_eq!(current_text_selection_scope(window_id, cx), None); + }); + } + + #[gpui::test] + fn reentrant_scope_from_one_window_does_not_pollute_another(cx: &mut TestAppContext) { + let first_window_id = { + let (_, window_cx) = cx.add_window_view(|_, _| SelectionElementOnlyView); + window_cx.update(|window, _| window.window_handle().window_id()) + }; + let second_window_id = { + let (_, window_cx) = cx.add_window_view(|_, _| SelectionElementOnlyView); + window_cx.update(|window, _| window.window_handle().window_id()) + }; + let scope = TextSelectionScopeId::from_raw(42); + + cx.update(|cx| { + with_text_selection_scope(first_window_id, scope, cx, |cx| { + assert_eq!(current_text_selection_scope(second_window_id, cx), None); + assert_eq!( + current_text_selection_scope(first_window_id, cx), + Some(scope) + ); + }); + }); + } + + #[gpui::test] + fn selection_callback_can_reenter_its_selection_state(cx: &mut TestAppContext) { + let called = Rc::new(Cell::new(false)); + let called_from_callback = called.clone(); + let (selection_state, participant) = cx.update(|cx| { + let selection_state = cx.new(|_| WindowSelectionState::default()); + let selection_state_for_callback = selection_state.clone(); + let participant = FakeParticipant::new("participant", cx); + participant + .selection + .subscribe( + move |event, cx| { + if matches!(event, TextSelectionEvent::SelectionChanged(Some(_))) { + selection_state_for_callback + .update(cx, |_, _| called_from_callback.set(true)); + } + }, + cx, + ) + .detach(); + (selection_state, participant) + }); + cx.run_until_parked(); + cx.update(|cx| { + selection_state.update(cx, |selection_state, cx| { + participant.register(selection_state, 0., TextSelectionScopeId::default(), 0, cx); + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(20.), px(1.)), cx); + }); + }); + cx.run_until_parked(); + assert!(called.get()); + } + + #[gpui::test] + fn selection_events_preserve_snapshot_then_clear_order(cx: &mut TestAppContext) { + let observed = Rc::new(RefCell::new(Vec::new())); + let observed_for_callback = observed.clone(); + let selection = cx.update(|cx| { + let selection = TextSelectionHandle::new("selection", cx); + selection + .subscribe( + move |event, _| { + if let TextSelectionEvent::SelectionChanged(snapshot) = event { + observed_for_callback.borrow_mut().push(snapshot.is_some()); + } + }, + cx, + ) + .detach(); + selection + }); + cx.run_until_parked(); + cx.update(|cx| { + selection.0.update(cx, |state, cx| { + state.set_snapshot( + Some(plain_snapshot(point(px(1.), px(1.)), point(px(8.), px(1.)))), + cx, + ); + state.clear_state(cx); + }); + }); + cx.run_until_parked(); + assert_eq!(&*observed.borrow(), &[true, false]); + } + + fn text_run(order: u64, text: SharedString, layout: TextLayout) -> TextSelectionRun { + let bounds = layout.bounds(); + TextSelectionRun::new(text, layout, bounds).with_document_order(order) + } + + #[gpui::test] + fn public_selection_data_uses_builders_and_readers(cx: &mut TestAppContext) { + let bounds = Bounds::new(point(px(1.), px(2.)), size(px(30.), px(10.))); + let hitbox = Hitbox { + id: HitboxId::placeholder(), + bounds, + content_mask: ContentMask { bounds }, + behavior: HitboxBehavior::Normal, + }; + let scope = TextSelectionScopeId::from_raw(7); + let endpoint = TextSelectionEndpoint::new(None, bounds.origin) + .with_content_key(TextSelectionContentKey::new(11)); + let snapshot = TextSelectionSnapshot::new(endpoint, endpoint) + .with_selecting(true) + .with_window_points(Some(TextSelectionWindowPoints { + anchor: bounds.origin, + cursor: bounds.bottom_right(), + })) + .with_coverage(TextSelectionCoverage::Full); + let registration = TextSelectionRegistration::new(hitbox, bounds) + .with_scroll_offset(point(px(3.), px(4.))) + .with_scope(scope) + .with_document_order(9) + .with_text_bounds(vec![bounds]); + + assert_eq!(endpoint.entity_id(), None); + assert_eq!(endpoint.content_point(), bounds.origin); + assert_eq!( + endpoint.content_key(), + Some(TextSelectionContentKey::new(11)) + ); + assert_eq!(snapshot.anchor(), endpoint); + assert_eq!(snapshot.cursor(), endpoint); + assert!(snapshot.is_selecting()); + assert_eq!(snapshot.coverage(), TextSelectionCoverage::Full); + assert_eq!( + snapshot.window_points(), + Some(TextSelectionWindowPoints { + anchor: bounds.origin, + cursor: bounds.bottom_right(), + }) + ); + assert_eq!(registration.bounds(), bounds); + assert_eq!(registration.scroll_offset(), point(px(3.), px(4.))); + assert_eq!(registration.scope(), scope); + assert_eq!(registration.document_order(), 9); + assert_eq!(registration.text_bounds(), &[bounds]); + + let (text, layout) = laid_out_runs(&["aé"], cx).pop().unwrap(); + let text_run = TextSelectionRun::new(text.clone(), layout.clone(), layout.bounds()) + .with_document_order(3); + assert_eq!(text_run.document_order(), 3); + assert_eq!(text_run.text(), &text); + assert_eq!(text_run.layout().len(), layout.len()); + assert_eq!(text_run.bounds(), layout.bounds()); + + let projection = TextSelectionProjection { + ranges: vec![Some(1..3)], + is_active: true, + }; + assert_eq!(projection.ranges(), &[Some(1..3)]); + assert!(projection.is_active()); + } + + #[gpui::test] + fn selection_handle_is_the_public_adapter_seam(cx: &mut TestAppContext) { + let selected = Rc::new(Cell::new(false)); + let selected_from_callback = selected.clone(); + cx.update(|cx| { + let selection = TextSelectionHandle::new("initial", cx); + let entity_id = selection.entity_id(); + selection.set_fallback_copy_text("updated", cx); + selection.set_local_selection(true, cx); + selection + .subscribe( + move |event, _| { + if let TextSelectionEvent::SelectionChanged(snapshot) = event { + selected_from_callback.set(snapshot.is_some()); + } + }, + cx, + ) + .detach(); + selection.focus_with(|_, _| {}, cx); + selection.copy_with(|_| "copied".to_string(), cx); + selection.resolve_content_key_with(|_, _| Some(TextSelectionContentKey::new(3)), cx); + + assert_eq!(selection.entity_id(), entity_id); + assert_eq!(selection.snapshot(cx), None); + assert_eq!( + selection.update_runs(&[], cx), + TextSelectionProjection::default() + ); + }); + assert!(!selected.get()); + } + + #[gpui::test] + fn selection_handle_can_subscribe_its_window_to_refresh(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, cx| WindowSelectionView { + selection: TextSelectionHandle::new("refresh", cx), + }); + cx.update(|window, cx| { + let selection = TextSelectionHandle::new("refresh", cx); + selection.refresh_window_on_change(window, cx).detach(); + }); + } + + #[gpui::test] + fn plain_projection_preserves_forward_reversed_and_unicode_ranges(cx: &mut TestAppContext) { + let (text, layout) = laid_out_runs(&["aé🙂z"], cx).pop().unwrap(); + let run = text_run(0, text, layout.clone()); + let start = layout.position_for_index(1).unwrap(); + let end = layout.position_for_index(7).unwrap(); + + let forward = project_ranges(Some(plain_snapshot(start, end)), std::slice::from_ref(&run)); + let reversed = project_ranges(Some(plain_snapshot(end, start)), &[run]); + + assert_eq!(forward.ranges(), &[Some(1..7)]); + assert_eq!(reversed.ranges(), &[Some(1..7)]); + assert!(forward.is_active()); + assert!(reversed.is_active()); + } + + #[gpui::test] + fn double_click_expands_a_plain_run_to_the_input_word_boundary(cx: &mut TestAppContext) { + let (text, layout) = laid_out_runs(&["one café, three"], cx).pop().unwrap(); + let run = text_run(0, text, layout.clone()); + let click = layout.position_for_index(6).unwrap(); + + let (anchor, cursor) = + points_for_multi_click(std::slice::from_ref(&run), click, 2).unwrap(); + let states = project_ranges(Some(plain_snapshot(anchor, cursor)), &[run]); + + assert_eq!(states.ranges(), &[Some(4..9)]); + } + + #[gpui::test] + fn multi_click_uses_text_layout_window_coordinates_at_a_nonzero_origin( + cx: &mut TestAppContext, + ) { + let mut runs = laid_out_runs(&["above", "alpha beta"], cx); + let (text, layout) = runs.pop().unwrap(); + assert!(layout.bounds().origin.y > px(0.)); + let run = text_run(0, text, layout.clone()); + let click = layout.position_for_index(7).unwrap(); + + let (anchor, cursor) = + points_for_multi_click(std::slice::from_ref(&run), click, 2).unwrap(); + let projection = project_ranges(Some(plain_snapshot(anchor, cursor)), &[run]); + + assert_eq!(projection.ranges(), &[Some(6..10)]); + } + + #[gpui::test] + fn triple_click_expands_to_the_input_logical_line_not_the_visual_row(cx: &mut TestAppContext) { + let (text, layout) = laid_out_runs(&["second line"], cx).pop().unwrap(); + let run = text_run(0, text, layout.clone()); + let click = layout.position_for_index(4).unwrap(); + + let (anchor, cursor) = + points_for_multi_click(std::slice::from_ref(&run), click, 4).unwrap(); + let states = project_ranges(Some(plain_snapshot(anchor, cursor)), &[run]); + + assert_eq!(states.ranges(), &[Some(0..11)]); + assert_eq!(line_range_at("first line\nsecond line\nthird", 15), 11..22); + } + + #[gpui::test] + fn plain_projection_spans_multiple_runs_and_leaves_empty_gutters_unselected( + cx: &mut TestAppContext, + ) { + let mut runs = laid_out_runs(&["first", "", "second"], cx); + let (first_text, first_layout) = runs.remove(0); + let (gutter_text, gutter_layout) = runs.remove(0); + let (second_text, second_layout) = runs.remove(0); + let start = first_layout.position_for_index(2).unwrap(); + let end = second_layout.position_for_index(3).unwrap(); + let states = project_ranges( + Some(plain_snapshot(start, end)), + &[ + text_run(2, second_text, second_layout), + text_run(1, gutter_text, gutter_layout), + text_run(0, first_text, first_layout), + ], + ); + + assert_eq!(states.ranges(), &[Some(0..3), None, Some(2..5)]); + assert!(states.is_active()); + } + + #[gpui::test] + fn plain_projection_caches_multiple_participant_copies_in_document_order( + cx: &mut TestAppContext, + ) { + let mut runs = laid_out_runs(&["one", "two"], cx); + let (first_text, first_layout) = runs.remove(0); + let (second_text, second_layout) = runs.remove(0); + let snapshot = plain_snapshot( + first_layout.position_for_index(1).unwrap(), + second_layout.position_for_index(2).unwrap(), + ); + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let first = FakeParticipant::new("", cx); + let second = FakeParticipant::new("", cx); + first.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 1, + cx, + ); + second.register( + &mut selection_state, + 20., + TextSelectionScopeId::default(), + 0, + cx, + ); + + first + .selection + .0 + .update(cx, |state, cx| state.set_snapshot(Some(snapshot), cx)); + let projection = first + .selection + .update_runs(&[text_run(0, first_text, first_layout)], cx); + assert_eq!(projection.ranges(), &[Some(1..3)]); + assert!(projection.is_active()); + second + .selection + .0 + .update(cx, |state, cx| state.set_snapshot(Some(snapshot), cx)); + let projection = second + .selection + .update_runs(&[text_run(0, second_text, second_layout)], cx); + assert_eq!(projection.ranges(), &[Some(0..2)]); + assert!(projection.is_active()); + + assert_eq!(selection_state.selected_text(cx), "tw\nne"); + }); + } + + #[gpui::test] + fn plain_projection_invalidates_cached_copy_when_the_snapshot_changes(cx: &mut TestAppContext) { + let (text, layout) = laid_out_runs(&["first"], cx).pop().unwrap(); + let first_snapshot = plain_snapshot( + layout.position_for_index(1).unwrap(), + layout.position_for_index(3).unwrap(), + ); + let changed_snapshot = plain_snapshot( + layout.position_for_index(3).unwrap(), + layout.position_for_index(5).unwrap(), + ); + let run = text_run(0, text, layout); + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("", cx); + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + participant.selection.0.update(cx, |state, cx| { + state.set_snapshot(Some(first_snapshot), cx); + state.update_runs(std::slice::from_ref(&run)); + }); + assert_eq!(selection_state.selected_text(cx), "ir"); + + participant.selection.0.update(cx, |state, cx| { + state.set_snapshot(Some(changed_snapshot), cx); + }); + assert_eq!(selection_state.selected_text(cx), ""); + + participant.selection.update_runs(&[run], cx); + assert_eq!(selection_state.selected_text(cx), "st"); + selection_state.clear(cx); + participant.selection.set_local_selection(true, cx); + assert_eq!(selection_state.selected_text(cx), ""); + }); + } + + #[gpui::test] + fn plain_projection_orders_cached_runs_by_frame_order_not_input_order(cx: &mut TestAppContext) { + let mut runs = laid_out_runs(&["one", "two"], cx); + let (first_text, first_layout) = runs.remove(0); + let (second_text, second_layout) = runs.remove(0); + let snapshot = plain_snapshot( + first_layout.position_for_index(1).unwrap(), + second_layout.position_for_index(2).unwrap(), + ); + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("", cx); + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + participant.selection.0.update(cx, |state, cx| { + state.set_snapshot(Some(snapshot), cx); + state.update_runs(&[ + text_run(1, first_text, first_layout), + text_run(0, second_text, second_layout), + ]); + }); + + assert_eq!(selection_state.selected_text(cx), "twne"); + }); + } + + #[gpui::test] + fn plain_projection_safely_rejects_a_text_layout_length_mismatch(cx: &mut TestAppContext) { + let (_, layout) = laid_out_runs(&["short"], cx).pop().unwrap(); + let start = layout.position_for_index(0).unwrap(); + let end = layout.position_for_index(5).unwrap(); + let states = project_ranges( + Some(plain_snapshot(start, end)), + &[text_run(0, SharedString::from("longer"), layout)], + ); + + assert_eq!(states.ranges(), &[None]); + assert!(states.is_active()); + } + + #[gpui::test] + fn begin_update_and_end_publish_a_cross_participant_selection(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let first = FakeParticipant::new("first", cx); + let second = FakeParticipant::new("second", cx); + first.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + second.register( + &mut selection_state, + 20., + TextSelectionScopeId::default(), + 1, + cx, + ); + + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(1.), px(25.)), cx); + assert!(selection_state.has_selection(cx)); + assert_eq!(selection_state.selected_text(cx), "first\nsecond"); + + selection_state.end(cx); + assert!(!selection_state.is_selecting()); + }); + } + + #[gpui::test] + fn shift_extension_keeps_its_original_anchor_when_reversed(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("participant", cx); + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + + selection_state.begin(point(px(2.), px(2.)), false, cx); + selection_state.end(cx); + selection_state.begin(point(px(8.), px(2.)), true, cx); + selection_state.end(cx); + let first_anchor = selection_state.snapshot().unwrap().anchor(); + + selection_state.begin(point(px(0.), px(2.)), true, cx); + selection_state.end(cx); + let reversed = selection_state.snapshot().unwrap(); + assert_eq!(reversed.anchor(), first_anchor); + assert!(reversed.cursor().content_point().x < reversed.anchor().content_point().x); + }); + } + + #[gpui::test] + fn content_key_resolver_runs_outside_the_window_state_lease(cx: &mut TestAppContext) { + cx.update(|cx| { + let state = cx.new(|_| WindowSelectionState::default()); + let participant = FakeParticipant::new("virtual", cx); + let state_for_callback = state.clone(); + participant.selection.resolve_content_key_with( + move |_, cx| { + let _ = state_for_callback.read(cx).snapshot(); + Some(TextSelectionContentKey::new(7)) + }, + cx, + ); + state.update(cx, |state, cx| { + participant.register(state, 0., TextSelectionScopeId::default(), 0, cx); + state.begin(point(px(1.), px(1.)), false, cx); + state.update(point(px(8.), px(1.)), cx); + }); + + WindowSelectionState::resolve_content_keys(&state, cx); + + assert_eq!( + state.read(cx).snapshot().unwrap().cursor().content_key(), + Some(TextSelectionContentKey::new(7)) + ); + }); + } + + #[gpui::test] + fn active_dnd_does_not_move_a_text_selection_cursor(cx: &mut TestAppContext) { + let window = cx.add_window(|_, cx| WindowSelectionView { + selection: TextSelectionHandle::new("unused", cx), + }); + window + .update(cx, |_, window, cx| { + let mut state = WindowSelectionState::default(); + let participant = FakeParticipant::new("participant", cx); + participant.register(&mut state, 0., TextSelectionScopeId::default(), 0, cx); + state.begin(point(px(1.), px(1.)), false, cx); + let before = state.cursor.as_ref().unwrap().point; + state.update_in_window_with_active_drag(point(px(80.), px(1.)), true, window, cx); + assert_eq!(state.cursor.as_ref().unwrap().point, before); + }) + .unwrap(); + } + + #[gpui::test] + fn shift_extension_falls_back_when_the_anchor_participant_was_swept(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let first = FakeParticipant::new("first", cx); + let second = FakeParticipant::new("second", cx); + first.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + selection_state.end(cx); + + selection_state.finish_frame(cx); + selection_state.finish_frame(cx); + second.register( + &mut selection_state, + 20., + TextSelectionScopeId::default(), + 1, + cx, + ); + selection_state.begin(point(px(1.), px(21.)), true, cx); + selection_state.update(point(px(8.), px(21.)), cx); + selection_state.end(cx); + + assert_eq!(selection_state.selected_text(cx), "second"); + }); + } + + #[gpui::test] + fn scope_and_suppression_prevent_unrelated_participants_from_participating( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let base = FakeParticipant::new("base", cx); + let modal = FakeParticipant::new("modal", cx); + base.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + modal.register(&mut selection_state, 20., TextSelectionScopeId(1), 1, cx); + + selection_state.set_active_scope(TextSelectionScopeId(1), cx); + selection_state.begin(point(px(1.), px(21.)), false, cx); + selection_state.update(point(px(8.), px(21.)), cx); + selection_state.end(cx); + assert_eq!(selection_state.selected_text(cx), "modal"); + + selection_state.clear(cx); + GlobalState::init(cx); + GlobalState::suppress_text_selection(cx); + selection_state.begin(point(px(1.), px(21.)), false, cx); + selection_state.update(point(px(8.), px(21.)), cx); + assert!(!selection_state.has_selection(cx)); + }); + } + + #[gpui::test] + fn dead_participants_are_pruned_and_empty_selection_falls_back_safely(cx: &mut TestAppContext) { + let selection_state = cx.update(|cx| { + let selection_state = cx.new(|_| WindowSelectionState::default()); + let participant = FakeParticipant::new("gone", cx); + selection_state.update(cx, |selection_state, cx| { + participant.register(selection_state, 0., TextSelectionScopeId::default(), 0, cx) + }); + selection_state + }); + cx.update(|cx| { + selection_state.update(cx, |selection_state, cx| { + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + selection_state.end(cx); + + assert_eq!(selection_state.selected_text(cx), ""); + assert!(!selection_state.has_selection(cx)); + }); + }); + } + + #[gpui::test] + fn text_selection_namespace_reports_copies_ends_and_clears_selection(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| WindowSelectionView { + selection: TextSelectionHandle::new("copied", cx), + }); + cx.update(|window, cx| { + let selection = view.read(cx).selection.clone(); + let selection_state = WindowSelectionState::ensure(window, cx); + selection_state.update(cx, |selection_state, cx| { + FakeParticipant { selection }.register( + selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + }); + + assert!(TextSelection::has_selection(window, cx)); + assert_eq!(TextSelection::selected_text(window, cx), "copied"); + TextSelection::end(window, cx); + assert!(TextSelection::has_selection(window, cx)); + TextSelection::clear(window, cx); + assert!(!TextSelection::has_selection(window, cx)); + assert_eq!(TextSelection::selected_text(window, cx), ""); + }); + } + + #[gpui::test] + fn two_windows_isolate_selection_copy_clear_and_release_ownership(cx: &mut TestAppContext) { + let first = cx.add_window(|_, cx| WindowOwnedSelectionView { + selection: TextSelectionHandle::new("first", cx), + }); + let second = cx.add_window(|_, cx| WindowOwnedSelectionView { + selection: TextSelectionHandle::new("second", cx), + }); + let first_selection = cx.update(|cx| first.read(cx).unwrap().selection.clone()); + let second_selection = cx.update(|cx| second.read(cx).unwrap().selection.clone()); + + let first_state = cx + .update_window(*first, |_, window, cx| { + let _ = window.draw(cx); + first_selection.set_local_selection(true, cx); + assert_eq!(TextSelection::selected_text(window, cx), "first"); + WindowSelectionState::existing(window, cx) + .unwrap() + .downgrade() + }) + .unwrap(); + cx.update_window(*second, |_, window, cx| { + let _ = window.draw(cx); + second_selection.set_local_selection(true, cx); + assert_eq!(TextSelection::selected_text(window, cx), "second"); + }) + .unwrap(); + + cx.update_window(*first, |_, window, cx| { + TextSelection::clear(window, cx); + assert_eq!(TextSelection::selected_text(window, cx), ""); + }) + .unwrap(); + cx.update_window(*second, |_, window, cx| { + assert_eq!(TextSelection::selected_text(window, cx), "second"); + }) + .unwrap(); + + cx.update_window(*first, |_, window, _| window.remove_window()) + .unwrap(); + cx.run_until_parked(); + + assert!(first_state.upgrade().is_none()); + cx.update_window(*second, |_, window, cx| { + assert_eq!(TextSelection::selected_text(window, cx), "second"); + }) + .unwrap(); + cx.update(|cx| { + assert_eq!(cx.global::().0.len(), 1); + }); + } + + #[gpui::test] + fn copy_callback_can_reenter_window_and_handle_selection(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| SelectionElementOnlyView); + cx.update(|window, cx| { + let _ = window.draw(cx); + let state = WindowSelectionState::existing(window, cx).unwrap(); + let selection = TextSelectionHandle::new("fallback", cx); + let state_for_copy = state.clone(); + let selection_for_copy = selection.clone(); + selection.copy_with( + move |cx: &mut App| { + state_for_copy.update(cx, |state, _| { + assert!(state.snapshot().is_some()); + }); + assert!(selection_for_copy.snapshot(cx).is_some()); + selection_for_copy.set_fallback_copy_text("reentered", cx); + "reentrant copy".to_string() + }, + cx, + ); + state.update(cx, |state, cx| { + FakeParticipant { + selection: selection.clone(), + } + .register(state, 0., TextSelectionScopeId::default(), 0, cx); + state.begin(point(px(1.), px(1.)), false, cx); + state.update(point(px(8.), px(1.)), cx); + state.end(cx); + }); + + assert_eq!(TextSelection::selected_text(window, cx), "reentrant copy"); + }); + } + + #[gpui::test] + fn cross_participant_selection_excludes_participants_outside_its_document_interval( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let first = FakeParticipant::new("first", cx); + let second = FakeParticipant::new("second", cx); + let third = FakeParticipant::new("third", cx); + first.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + second.register( + &mut selection_state, + 20., + TextSelectionScopeId::default(), + 1, + cx, + ); + third.register( + &mut selection_state, + 40., + TextSelectionScopeId::default(), + 2, + cx, + ); + + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(1.), px(25.)), cx); + selection_state.end(cx); + + assert_eq!(selection_state.selected_text(cx), "first\nsecond"); + assert!(third.selection.snapshot(cx).is_none()); + }); + } + + #[gpui::test] + fn changing_scope_clears_the_previous_scope_selection(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let base = FakeParticipant::new("base", cx); + let modal = FakeParticipant::new("modal", cx); + base.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + modal.register( + &mut selection_state, + 20., + TextSelectionScopeId::from_raw(1), + 1, + cx, + ); + + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + selection_state.end(cx); + selection_state.set_active_scope(TextSelectionScopeId::from_raw(1), cx); + + assert!(!selection_state.has_selection(cx)); + assert!(base.selection.snapshot(cx).is_none()); + }); + } + + #[gpui::test] + fn blank_only_drag_never_publishes_or_copies_selection(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("participant", cx); + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + + selection_state.begin(point(px(200.), px(1.)), false, cx); + selection_state.update(point(px(200.), px(8.)), cx); + selection_state.end(cx); + + assert!(!selection_state.has_selection(cx)); + assert_eq!(selection_state.selected_text(cx), ""); + assert!(participant.selection.snapshot(cx).is_none()); + }); + } + + #[gpui::test] + fn stale_live_participants_are_removed_when_the_next_frame_begins(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("stale", cx); + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + selection_state.end(cx); + + selection_state.finish_frame(cx); + selection_state.finish_frame(cx); + assert_eq!(selection_state.selected_text(cx), ""); + assert!(participant.selection.snapshot(cx).is_none()); + }); + } + + #[gpui::test] + fn clear_stops_anchor_auto_scroll_before_discarding_the_anchor(cx: &mut TestAppContext) { + let commands = Rc::new(RefCell::new(Vec::new())); + let observed = commands.clone(); + let (mut selection_state, participant) = cx.update(|cx| { + let selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("scroll", cx); + participant + .selection + .subscribe( + move |event, _| { + if let TextSelectionEvent::AutoScroll(delta) = event { + observed.borrow_mut().push(*delta); + } + }, + cx, + ) + .detach(); + (selection_state, participant) + }); + cx.run_until_parked(); + cx.update(|cx| { + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(1.), px(25.)), cx); + selection_state.clear(cx); + }); + cx.run_until_parked(); + assert!(commands.borrow().iter().any(Option::is_some)); + assert_eq!(commands.borrow().last(), Some(&None)); + } + + #[gpui::test] + fn proxy_endpoints_break_equal_position_ties_by_document_order(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let later = FakeParticipant::new("later", cx); + let earlier = FakeParticipant::new("earlier", cx); + later.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 2, + cx, + ); + earlier.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 1, + cx, + ); + + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(200.), px(25.)), cx); + let endpoint = selection_state.snapshot().unwrap().cursor(); + + assert_eq!(endpoint.entity_id(), Some(earlier.selection.entity_id())); + }); + } + + #[gpui::test] + fn equal_area_hovered_participants_break_ties_by_document_order(cx: &mut TestAppContext) { + cx.update(|cx| { + for _ in 0..64 { + let mut selection_state = WindowSelectionState::default(); + let later = FakeParticipant::new("later", cx); + let earliest = FakeParticipant::new("earliest", cx); + let middle = FakeParticipant::new("middle", cx); + later.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 30, + cx, + ); + earliest.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 10, + cx, + ); + middle.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 20, + cx, + ); + + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + + assert_eq!( + selection_state.snapshot().unwrap().anchor().entity_id(), + Some(earliest.selection.entity_id()) + ); + } + }); + } + + #[gpui::test] + fn text_selection_namespace_is_a_safe_no_op_until_the_element_is_rendered( + cx: &mut TestAppContext, + ) { + let (_, cx) = cx.add_window_view(|_, cx| WindowSelectionView { + selection: TextSelectionHandle::new("not enabled", cx), + }); + cx.update(|window, cx| { + assert!(!TextSelection::has_selection(window, cx)); + assert_eq!(TextSelection::selected_text(window, cx), ""); + TextSelection::clear(window, cx); + TextSelection::end(window, cx); + assert!(!TextSelection::has_selection(window, cx)); + }); + } + + #[gpui::test] + fn unit_selection_element_supports_scope_and_registration_on_the_first_frame( + cx: &mut TestAppContext, + ) { + let (view, cx) = cx.add_window_view(|_, cx| FirstFrameScopedSelectionView { + selection: TextSelectionHandle::new("first frame", cx), + }); + let selection = cx.update(|_, cx| view.read(cx).selection.clone()); + + cx.update(|window, cx| { + let _ = window.draw(cx); + let state = WindowSelectionState::existing(window, cx).unwrap(); + assert_eq!( + state.read(cx).active_scope, + TextSelectionScopeId::from_raw(23) + ); + assert!( + state + .read(cx) + .participants + .contains_key(&selection.entity_id()) + ); + }); + } + + #[gpui::test] + fn lazy_registration_does_not_enable_queries_without_the_element(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, cx| WindowSelectionView { + selection: TextSelectionHandle::new("registered", cx), + }); + cx.update(|window, cx| { + let selection = TextSelectionHandle::new("registered", cx); + selection.set_local_selection(true, cx); + let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(20.))); + let hitbox = Hitbox { + id: HitboxId::placeholder(), + bounds, + content_mask: ContentMask { bounds }, + behavior: HitboxBehavior::Normal, + }; + selection.register( + TextSelectionRegistration::new(hitbox, bounds).with_text_bounds(vec![bounds]), + window, + cx, + ); + assert_eq!(TextSelection::selected_text(window, cx), ""); + assert!(!TextSelection::has_selection(window, cx)); + TextSelection::clear(window, cx); + assert_eq!(TextSelection::selected_text(window, cx), ""); + }); + } + + #[gpui::test] + fn retained_selection_state_releases_and_does_not_resurrect_selection(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| ToggleSelectionElementView { + enabled: true, + selection: TextSelectionHandle::new("local", cx), + }); + let selection = cx.update(|_, cx| view.read(cx).selection.clone()); + cx.update(|window, cx| { + let _ = window.draw(cx); + selection.set_local_selection(true, cx); + assert!(TextSelection::has_selection(window, cx)); + + window.simulate_next_frame(cx); + assert!(TextSelection::has_selection(window, cx)); + let _ = window.draw(cx); + assert!(TextSelection::has_selection(window, cx)); + }); + view.update(cx, |view, cx| { + view.enabled = false; + cx.notify(); + }); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + assert!(!TextSelection::has_selection(window, cx)); + assert_eq!(TextSelection::selected_text(window, cx), ""); + assert!(!selection.has_local_selection(cx)); + TextSelection::clear(window, cx); + }); + + view.update(cx, |view, cx| { + view.enabled = true; + cx.notify(); + }); + cx.update(|window, cx| { + let _ = window.draw(cx); + assert!(!TextSelection::has_selection(window, cx)); + assert_eq!(TextSelection::selected_text(window, cx), ""); + }); + } + + #[gpui::test] + fn mounted_selection_element_does_not_keep_an_idle_frame_queue_alive(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| SelectionElementOnlyView); + cx.update(|window, cx| { + let _ = window.draw(cx); + assert!(window.simulate_next_frame(cx) > 0); + assert_eq!(window.simulate_next_frame(cx), 0); + assert_eq!(window.simulate_next_frame(cx), 0); + assert!(live_text_selection_state(window, cx).is_some()); + }); + } + + #[gpui::test] + fn selection_element_initializes_suppression_and_respects_bubble_suppression( + cx: &mut TestAppContext, + ) { + let (_, cx) = cx.add_window_view(|_, _| SelectionElementOnlyView); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.simulate_mouse_down( + point(px(1.), px(1.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(1.), px(1.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.update(|window, cx| { + assert!(GlobalState::is_text_selection_suppressed(cx)); + assert!(!TextSelection::has_selection(window, cx)); + }); + } + + #[gpui::test] + fn frame_sweep_keeps_a_participant_registered_before_the_selection_element_paints( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + let mut selection_state = WindowSelectionState::default(); + let participant = FakeParticipant::new("painted first", cx); + participant.register( + &mut selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + selection_state.end(cx); + + selection_state.finish_frame(cx); + + assert_eq!(selection_state.selected_text(cx), "painted first"); + assert!(participant.selection.snapshot(cx).is_some()); + }); + } + + #[gpui::test] + fn two_selection_elements_schedule_only_one_post_frame_sweep(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| DoubleSelectionElementView { + selection: TextSelectionHandle::new("once", cx), + }); + cx.update(|window, cx| { + let selection_state = WindowSelectionState::ensure(window, cx); + let selection = view.read(cx).selection.clone(); + selection_state.update(cx, |selection_state, cx| { + FakeParticipant { selection }.register( + selection_state, + 0., + TextSelectionScopeId::default(), + 0, + cx, + ); + selection_state.begin(point(px(1.), px(1.)), false, cx); + selection_state.update(point(px(8.), px(1.)), cx); + selection_state.end(cx); + }); + + let _ = window.draw(cx); + window.simulate_next_frame(cx); + + let items = selection_state.read(cx).copy_items(cx); + assert_eq!(resolve_copy_items(items, cx), "once"); + }); + } + + #[gpui::test] + fn duplicate_selection_elements_gate_real_pointer_gestures_and_reentrant_clear( + cx: &mut TestAppContext, + ) { + let (view, cx) = cx.add_window_view(|_, cx| DoubleSelectionElementView { + selection: TextSelectionHandle::new("once", cx), + }); + let clear_count = Rc::new(Cell::new(0)); + cx.update(|window, cx| { + let state = WindowSelectionState::ensure(window, cx); + let state_for_clear = state.clone(); + let count = clear_count.clone(); + let selection = view.read(cx).selection.clone(); + selection + .subscribe( + move |event, cx| { + if matches!(event, TextSelectionEvent::Cleared) { + count.set(count.get() + 1); + let _ = state_for_clear.read(cx).snapshot(); + } + }, + cx, + ) + .detach(); + let _ = window.draw(cx); + }); + + cx.simulate_mouse_down( + point(px(10.), px(10.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(10.), px(10.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_down( + point(px(70.), px(10.)), + MouseButton::Left, + gpui::Modifiers { + shift: true, + ..Default::default() + }, + ); + cx.simulate_mouse_up( + point(px(70.), px(10.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.update(|window, cx| assert!(TextSelection::has_selection(window, cx))); + + cx.simulate_mouse_down( + point(px(15.), px(10.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move( + point(px(85.), px(10.)), + Some(MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(85.), px(10.)), + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.update(|window, cx| assert!(TextSelection::has_selection(window, cx))); + assert_eq!(clear_count.get(), 3); + } + + #[gpui::test] + fn selection_layer_handles_real_double_and_triple_click_events(cx: &mut TestAppContext) { + let (text, layout) = laid_out_runs(&["alpha beta"], cx).pop().unwrap(); + let (view, cx) = cx.add_window_view(|_, cx| DoubleSelectionElementView { + selection: TextSelectionHandle::new("", cx), + }); + cx.update(|window, cx| { + let _ = window.draw(cx); + let selection = view.read(cx).selection.clone(); + selection.resolve_content_key_with(|_, _| Some(TextSelectionContentKey::new(17)), cx); + selection.update_runs(&[text_run(0, text.clone(), layout.clone())], cx); + }); + + let position = layout.position_for_index(7).unwrap(); + cx.simulate_event(MouseDownEvent { + position, + modifiers: gpui::Modifiers::default(), + button: MouseButton::Left, + click_count: 2, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + position, + modifiers: gpui::Modifiers::default(), + button: MouseButton::Left, + click_count: 2, + }); + cx.update(|window, cx| { + let selection = view.read(cx).selection.clone(); + selection.update_runs(&[text_run(0, text.clone(), layout.clone())], cx); + assert_eq!(TextSelection::selected_text(window, cx), "beta"); + let snapshot = selection.snapshot(cx).unwrap(); + assert_eq!( + snapshot.anchor().content_key(), + Some(TextSelectionContentKey::new(17)) + ); + assert_eq!( + snapshot.cursor().content_key(), + Some(TextSelectionContentKey::new(17)) + ); + }); + + cx.simulate_event(MouseDownEvent { + position, + modifiers: gpui::Modifiers::default(), + button: MouseButton::Left, + click_count: 3, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + position, + modifiers: gpui::Modifiers::default(), + button: MouseButton::Left, + click_count: 3, + }); + cx.update(|window, cx| { + let selection = view.read(cx).selection.clone(); + selection.update_runs(&[text_run(0, text, layout)], cx); + assert_eq!(TextSelection::selected_text(window, cx), "alpha beta"); + }); + } +} diff --git a/vendor/gpui-base/src/theme.rs b/vendor/gpui-base/src/theme.rs new file mode 100644 index 00000000..b25cd639 --- /dev/null +++ b/vendor/gpui-base/src/theme.rs @@ -0,0 +1,112 @@ +use gpui::{App, Global}; + +use crate::{ScrollbarMode, ScrollbarMotion, ScrollbarStyles, SemanticThemeTokens}; + +/// Application-wide defaults for Base behavior modules. +#[derive(Clone, Default)] +pub struct Theme { + pub tokens: SemanticThemeTokens, + pub scrollbar: ScrollbarTheme, + pub resizable: ResizableTheme, +} + +impl Global for Theme {} + +impl Theme { + pub fn global(cx: &App) -> Self { + cx.try_global::().cloned().unwrap_or_default() + } + + pub fn global_mut(cx: &mut App) -> &mut Self { + if !cx.has_global::() { + cx.set_global(Self::default()); + } + cx.global_mut::() + } +} + +/// Access to the active base theme through an application context. +pub(crate) trait ActiveTheme { + fn theme(&self) -> Theme; +} + +impl ActiveTheme for App { + #[inline(always)] + fn theme(&self) -> Theme { + Theme::global(self) + } +} + +/// Global defaults used by [`crate::Scrollbar`]. +/// +/// `motion` defaults to motionless. Styled layers project their own timing; +/// Base never installs a fade or slide of its own. +#[derive(Clone, Default)] +pub struct ScrollbarTheme { + mode: ScrollbarMode, + motion: ScrollbarMotion, + styles: ScrollbarStyles, +} + +impl ScrollbarTheme { + pub fn new() -> Self { + Self::default() + } + + pub fn with_mode(mut self, mode: ScrollbarMode) -> Self { + self.mode = mode; + self + } + + pub fn with_motion(mut self, motion: ScrollbarMotion) -> Self { + self.motion = motion; + self + } + + pub fn with_styles(mut self, styles: ScrollbarStyles) -> Self { + self.styles = styles; + self + } + + pub fn mode(&self) -> ScrollbarMode { + self.mode + } + + pub fn motion(&self) -> ScrollbarMotion { + self.motion + } + + pub fn styles(&self) -> &ScrollbarStyles { + &self.styles + } +} + +/// Global visual defaults used by resizable panel handles. +/// +/// The Base default is transparent. Applications and styled façades may +/// project their own colors without coupling resize behavior to a theme crate. +#[derive(Clone, Copy, Default)] +pub struct ResizableTheme { + pub handle: gpui::Hsla, + pub active_handle: gpui::Hsla, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scrollbar_theme_supports_builder_construction() { + let mode = ScrollbarMode::Hover; + let motion = ScrollbarMotion::default().with_enter(std::time::Duration::from_millis(120)); + let styles = ScrollbarStyles::default(); + let theme = ScrollbarTheme::new() + .with_mode(mode) + .with_motion(motion) + .with_styles(styles); + + assert_eq!(theme.mode(), mode); + assert_eq!(theme.motion(), motion); + let _ = theme.styles(); + } +} diff --git a/vendor/gpui-base/src/theme_tokens.rs b/vendor/gpui-base/src/theme_tokens.rs new file mode 100644 index 00000000..a7bf2167 --- /dev/null +++ b/vendor/gpui-base/src/theme_tokens.rs @@ -0,0 +1,165 @@ +//! Semantic design tokens shared by application-owned components. +//! +//! These tokens describe visual roles and scales. They intentionally do not +//! contain component names such as `button`, `table`, or `sidebar`. + +use gpui::{BoxShadow, FontWeight, Hsla, Pixels, SharedString, point, px}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticThemeTokens { + pub colors: ColorTokens, + pub radius: RadiusTokens, + pub spacing: SpacingTokens, + pub typography: TypographyTokens, + pub shadow: ShadowTokens, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ColorTokens { + pub background: Hsla, + pub foreground: Hsla, + pub surface: Hsla, + pub surface_foreground: Hsla, + pub primary: Hsla, + pub primary_foreground: Hsla, + pub secondary: Hsla, + pub secondary_foreground: Hsla, + pub muted: Hsla, + pub muted_foreground: Hsla, + pub accent: Hsla, + pub accent_foreground: Hsla, + pub destructive: Hsla, + pub destructive_foreground: Hsla, + pub border: Hsla, + pub input: Hsla, + pub ring: Hsla, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RadiusTokens { + pub none: Pixels, + pub sm: Pixels, + pub md: Pixels, + pub lg: Pixels, + pub xl: Pixels, + pub full: Pixels, +} + +impl Default for RadiusTokens { + fn default() -> Self { + Self { + none: px(0.), + sm: px(3.), + md: px(6.), + lg: px(8.), + xl: px(12.), + full: px(9999.), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SpacingTokens { + pub xxs: Pixels, + pub xs: Pixels, + pub sm: Pixels, + pub md: Pixels, + pub lg: Pixels, + pub xl: Pixels, + pub xxl: Pixels, +} + +impl Default for SpacingTokens { + fn default() -> Self { + Self { + xxs: px(2.), + xs: px(4.), + sm: px(8.), + md: px(12.), + lg: px(16.), + xl: px(24.), + xxl: px(32.), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TextStyleToken { + pub size: Pixels, + pub line_height: Pixels, + pub weight: FontWeight, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TypographyTokens { + pub sans: SharedString, + pub mono: SharedString, + pub xs: TextStyleToken, + pub sm: TextStyleToken, + pub md: TextStyleToken, + pub lg: TextStyleToken, + pub xl: TextStyleToken, + pub mono_md: TextStyleToken, +} + +impl Default for TypographyTokens { + fn default() -> Self { + Self { + sans: ".SystemUIFont".into(), + mono: default_mono_font_family(), + xs: text_style(12., 16.), + sm: text_style(14., 20.), + md: text_style(16., 24.), + lg: text_style(18., 28.), + xl: text_style(20., 28.), + mono_md: text_style(13., 20.), + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ShadowTokens { + pub sm: Vec, + pub md: Vec, + pub lg: Vec, +} + +impl ShadowTokens { + pub fn elevations(color: Hsla) -> Self { + Self { + sm: vec![box_shadow(0., 1., 2., 0., color)], + md: vec![box_shadow(0., 4., 8., -2., color)], + lg: vec![box_shadow(0., 12., 24., -4., color)], + } + } +} + +fn text_style(size: f32, line_height: f32) -> TextStyleToken { + TextStyleToken { + size: px(size), + line_height: px(line_height), + weight: FontWeight::NORMAL, + } +} + +fn default_mono_font_family() -> SharedString { + if cfg!(target_os = "macos") { + "Menlo".into() + } else if cfg!(target_os = "windows") { + "Consolas".into() + } else { + "DejaVu Sans Mono".into() + } +} + +fn box_shadow(x: f32, y: f32, blur: f32, spread: f32, color: Hsla) -> BoxShadow { + BoxShadow { + color, + offset: point(px(x), px(y)), + blur_radius: px(blur), + spread_radius: px(spread), + inset: false, + } +} diff --git a/vendor/gpui-base/src/toast.rs b/vendor/gpui-base/src/toast.rs new file mode 100644 index 00000000..c05a6471 --- /dev/null +++ b/vendor/gpui-base/src/toast.rs @@ -0,0 +1,976 @@ +#[cfg(not(target_family = "wasm"))] +use std::time::Instant; +use std::{cell::RefCell, collections::VecDeque, rc::Rc, time::Duration}; +#[cfg(target_family = "wasm")] +use web_time::Instant; + +use gpui::{ + Anchor, AnyElement, App, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, MouseMoveEvent, ParentElement, Pixels, RenderOnce, Role, Stateful, + StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, + prelude::FluentBuilder as _, px, +}; + +use crate::{ + ElementExt as _, StyledExt as _, + animation::cubic_bezier, + motion::{Transition, transition}, +}; + +/// Motion tokens used by an unstyled toast stack. +#[derive(Clone, Copy, Debug)] +pub struct ToastMotion { + /// Duration of stack expansion and collapse. + pub duration: Duration, + /// Duration before an ending toast is unmounted. + pub exit_duration: Duration, + /// Visible distance between collapsed toast layers. + pub collapsed_peek: Pixels, + /// Distance between expanded toast items. + pub expanded_gap: Pixels, + /// Fractional width reduction for each collapsed layer. + pub collapsed_scale_step: f32, + /// Number of layers visible while the stack is collapsed. + pub collapsed_visible: usize, +} + +impl ToastMotion { + /// Create motion matching the shadcn/Sonner toaster. + pub fn sonner() -> Self { + Self { + duration: Duration::from_millis(400), + exit_duration: Duration::from_millis(200), + collapsed_peek: px(14.), + expanded_gap: px(14.), + collapsed_scale_step: 0.05, + collapsed_visible: 3, + } + } +} + +impl Default for ToastMotion { + fn default() -> Self { + Self::sonner() + } +} + +/// Persistent private layout state used by [`ToastStack`]. +#[derive(Clone, Debug, Default)] +pub struct ToastStackState { + heights: Rc>>, + width: Rc>, + hovered: Rc>, + focused: Rc>, +} + +impl ToastStackState { + /// Return whether interaction has expanded the stack. + pub fn is_expanded(&self) -> bool { + self.hovered.get() || self.focused.get() + } +} + +/// Options applied when a toast enters a [`ToastManager`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct ToastOptions { + /// Active time before automatic dismissal; `None` disables auto-hide. + pub timeout: Option, +} + +#[derive(Debug)] +struct ManagedToast { + id: I, + value: T, + status: ToastTransitionStatus, + timeout_remaining: Option, + transition_elapsed: Duration, + last_advance: Instant, +} + +/// Changes produced when a toast manager advances its lifecycle clock. +#[derive(Debug)] +pub struct ToastAdvance { + /// Whether a mounted toast changed phase or membership. + pub changed: bool, + /// Toast ids whose entry transition completed. + pub presented: Vec, + /// Toast ids that entered their ending transition. + pub ending: Vec, + /// Toast values removed after their ending transition completed. + pub removed: Vec<(I, T)>, +} + +/// Ordered toast storage, lifecycle, auto-hide, limits, and exit coordination. +#[derive(Debug)] +pub struct ToastManager { + entries: VecDeque>, + transition_duration: Duration, + exit_duration: Duration, +} + +impl ToastManager { + /// Create a manager using the supplied motion duration for enter and exit. + pub fn new(motion: ToastMotion) -> Self { + Self { + entries: VecDeque::new(), + transition_duration: motion.duration, + exit_duration: motion.exit_duration, + } + } + + /// Return the number of mounted toasts, including ending toasts. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Return whether no toast is mounted. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Iterate over mounted toast ids, values, and phases in display order. + pub fn iter(&self) -> impl DoubleEndedIterator { + self.entries + .iter() + .map(|entry| (&entry.id, &entry.value, entry.status)) + } + + /// Iterate over newest visible active toasts plus ending toasts. + pub fn visible(&self, limit: usize) -> impl Iterator { + let first = self + .entries + .iter() + .filter(|entry| entry.status != ToastTransitionStatus::Ending) + .count() + .saturating_sub(limit); + let mut active_index = 0usize; + self.entries.iter().filter_map(move |entry| { + let visible = if entry.status == ToastTransitionStatus::Ending { + true + } else { + let keep = active_index >= first; + active_index += 1; + keep + }; + visible.then_some((&entry.id, &entry.value, entry.status)) + }) + } + + /// Return a mounted toast value by id. + pub fn get(&self, id: &I) -> Option<&T> + where + I: Eq, + { + self.entries + .iter() + .find_map(|entry| (&entry.id == id).then_some(&entry.value)) + } +} + +impl ToastManager { + /// Add a newest toast, replacing an existing toast with the same id. + pub fn push(&mut self, id: I, value: T, options: ToastOptions, now: Instant) -> Option { + let replaced = self + .entries + .iter() + .position(|entry| entry.id == id) + .and_then(|index| self.entries.remove(index)) + .map(|entry| entry.value); + self.entries.push_back(ManagedToast { + id, + value, + status: ToastTransitionStatus::Starting, + timeout_remaining: options.timeout, + transition_elapsed: Duration::ZERO, + last_advance: now, + }); + replaced + } + + /// Begin a toast's exit transition, returning whether its state changed. + pub fn dismiss(&mut self, id: &I, now: Instant) -> bool { + let Some(entry) = self.entries.iter_mut().find(|entry| &entry.id == id) else { + return false; + }; + if entry.status == ToastTransitionStatus::Ending { + return false; + } + entry.status = ToastTransitionStatus::Ending; + entry.transition_elapsed = Duration::ZERO; + entry.last_advance = now; + true + } + + /// Begin the exit transition for every active toast. + pub fn dismiss_all(&mut self, now: Instant) -> Vec { + let mut changed = Vec::new(); + for entry in &mut self.entries { + if entry.status != ToastTransitionStatus::Ending { + entry.status = ToastTransitionStatus::Ending; + entry.transition_elapsed = Duration::ZERO; + entry.last_advance = now; + changed.push(entry.id.clone()); + } + } + changed + } + + /// Advance lifecycle time; active timers pause while `paused` is true. + pub fn advance(&mut self, now: Instant, paused: bool) -> ToastAdvance { + let mut ending = Vec::new(); + let mut presented = Vec::new(); + let mut changed = false; + for entry in &mut self.entries { + let delta = now.saturating_duration_since(entry.last_advance); + entry.last_advance = now; + match entry.status { + ToastTransitionStatus::Starting => { + entry.transition_elapsed += delta; + if entry.transition_elapsed >= self.transition_duration { + entry.status = ToastTransitionStatus::Present; + entry.transition_elapsed = Duration::ZERO; + presented.push(entry.id.clone()); + changed = true; + } + } + ToastTransitionStatus::Present if !paused => { + if let Some(remaining) = &mut entry.timeout_remaining { + *remaining = remaining.saturating_sub(delta); + if remaining.is_zero() { + entry.status = ToastTransitionStatus::Ending; + entry.transition_elapsed = Duration::ZERO; + ending.push(entry.id.clone()); + changed = true; + } + } + } + ToastTransitionStatus::Ending => entry.transition_elapsed += delta, + ToastTransitionStatus::Present => {} + } + } + let mut removed = Vec::new(); + let mut index = 0; + while index < self.entries.len() { + if self.entries[index].status == ToastTransitionStatus::Ending + && self.entries[index].transition_elapsed >= self.exit_duration + { + let entry = self.entries.remove(index).expect("toast index is valid"); + removed.push((entry.id, entry.value)); + changed = true; + } else { + index += 1; + } + } + ToastAdvance { + changed, + presented, + ending, + removed, + } + } +} + +/// A deep toast-stack element that owns measurement, overlap, and expansion motion. +#[derive(IntoElement)] +pub struct ToastStack { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + state: ToastStackState, + motion: ToastMotion, + placement: Anchor, + focus_handle: Option, + children: Vec<(ElementId, AnyElement)>, +} + +impl ToastStack { + /// Create a toast stack with Base UI-compatible motion. + pub fn new(id: impl Into, state: ToastStackState) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + state, + motion: ToastMotion::sonner(), + placement: Anchor::TopRight, + focus_handle: None, + children: Vec::new(), + } + } + + /// Add a stably keyed toast item to the stack. + pub fn item(mut self, id: impl Into, child: impl IntoElement) -> Self { + self.children.push((id.into(), child.into_any_element())); + self + } + + /// Set the stack motion tokens. + pub fn motion(mut self, motion: ToastMotion) -> Self { + self.motion = motion; + self + } + + /// Set the viewport edge used to anchor stack geometry. + pub fn placement(mut self, placement: Anchor) -> Self { + self.placement = placement; + self + } + + /// Set the focus scope that expands the stack and pauses auto-hide timers. + pub fn focus_handle(mut self, focus_handle: FocusHandle) -> Self { + self.focus_handle = Some(focus_handle); + self + } +} + +impl Styled for ToastStack { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for ToastStack { + fn extend(&mut self, children: impl IntoIterator) { + self.children + .extend(children.into_iter().enumerate().map(|(index, child)| { + ( + ElementId::NamedInteger("toast-stack-child".into(), index as u64), + child, + ) + })); + } +} + +impl InteractiveElement for ToastStack { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for ToastStack {} + +fn stack_geometry( + heights: &[Pixels], + gap: Pixels, + peek: Pixels, + anchored_bottom: bool, +) -> (Pixels, Pixels, Vec<(Pixels, Pixels)>) { + let count = heights.len(); + let expanded_height = heights + .iter() + .copied() + .fold(px(0.), |sum, height| sum + height) + + gap * count.saturating_sub(1) as f32; + let front_height = heights.last().copied().unwrap_or(px(0.)); + let collapsed_height = heights + .iter() + .enumerate() + .map(|(index, height)| { + let rank = count - 1 - index; + *height + peek * rank as f32 + }) + .fold( + front_height + peek * count.saturating_sub(1) as f32, + Pixels::max, + ); + let offsets = heights + .iter() + .enumerate() + .map(|(index, height)| { + let rank = count - 1 - index; + let newer_height = heights[(index + 1)..] + .iter() + .copied() + .fold(px(0.), |sum, height| sum + height); + let expanded = if anchored_bottom { + expanded_height - newer_height - gap * rank as f32 - *height + } else { + newer_height + gap * rank as f32 + }; + let collapsed = if anchored_bottom { + collapsed_height - *height - peek * rank as f32 + } else { + peek * rank as f32 + }; + (collapsed, expanded) + }) + .collect(); + (collapsed_height, expanded_height, offsets) +} + +impl RenderOnce for ToastStack { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focused = self + .focus_handle + .as_ref() + .is_some_and(|handle| handle.contains_focused(window, cx)); + self.state.focused.set(focused); + let expanded = self.state.is_expanded(); + let keys = self + .children + .iter() + .map(|(id, _)| id.clone()) + .collect::>(); + self.state + .heights + .borrow_mut() + .retain(|id, _| keys.contains(id)); + let measured_by_id = self.state.heights.borrow().clone(); + let heights = keys + .iter() + .map(|id| measured_by_id.get(id).copied().unwrap_or(px(0.))) + .collect::>(); + let measured = self.state.heights.clone(); + let duration = self.motion.duration; + let peek = self.motion.collapsed_peek; + let gap = self.motion.expanded_gap; + let scale_step = self.motion.collapsed_scale_step; + let collapsed_visible = self.motion.collapsed_visible.max(1); + let stack_width = self.state.width.get(); + let count = self.children.len(); + let anchored_bottom = matches!( + self.placement, + Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight + ); + let (collapsed_height, expanded_height, offsets) = stack_geometry( + &heights[..count.min(heights.len())], + gap, + peek, + anchored_bottom, + ); + let policy = || Transition::new(duration).ease(cubic_bezier(0.25, 0.1, 0.25, 1.)); + let stack_height = transition( + (self.id.clone(), "height"), + if expanded { + expanded_height + } else { + collapsed_height + }, + policy(), + window, + cx, + ); + let items = self + .children + .into_iter() + .enumerate() + .map(move |(index, (item_id, child))| { + let (collapsed_offset, expanded_offset) = + offsets.get(index).copied().unwrap_or((px(0.), px(0.))); + let measured = measured.clone(); + let measured_id = item_id.clone(); + let rank = count.saturating_sub(1 + index); + let target_offset = if expanded { + expanded_offset + } else { + collapsed_offset + }; + let offset = transition( + (item_id.clone(), "offset"), + target_offset, + policy(), + window, + cx, + ); + let target_inset = if expanded { + px(0.) + } else { + stack_width * (scale_step * rank.min(collapsed_visible - 1) as f32 / 2.) + }; + let inset = transition( + (item_id.clone(), "inset"), + target_inset, + policy(), + window, + cx, + ); + let opacity = transition( + (item_id.clone(), "visibility"), + if expanded || rank < collapsed_visible { + 1. + } else { + 0. + }, + policy(), + window, + cx, + ); + div() + .id(item_id.clone()) + .absolute() + .top_0() + .left_0() + .right_0() + .top(offset) + .left(inset) + .right(inset) + .opacity(opacity) + .when(!expanded && rank >= collapsed_visible, |this| { + this.invisible() + }) + .on_prepaint(move |bounds, _, cx| { + let mut heights = measured.borrow_mut(); + if heights.get(&measured_id).copied() != Some(bounds.size.height) { + heights.insert(measured_id.clone(), bounds.size.height); + cx.refresh_windows(); + } + }) + .child(child) + }); + + let hovered_state = self.state.hovered.clone(); + let measured_width = self.state.width.clone(); + self.base + .relative() + .h(stack_height) + .when_some(self.focus_handle, |this, handle| this.track_focus(&handle)) + .child( + canvas( + |_, _, _| {}, + move |mut bounds, _, window, cx| { + if measured_width.replace(bounds.size.width) != bounds.size.width { + cx.refresh_windows(); + } + if !expanded { + if anchored_bottom { + bounds.origin.y += stack_height - collapsed_height; + } + bounds.size.height = collapsed_height; + } + let hovered_state = hovered_state.clone(); + window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| { + let hovered = bounds.contains(&event.position); + if hovered_state.replace(hovered) != hovered { + cx.refresh_windows(); + } + }); + }, + ) + .absolute() + .size_full(), + ) + .children(items) + .refine_style(&self.style) + } +} + +/// The lifecycle phase exposed by a toast root to application-owned presentation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ToastTransitionStatus { + /// The toast has just been added and may run its enter transition. + #[default] + Starting, + /// The toast is fully present. + Present, + /// The toast is closing and remains mounted until its exit transition completes. + Ending, +} + +/// An unstyled semantic toast root. Applications own all presentation and motion. +#[derive(IntoElement)] +pub struct Toast { + base: Stateful
, + style: StyleRefinement, + transition_status: ToastTransitionStatus, + children: Vec, +} + +impl Toast { + /// Create an unstyled semantic toast in the starting transition phase. + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id), + style: StyleRefinement::default(), + transition_status: ToastTransitionStatus::Starting, + children: Vec::new(), + } + } + + /// Set the lifecycle phase used by application-owned toast presentation. + pub fn transition_status(mut self, status: ToastTransitionStatus) -> Self { + self.transition_status = status; + self + } + + /// Return the lifecycle phase used by application-owned toast presentation. + pub fn status(&self) -> ToastTransitionStatus { + self.transition_status + } +} + +impl Styled for Toast { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Toast { + fn extend(&mut self, children: impl IntoIterator) { + self.children.extend(children); + } +} + +impl InteractiveElement for Toast { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Toast {} + +impl RenderOnce for Toast { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::Alert) + .children(self.children) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Element as _, accesskit, point}; + + #[test] + fn manager_pauses_timeout_and_removes_only_after_exit() { + let motion = ToastMotion::sonner(); + let start = Instant::now(); + let mut manager = ToastManager::new(motion); + manager.push( + "a", + 1, + ToastOptions { + timeout: Some(Duration::from_secs(5)), + }, + start, + ); + manager.advance(start + motion.duration, false); + manager.advance(start + motion.duration + Duration::from_secs(4), true); + assert_eq!( + manager.iter().next().unwrap().2, + ToastTransitionStatus::Present + ); + let ending = manager.advance(start + motion.duration + Duration::from_secs(9), false); + assert_eq!(ending.ending, vec!["a"]); + assert!(ending.removed.is_empty()); + let removed = manager.advance( + start + motion.duration + motion.exit_duration + Duration::from_secs(9), + false, + ); + assert_eq!(removed.removed, vec![("a", 1)]); + } + + #[test] + fn manager_limit_keeps_ending_toasts_mounted() { + let now = Instant::now(); + let mut manager = ToastManager::new(ToastMotion::sonner()); + for id in ["a", "b", "c"] { + manager.push(id, id, ToastOptions::default(), now); + } + manager.dismiss(&"a", now); + assert_eq!( + manager.visible(1).map(|(id, _, _)| *id).collect::>(), + vec!["a", "c"] + ); + } + + #[test] + fn manager_replaces_duplicate_ids_as_the_newest_toast() { + let now = Instant::now(); + let mut manager = ToastManager::new(ToastMotion::sonner()); + manager.push("a", 1, ToastOptions::default(), now); + manager.push("b", 2, ToastOptions::default(), now); + assert_eq!(manager.push("a", 3, ToastOptions::default(), now), Some(1)); + assert_eq!( + manager + .iter() + .map(|(id, value, _)| (*id, *value)) + .collect::>(), + vec![("b", 2), ("a", 3)] + ); + } + + #[test] + fn manager_resets_clock_when_reused_after_becoming_empty() { + let motion = ToastMotion::sonner(); + let start = Instant::now(); + let mut manager = ToastManager::new(motion); + manager.push("old", 1, ToastOptions::default(), start); + manager.dismiss(&"old", start); + manager.advance(start + motion.exit_duration, false); + let later = start + Duration::from_secs(60); + manager.push("new", 2, ToastOptions::default(), later); + manager.advance(later + Duration::from_millis(50), false); + assert_eq!( + manager.iter().next().unwrap().2, + ToastTransitionStatus::Starting + ); + } + + #[test] + fn newly_pushed_toast_does_not_inherit_existing_entry_clock() { + let motion = ToastMotion::sonner(); + let start = Instant::now(); + let mut manager = ToastManager::new(motion); + manager.push("old", 1, ToastOptions::default(), start); + let later = start + Duration::from_secs(60); + manager.push("new", 2, ToastOptions::default(), later); + manager.advance(later + Duration::from_millis(50), false); + assert_eq!( + manager + .iter() + .find(|(id, _, _)| **id == "new") + .map(|(_, _, status)| status), + Some(ToastTransitionStatus::Starting) + ); + } + + #[test] + fn stack_geometry_anchors_newest_item_and_supports_variable_heights() { + let heights = [px(40.), px(60.), px(80.)]; + let (collapsed, expanded, top) = stack_geometry(&heights, px(12.), px(12.), false); + assert_eq!(collapsed, px(104.)); + assert_eq!(expanded, px(204.)); + assert_eq!( + top, + vec![(px(24.), px(164.)), (px(12.), px(92.)), (px(0.), px(0.))] + ); + + let (_, _, bottom) = stack_geometry(&heights, px(12.), px(12.), true); + assert_eq!( + bottom, + vec![(px(40.), px(0.)), (px(32.), px(52.)), (px(24.), px(124.))] + ); + + let tall_behind = [px(180.), px(60.)]; + let (collapsed, _, top) = stack_geometry(&tall_behind, px(14.), px(14.), false); + assert_eq!(collapsed, px(194.)); + assert_eq!(top[0].0, px(14.)); + assert!(top[0].0 + tall_behind[0] <= collapsed); + assert_eq!(top[0].0 - top[1].0, px(14.)); + + let (_, _, bottom) = stack_geometry(&tall_behind, px(14.), px(14.), true); + assert!(bottom[0].0 >= px(0.)); + assert!(bottom[0].0 + tall_behind[0] <= collapsed); + assert_eq!( + (bottom[1].0 + tall_behind[1]) - (bottom[0].0 + tall_behind[0]), + px(14.) + ); + } + + #[gpui::test] + fn toast_exposes_alert_semantics(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + let mut node = accesskit::Node::new(Role::Alert); + Toast::new("toast") + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + assert_eq!(node.role(), Role::Alert); + }); + } + + #[gpui::test] + fn stack_bootstraps_measurement_without_zero_height_clipping(cx: &mut gpui::TestAppContext) { + struct Harness { + state: ToastStackState, + } + impl gpui::Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + ToastStack::new("stack", self.state.clone()) + .w(px(300.)) + .item("first", div().h(px(80.)).child("Visible")) + } + } + + let (view, cx) = cx.add_window_view(|_, _| Harness { + state: ToastStackState::default(), + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + view.read_with(cx, |view, _| { + assert_eq!( + view.state + .heights + .borrow() + .get(&ElementId::from("first")) + .copied(), + Some(px(80.)) + ); + }); + } + + #[gpui::test] + fn stack_expands_for_hover_and_focus(cx: &mut gpui::TestAppContext) { + struct Harness { + state: ToastStackState, + focus: FocusHandle, + } + impl gpui::Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + ToastStack::new("stack", self.state.clone()) + .focus_handle(self.focus.clone()) + .w(px(300.)) + .item("first", div().h(px(80.))) + } + } + + let (view, cx) = cx.add_window_view(|_, cx| Harness { + state: ToastStackState::default(), + focus: cx.focus_handle().tab_stop(true), + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + view.read_with(cx, |view, _| assert!(view.state.is_expanded())); + + cx.simulate_mouse_move(point(px(400.), px(400.)), None, gpui::Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + view.read_with(cx, |view, _| assert!(!view.state.is_expanded())); + + cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + view.read_with(cx, |view, _| assert!(view.state.is_expanded())); + + cx.simulate_mouse_move(point(px(400.), px(400.)), None, gpui::Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + view.read_with(cx, |view, _| assert!(!view.state.is_expanded())); + + let focus = view.read_with(cx, |view, _| view.focus.clone()); + cx.update(|window, cx| { + focus.focus(window, cx); + window.draw(cx).clear(cx); + }); + view.read_with(cx, |view, _| assert!(view.state.is_expanded())); + } + + #[gpui::test] + fn keyed_stack_reflow_moves_from_the_current_visual_position(cx: &mut gpui::TestAppContext) { + struct Harness { + state: ToastStackState, + show_second: bool, + } + impl gpui::Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + ToastStack::new("stack", self.state.clone()) + .w(px(300.)) + .item( + "first", + div().debug_selector(|| "first-toast".into()).h(px(40.)), + ) + .when(self.show_second, |stack| { + stack.item( + "second", + div().debug_selector(|| "second-toast".into()).h(px(80.)), + ) + }) + } + } + + let (view, cx) = cx.add_window_view(|_, _| Harness { + state: ToastStackState::default(), + show_second: false, + }); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + let initial_y = cx.debug_bounds("first-toast").unwrap().origin.y; + + view.update(cx, |view, cx| { + view.show_second = true; + cx.notify(); + }); + cx.run_until_parked(); + let first_reflow_y = cx.debug_bounds("first-toast").unwrap().origin.y; + assert_eq!(first_reflow_y, initial_y); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let middle_y = cx.debug_bounds("first-toast").unwrap().origin.y; + assert!(middle_y > initial_y); + assert!(middle_y < initial_y + px(14.)); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!( + cx.debug_bounds("first-toast").unwrap().origin.y, + initial_y + px(14.) + ); + assert!( + cx.debug_bounds("first-toast").unwrap().size.width + < cx.debug_bounds("second-toast").unwrap().size.width + ); + } + + #[gpui::test] + fn bottom_stack_reflow_moves_up_without_an_opposite_direction_jump( + cx: &mut gpui::TestAppContext, + ) { + struct Harness { + state: ToastStackState, + show_second: bool, + } + impl gpui::Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().relative().size(px(300.)).child( + ToastStack::new("bottom-stack", self.state.clone()) + .placement(Anchor::BottomRight) + .absolute() + .bottom_0() + .w(px(300.)) + .item( + "bottom-first", + div() + .debug_selector(|| "bottom-first-toast".into()) + .h(px(40.)), + ) + .when(self.show_second, |stack| { + stack.item("bottom-second", div().h(px(80.))) + }), + ) + } + } + + let (view, cx) = cx.add_window_view(|_, _| Harness { + state: ToastStackState::default(), + show_second: false, + }); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + }); + cx.executor().advance_clock(ToastMotion::sonner().duration); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let initial_y = cx.debug_bounds("bottom-first-toast").unwrap().origin.y; + + view.update(cx, |view, cx| { + view.show_second = true; + cx.notify(); + }); + cx.run_until_parked(); + assert_eq!( + cx.debug_bounds("bottom-first-toast").unwrap().origin.y, + initial_y + ); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let middle_y = cx.debug_bounds("bottom-first-toast").unwrap().origin.y; + assert!(middle_y < initial_y); + assert!( + middle_y >= initial_y - px(14.), + "middle={middle_y:?}, initial={initial_y:?}" + ); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!( + cx.debug_bounds("bottom-first-toast").unwrap().origin.y, + initial_y - px(14.) + ); + } +} diff --git a/vendor/gpui-base/src/toggle.rs b/vendor/gpui-base/src/toggle.rs new file mode 100644 index 00000000..1e78e728 --- /dev/null +++ b/vendor/gpui-base/src/toggle.rs @@ -0,0 +1,402 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, + IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString, + Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div, + prelude::FluentBuilder as _, +}; +use smallvec::SmallVec; + +use crate::{StateStyle, StyledExt as _}; + +type ChangeHandler = Rc; + +/// An unstyled, controlled toggle button. +/// +/// This primitive owns activation, focus, and accessibility behavior. The +/// application owns all layout, visual states, sizes, and variants. +#[derive(IntoElement)] +pub struct Toggle { + id: ElementId, + base: Stateful
, + style: StyleRefinement, + semantic_styles: ToggleStyles, + pressed: bool, + disabled: bool, + children: SmallVec<[AnyElement; 2]>, + on_change: Option, + accessibility_label: Option, + tab_index: isize, + tab_stop: bool, + tracked_focus: Option, +} + +/// Semantic root styles supported by [`Toggle`]. +#[derive(Default)] +pub struct ToggleStyles { + pressed: StyleRefinement, + disabled: StyleRefinement, +} + +impl ToggleStyles { + pub fn pressed(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.pressed + .refine(&build(StateStyle::default()).into_refinement()); + self + } + + pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self { + self.disabled + .refine(&build(StateStyle::default()).into_refinement()); + self + } +} + +impl Toggle { + pub fn new(id: impl Into) -> Self { + let id = id.into(); + Self { + base: div().id(id.clone()), + id, + style: StyleRefinement::default(), + semantic_styles: ToggleStyles::default(), + pressed: false, + disabled: false, + children: SmallVec::new(), + on_change: None, + accessibility_label: None, + tab_index: 0, + tab_stop: true, + tracked_focus: None, + } + } + + pub fn pressed(mut self, pressed: bool) -> Self { + self.pressed = pressed; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Configures application-owned styles for the toggle's semantic states. + pub fn styles(mut self, build: impl FnOnce(ToggleStyles) -> ToggleStyles) -> Self { + self.semantic_styles = build(self.semantic_styles); + self + } + + fn resolved_style(&self) -> StyleRefinement { + crate::state_style::resolve_style( + &self.style, + [ + self.pressed.then_some(&self.semantic_styles.pressed), + self.disabled.then_some(&self.semantic_styles.disabled), + ] + .into_iter() + .flatten(), + ) + } + + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } + + /// Handles a request to change the controlled pressed state. + pub fn on_change( + mut self, + handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { + self.tracked_focus = Some(focus_handle.clone()); + self + } + + fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle { + window + .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + } +} + +impl Styled for Toggle { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Toggle { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for Toggle { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for Toggle {} + +impl RenderOnce for Toggle { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self + .tracked_focus + .clone() + .unwrap_or_else(|| self.focus_handle(window, cx)); + let pressed = self.pressed; + let disabled = self.disabled; + let style = self.resolved_style(); + let on_change = self.on_change; + + self.base + .role(Role::Button) + .aria_toggled(if pressed { + Toggled::True + } else { + Toggled::False + }) + .when_some(self.accessibility_label, |this, label| { + this.aria_label(label) + }) + .when(!disabled, |this| { + this.track_focus( + &focus_handle + .tab_index(self.tab_index) + .tab_stop(self.tab_stop), + ) + }) + .when(disabled, |this| { + this.on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + }) + .when_some( + (!disabled).then_some(on_change).flatten(), + |this, on_change| { + this.on_click(move |event, window, cx| { + on_change(!pressed, event, window, cx); + }) + }, + ) + .children(self.children) + .refine_style(&style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::{Arc, Mutex}, + }; + + use gpui::{ + Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + TestAppContext, VisualTestContext, accesskit, canvas, point, px, + }; + + struct Harness { + pressed: bool, + disabled: bool, + changes: Rc>>, + keyboard_changes: Rc>, + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let keyboard_changes = self.keyboard_changes.clone(); + Toggle::new("toggle") + .pressed(self.pressed) + .disabled(self.disabled) + .size(px(100.)) + .on_change(move |pressed, event, _, _| { + changes.borrow_mut().push(pressed); + if matches!(event, ClickEvent::Keyboard(_)) { + keyboard_changes.set(keyboard_changes.get() + 1); + } + }) + } + } + + fn harness( + cx: &mut TestAppContext, + pressed: bool, + disabled: bool, + ) -> ( + &mut VisualTestContext, + Rc>>, + Rc>, + ) { + let changes = Rc::new(RefCell::new(Vec::new())); + let keyboard_changes = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let changes = changes.clone(); + let keyboard_changes = keyboard_changes.clone(); + move |_, _| Harness { + pressed, + disabled, + changes, + keyboard_changes, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, changes, keyboard_changes) + } + + #[gpui::test] + fn pointer_requests_inverse_controlled_state_once(cx: &mut TestAppContext) { + for (pressed, expected) in [(false, true), (true, false)] { + let (cx, changes, _) = harness(cx, pressed, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(changes.borrow().as_slice(), &[expected]); + } + } + + #[gpui::test] + fn enter_and_space_use_one_native_keyboard_click_each(cx: &mut TestAppContext) { + let (cx, changes, keyboard_changes) = harness(cx, false, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + changes.borrow_mut().clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + for key in ["enter", "space"] { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + assert_eq!(changes.borrow().as_slice(), &[true, true]); + assert_eq!(keyboard_changes.get(), 2); + } + + #[gpui::test] + fn disabled_toggle_is_inert(cx: &mut TestAppContext) { + let (cx, changes, _) = harness(cx, false, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.simulate_keystrokes("enter space"); + assert!(changes.borrow().is_empty()); + } + + #[test] + fn state_styling_and_children_are_application_owned() { + let _ = Toggle::new("styled") + .child("Label") + .styles(|styles| { + styles + .pressed(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }) + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.8)) + .focus_visible(|style| style.opacity(0.7)); + } + + #[test] + fn semantic_root_styles_follow_toggle_priority() { + let styled = |toggle: Toggle| { + toggle.styles(|styles| { + styles + .pressed(|style| style.opacity(0.8)) + .disabled(|style| style.opacity(0.5)) + }) + }; + + assert_eq!(styled(Toggle::new("normal")).resolved_style().opacity, None); + assert_eq!( + styled(Toggle::new("pressed").pressed(true)) + .resolved_style() + .opacity, + Some(0.8) + ); + assert_eq!( + styled(Toggle::new("pressed-disabled").pressed(true).disabled(true)) + .resolved_style() + .opacity, + Some(0.5) + ); + assert_eq!( + styled( + Toggle::new("state-over-instance") + .pressed(true) + .disabled(true) + .opacity(0.9), + ) + .resolved_style() + .opacity, + Some(0.5) + ); + } + + #[gpui::test] + fn accessibility_exposes_button_role_toggled_state_and_action(cx: &mut TestAppContext) { + type Captured = Arc>>; + struct Probe(Captured); + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.0.clone(); + canvas( + move |_, window, cx| { + let mut info = |toggle: Toggle| { + let mut node = accesskit::Node::new(Role::Button); + toggle + .render(window, cx) + .into_element() + .write_a11y_info(&mut node); + node + }; + let enabled = info( + Toggle::new("enabled") + .pressed(true) + .accessibility_label("Bold") + .on_change(|_, _, _, _| {}), + ); + let disabled = info( + Toggle::new("disabled") + .disabled(true) + .on_change(|_, _, _, _| {}), + ); + *captured.lock().unwrap() = Some((enabled, disabled)); + }, + |_, _, _, _| {}, + ) + } + } + let captured: Captured = Arc::new(Mutex::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Probe(captured)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let (enabled, disabled) = result.lock().unwrap().take().unwrap(); + assert_eq!(enabled.role(), Role::Button); + assert_eq!(enabled.label(), Some("Bold")); + assert_eq!(enabled.toggled(), Some(Toggled::True)); + assert!(enabled.supports_action(accesskit::Action::Click)); + assert_eq!(disabled.toggled(), Some(Toggled::False)); + assert!(!disabled.supports_action(accesskit::Action::Click)); + } +} diff --git a/vendor/gpui-base/src/toggle_group.rs b/vendor/gpui-base/src/toggle_group.rs new file mode 100644 index 00000000..3a6991f6 --- /dev/null +++ b/vendor/gpui-base/src/toggle_group.rs @@ -0,0 +1,67 @@ +use gpui::{ + AnyElement, App, Axis, Div, ElementId, InteractiveElement, Interactivity, IntoElement, + ParentElement, RenderOnce, Role, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, + Window, accesskit, div, +}; +use smallvec::SmallVec; + +use crate::StyledExt as _; + +/// An unstyled container for a set of toggle elements. +#[derive(IntoElement)] +pub struct ToggleGroup { + base: Stateful
, + style: StyleRefinement, + axis: Axis, + children: SmallVec<[AnyElement; 4]>, +} + +impl ToggleGroup { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id.into()), + style: StyleRefinement::default(), + axis: Axis::Horizontal, + children: SmallVec::new(), + } + } + + /// Sets the semantic axis of the group. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } +} + +impl Styled for ToggleGroup { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for ToggleGroup { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl InteractiveElement for ToggleGroup { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for ToggleGroup {} + +impl RenderOnce for ToggleGroup { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .role(Role::Toolbar) + .aria_orientation(match self.axis { + Axis::Horizontal => accesskit::Orientation::Horizontal, + Axis::Vertical => accesskit::Orientation::Vertical, + }) + .children(self.children) + .refine_style(&self.style) + } +} diff --git a/vendor/gpui-base/src/tooltip.rs b/vendor/gpui-base/src/tooltip.rs new file mode 100644 index 00000000..3046c2e1 --- /dev/null +++ b/vendor/gpui-base/src/tooltip.rs @@ -0,0 +1,308 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + AnyElement, AnyView, App, Bounds, Context, Div, ElementId, InteractiveElement, IntoElement, + ParentElement, Pixels, Render, RenderOnce, Role, Stateful, StatefulInteractiveElement, Styled, + Task, Window, deferred, div, prelude::FluentBuilder as _, px, +}; + +use crate::{Placement, Positioner}; + +const TOOLTIP_PRIORITY: usize = 200; +const WINDOW_MARGIN: Pixels = px(4.); +const GRACE_PERIOD: Duration = Duration::from_millis(300); +const SHOW_DELAY: Duration = Duration::from_millis(500); + +type TooltipBuilder = Rc AnyView>; +type TooltipRenderer = Rc AnyElement>; + +/// An unstyled tooltip popup. +/// +/// This corresponds to Base UI's `Tooltip.Popup`: it owns the accessible +/// tooltip role and accepts application-owned content and presentation. +#[derive(IntoElement)] +pub struct Tooltip { + base: Stateful
, +} + +impl Tooltip { + pub fn new(id: impl Into) -> Self { + Self { + base: div().id(id).role(Role::Tooltip), + } + } +} + +impl Styled for Tooltip { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Tooltip { + fn extend(&mut self, children: impl IntoIterator) { + self.base.extend(children); + } +} + +impl RenderOnce for Tooltip { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + } +} + +/// Content requested by a tooltip trigger. +#[derive(Clone)] +pub struct TooltipRequest { + build: TooltipBuilder, + trigger_bounds: Bounds, + preferred_placement: Option, +} + +impl TooltipRequest { + pub fn new( + trigger_bounds: Bounds, + build: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + Self { + build: Rc::new(build), + trigger_bounds, + preferred_placement: None, + } + } + + pub fn placement(mut self, placement: Placement) -> Self { + self.preferred_placement = Some(placement); + self + } +} + +/// Presentation transition requested by the Base tooltip lifecycle. +#[derive(Clone, Copy, Debug)] +pub enum TooltipTransition { + Enter { + epoch: usize, + }, + Switch { + epoch: usize, + previous: Bounds, + current: Bounds, + }, +} + +/// Per-window tooltip provider and overlay. +pub struct TooltipOverlay { + content: Option, + previous_bounds: Option>, + epoch: usize, + had_recent_tooltip: bool, + animation_epoch: usize, + is_switching: bool, + show_task: Option>, + hide_task: Option>, + renderer: TooltipRenderer, +} + +impl TooltipOverlay { + pub fn new() -> Self { + Self { + content: None, + previous_bounds: None, + epoch: 0, + had_recent_tooltip: false, + animation_epoch: 0, + is_switching: false, + show_task: None, + hide_task: None, + renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()), + } + } + + pub fn render_with( + mut self, + renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.renderer = Rc::new(renderer); + self + } + + fn next_epoch(&mut self) -> usize { + self.epoch += 1; + self.epoch + } + + pub fn request_show( + &mut self, + content: TooltipRequest, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_task = None; + let was_visible = self.content.is_some(); + if was_visible || self.had_recent_tooltip { + self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds); + self.content = Some(content); + self.show_task = None; + self.is_switching = was_visible; + self.animation_epoch += 1; + cx.notify(); + return; + } + + let epoch = self.next_epoch(); + self.show_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(SHOW_DELAY).await; + let _ = this.update_in(cx, |this, _, cx| { + if this.epoch == epoch { + this.content = Some(content); + this.previous_bounds = None; + this.is_switching = false; + this.animation_epoch += 1; + cx.notify(); + } + }); + })); + } + + pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context) { + self.show_task = None; + if self.content.is_none() { + return; + } + let epoch = self.next_epoch(); + self.had_recent_tooltip = true; + self.hide_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(GRACE_PERIOD).await; + let _ = this.update_in(cx, |this, _, cx| { + if this.epoch == epoch { + this.content = None; + this.previous_bounds = None; + this.had_recent_tooltip = false; + cx.notify(); + } + }); + })); + } + + pub fn hide(&mut self, cx: &mut Context) { + let changed = self.content.is_some() + || self.previous_bounds.is_some() + || self.had_recent_tooltip + || self.show_task.is_some() + || self.hide_task.is_some(); + self.content = None; + self.previous_bounds = None; + self.had_recent_tooltip = false; + self.is_switching = false; + self.show_task = None; + self.hide_task = None; + if changed { + cx.notify(); + } + } +} + +impl Default for TooltipOverlay { + fn default() -> Self { + Self::new() + } +} + +impl Render for TooltipOverlay { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(content) = self.content.as_ref() else { + return div().into_any_element(); + }; + let view = (content.build)(window, cx); + let transition = match (self.is_switching, self.previous_bounds) { + (true, Some(previous)) => TooltipTransition::Switch { + epoch: self.animation_epoch, + previous, + current: content.trigger_bounds, + }, + _ => TooltipTransition::Enter { + epoch: self.animation_epoch, + }, + }; + let rendered = (self.renderer)(view, transition, window, cx); + deferred( + TooltipPositioner::new(content.trigger_bounds) + .when_some(content.preferred_placement, |this, placement| { + this.placement(placement) + }) + .child(rendered), + ) + .with_priority(TOOLTIP_PRIORITY) + .into_any_element() + } +} + +/// An unstyled tooltip positioner with viewport-aware flipping and clamping. +/// +/// This is a tooltip-named view of [`crate::Positioner`]'s side placement. It +/// adds no element of its own; the shared positioner is what gets rendered. +pub struct TooltipPositioner(Positioner); + +impl TooltipPositioner { + pub fn new(trigger_bounds: Bounds) -> Self { + Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN)) + } + + pub fn placement(mut self, placement: Placement) -> Self { + self.0 = self.0.placement(placement); + self + } +} + +impl ParentElement for TooltipPositioner { + fn extend(&mut self, elements: impl IntoIterator) { + self.0.extend(elements); + } +} + +impl IntoElement for TooltipPositioner { + type Element = Positioner; + + fn into_element(self) -> Self::Element { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, point, size}; + + fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), size(px(width), px(height))) + } + + #[gpui::test] + fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) { + let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new())); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + state.update(cx, |tooltip, cx| { + tooltip.had_recent_tooltip = true; + tooltip.request_show( + TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| { + panic!("content is not rendered by this lifecycle test") + }), + window, + cx, + ); + }); + }); + cx.update(|_, cx| assert!(state.read(cx).content.is_some())); + + cx.update(|_, cx| { + state.update(cx, |tooltip, cx| tooltip.hide(cx)); + }); + cx.update(|_, cx| assert!(state.read(cx).content.is_none())); + } + + #[test] + fn tooltip_priority_exceeds_popup_layer() { + assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY); + } +} diff --git a/vendor/gpui-base/src/tree.rs b/vendor/gpui-base/src/tree.rs new file mode 100644 index 00000000..395b7e38 --- /dev/null +++ b/vendor/gpui-base/src/tree.rs @@ -0,0 +1,643 @@ +use std::{cell::RefCell, ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, InteractiveElement, + IntoElement, KeyBinding, MouseButton, ParentElement, Render, RenderOnce, SharedString, + StyleRefinement, Styled, UniformListScrollHandle, Window, div, prelude::FluentBuilder as _, + uniform_list, +}; + +use crate::{ + actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}, + styled::StyledExt as _, +}; + +const CONTEXT: &str = "Tree"; + +#[doc(hidden)] +pub fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("right", SelectRight, Some(CONTEXT)), + ]); +} + +#[doc(hidden)] +pub const fn key_context() -> &'static str { + CONTEXT +} + +struct TreeItemState { + expanded: bool, + disabled: bool, +} + +/// A tree item with a stable id, display label, children, and shared state. +#[derive(Clone)] +pub struct TreeItem { + pub id: SharedString, + pub label: SharedString, + pub children: Vec, + state: Rc>, +} + +/// A flat representation of a tree item with its depth. +#[derive(Clone)] +pub struct TreeEntry { + item: TreeItem, + depth: usize, +} + +impl TreeEntry { + pub fn new(item: TreeItem, depth: usize) -> Self { + Self { item, depth } + } + + #[inline] + pub fn item(&self) -> &TreeItem { + &self.item + } + + #[inline] + pub fn depth(&self) -> usize { + self.depth + } + + #[inline] + pub fn is_root(&self) -> bool { + self.depth == 0 + } + + #[inline] + pub fn is_folder(&self) -> bool { + self.item.is_folder() + } + + #[inline] + pub fn is_expanded(&self) -> bool { + self.item.is_expanded() + } + + #[inline] + pub fn is_disabled(&self) -> bool { + self.item.is_disabled() + } +} + +/// Event emitted by a tree when user-visible expansion state changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TreeEvent { + Expanded(SharedString), + Collapsed(SharedString), +} + +impl TreeItem { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + children: Vec::new(), + state: Rc::new(RefCell::new(TreeItemState { + expanded: false, + disabled: false, + })), + } + } + + pub fn child(mut self, child: TreeItem) -> Self { + self.children.push(child); + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.children.extend(children); + self + } + + pub fn expanded(self, expanded: bool) -> Self { + self.state.borrow_mut().expanded = expanded; + self + } + + pub fn disabled(self, disabled: bool) -> Self { + self.state.borrow_mut().disabled = disabled; + self + } + + #[inline] + pub fn is_folder(&self) -> bool { + !self.children.is_empty() + } + + pub fn is_disabled(&self) -> bool { + self.state.borrow().disabled + } + + #[inline] + pub fn is_expanded(&self) -> bool { + self.state.borrow().expanded + } + + /// Returns the target's ancestors from nearest parent to root. + pub fn ancestors(&self, target_id: &SharedString) -> Option> { + if self.id == *target_id { + return Some(Vec::new()); + } + + for child in &self.children { + if let Some(mut path) = child.ancestors(target_id) { + path.push(self.clone()); + return Some(path); + } + } + + None + } +} + +/// The interaction state supplied while rendering a visible tree entry. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TreeEntryState { + selected: bool, + right_clicked: bool, +} + +impl TreeEntryState { + #[inline] + pub fn is_selected(self) -> bool { + self.selected + } + + #[inline] + pub fn is_right_clicked(self) -> bool { + self.right_clicked + } +} + +type RenderItem = dyn Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement; + +/// Behavior and interaction state for a virtualized tree. +pub struct TreeState { + focus_handle: FocusHandle, + entries: Vec, + scroll_handle: UniformListScrollHandle, + selected_ix: Option, + right_clicked_ix: Option, + render_item: Rc, + list_style: StyleRefinement, +} + +impl EventEmitter for TreeState {} + +impl TreeState { + pub fn new(cx: &mut App) -> Self { + Self { + focus_handle: cx.focus_handle(), + entries: Vec::new(), + scroll_handle: UniformListScrollHandle::default(), + selected_ix: None, + right_clicked_ix: None, + render_item: Rc::new(|_, _, _, _, _| div().into_any_element()), + list_style: StyleRefinement::default(), + } + } + + pub fn items(mut self, items: impl Into>) -> Self { + self.replace_items(items.into()); + self + } + + pub fn set_items(&mut self, items: impl Into>, cx: &mut Context) { + self.replace_items(items.into()); + self.selected_ix = None; + self.right_clicked_ix = None; + cx.notify(); + } + + pub fn selected_index(&self) -> Option { + self.selected_ix + } + + pub fn set_selected_index(&mut self, ix: Option, cx: &mut Context) { + self.selected_ix = ix; + cx.notify(); + } + + pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context) { + if let Some(item) = item { + self.selected_ix = self.index_of(&item.id); + if self.selected_ix.is_none() { + self.expand_ancestors(item.id.clone(), cx); + self.selected_ix = self.index_of(&item.id); + } + } else { + self.selected_ix = None; + } + cx.notify(); + } + + pub fn selected_item(&self) -> Option<&TreeItem> { + self.selected_ix + .and_then(|ix| self.entries.get(ix).map(TreeEntry::item)) + } + + pub fn selected_entry(&self) -> Option<&TreeEntry> { + self.selected_ix.and_then(|ix| self.entries.get(ix)) + } + + pub fn entry(&self, ix: usize) -> Option<&TreeEntry> { + self.entries.get(ix) + } + + pub fn scroll_handle(&self) -> &UniformListScrollHandle { + &self.scroll_handle + } + + pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) { + self.scroll_handle.scroll_to_item(ix, strategy); + } + + pub fn index_of(&self, id: &SharedString) -> Option { + self.entries.iter().position(|entry| &entry.item.id == id) + } + + pub fn reveal_item( + &mut self, + id: &SharedString, + strategy: gpui::ScrollStrategy, + cx: &mut Context, + ) { + self.expand_ancestors(id.clone(), cx); + if let Some(ix) = self.index_of(id) { + self.scroll_to_item(ix, strategy); + } + } + + pub fn focus(&mut self, window: &mut Window, cx: &mut App) { + self.focus_handle.focus(window, cx); + } + + fn replace_items(&mut self, items: Vec) { + self.entries.clear(); + for item in items { + self.add_entry(item, 0); + } + } + + fn expand_ancestors(&mut self, target_id: SharedString, cx: &mut Context) { + let ancestors = self + .entries + .iter() + .find_map(|entry| entry.item.ancestors(&target_id)) + .unwrap_or_default(); + + if ancestors.is_empty() { + return; + } + + for ancestor in ancestors.into_iter().rev() { + if !ancestor.is_expanded() { + ancestor.state.borrow_mut().expanded = true; + cx.emit(TreeEvent::Expanded(ancestor.id.clone())); + } + } + self.rebuild_entries(); + } + + fn add_entry(&mut self, item: TreeItem, depth: usize) { + self.entries.push(TreeEntry::new(item.clone(), depth)); + if item.is_expanded() { + for child in &item.children { + self.add_entry(child.clone(), depth + 1); + } + } + } + + fn toggle_expand(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.entries.get(ix) else { + return; + }; + if !entry.is_folder() { + return; + } + + let expanded = !entry.is_expanded(); + let id = entry.item.id.clone(); + entry.item.state.borrow_mut().expanded = expanded; + cx.emit(if expanded { + TreeEvent::Expanded(id) + } else { + TreeEvent::Collapsed(id) + }); + self.right_clicked_ix = None; + self.rebuild_entries(); + } + + fn rebuild_entries(&mut self) { + let roots = self + .entries + .iter() + .filter(|entry| entry.is_root()) + .map(|entry| entry.item.clone()) + .collect::>(); + self.replace_items(roots); + } + + fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { + if self + .selected_ix + .and_then(|ix| self.entries.get(ix).map(|entry| (ix, entry.is_folder()))) + .is_some_and(|(ix, is_folder)| { + if is_folder { + self.toggle_expand(ix, cx); + } + is_folder + }) + { + cx.notify(); + } + } + + fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + if let Some(ix) = self.selected_ix + && self + .entries + .get(ix) + .is_some_and(|entry| entry.is_folder() && entry.is_expanded()) + { + self.toggle_expand(ix, cx); + cx.notify(); + } + } + + fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + if let Some(ix) = self.selected_ix + && self + .entries + .get(ix) + .is_some_and(|entry| entry.is_folder() && !entry.is_expanded()) + { + self.toggle_expand(ix, cx); + cx.notify(); + } + } + + fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + let mut ix = self.selected_ix.unwrap_or(0); + ix = ix + .checked_sub(1) + .unwrap_or_else(|| self.entries.len().saturating_sub(1)); + self.selected_ix = Some(ix); + self.scroll_handle + .scroll_to_item(ix, gpui::ScrollStrategy::Top); + cx.notify(); + } + + fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + let mut ix = self.selected_ix.unwrap_or(0); + ix = if ix + 1 < self.entries.len() { + ix + 1 + } else { + 0 + }; + self.selected_ix = Some(ix); + self.scroll_handle + .scroll_to_item(ix, gpui::ScrollStrategy::Bottom); + cx.notify(); + } + + fn on_entry_click(&mut self, ix: usize, cx: &mut Context) { + self.selected_ix = Some(ix); + self.toggle_expand(ix, cx); + cx.notify(); + } +} + +impl Render for TreeState { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let render_item = self.render_item.clone(); + uniform_list("entries", self.entries.len(), { + cx.processor(move |state, visible_range: Range, window, cx| { + visible_range + .map(|ix| { + let entry = &state.entries[ix]; + let entry_state = TreeEntryState { + selected: Some(ix) == state.selected_ix, + right_clicked: Some(ix) == state.right_clicked_ix, + }; + div() + .id(ix) + .child((render_item)(ix, entry, entry_state, window, cx)) + .when(!entry.is_disabled(), |this| { + this.on_mouse_down( + MouseButton::Left, + cx.listener(move |state, _, _, cx| { + state.on_entry_click(ix, cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |state, _, _, cx| { + state.right_clicked_ix = Some(ix); + cx.notify(); + }), + ) + }) + }) + .collect() + }) + }) + .track_scroll(&self.scroll_handle) + .refine_style(&self.list_style) + } +} + +/// An unstyled, virtualized tree element. +#[derive(IntoElement)] +pub struct Tree { + id: ElementId, + state: Entity, + style: StyleRefinement, + list_style: StyleRefinement, + render_item: Rc, +} + +impl Tree { + pub fn new(state: &Entity) -> Self { + Self { + id: ElementId::Name(format!("tree-{}", state.entity_id()).into()), + state: state.clone(), + style: StyleRefinement::default(), + list_style: StyleRefinement::default(), + render_item: Rc::new(|_, _, _, _, _| div().into_any_element()), + } + } + + /// Supplies the application-owned content for each visible entry. + pub fn item(mut self, render_item: R) -> Self + where + R: Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement + 'static, + { + self.render_item = Rc::new(render_item); + self + } + + /// Applies caller-owned presentation to the internal virtual list. + pub fn list_style(mut self, style: StyleRefinement) -> Self { + self.list_style = style; + self + } +} + +impl Styled for Tree { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Tree { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus_handle = self.state.read(cx).focus_handle.clone(); + self.state.update(cx, |state, _| { + state.render_item = self.render_item; + state.list_style = self.list_style; + }); + + div() + .id(self.id) + .key_context(CONTEXT) + .track_focus(&focus_handle) + .on_action(window.listener_for(&self.state, TreeState::on_action_confirm)) + .on_action(window.listener_for(&self.state, TreeState::on_action_left)) + .on_action(window.listener_for(&self.state, TreeState::on_action_right)) + .on_action(window.listener_for(&self.state, TreeState::on_action_up)) + .on_action(window.listener_for(&self.state, TreeState::on_action_down)) + .child(self.state) + .refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, Subscription}; + + struct EventCollector { + events: Rc>>, + _subscription: Subscription, + } + + impl EventCollector { + fn new(state: &Entity, cx: &mut Context) -> Self { + let events = Rc::new(RefCell::new(Vec::new())); + let captured = events.clone(); + let subscription = cx.subscribe(state, move |_, _, event, _| { + captured.borrow_mut().push(event.clone()); + }); + Self { + events, + _subscription: subscription, + } + } + } + + impl Render for EventCollector { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + #[test] + fn clones_share_state_and_ancestors_keep_nearest_first_order() { + let leaf = TreeItem::new("leaf", "Leaf"); + let branch = TreeItem::new("branch", "Branch").child(leaf.clone()); + let root = TreeItem::new("root", "Root").child(branch.clone()); + + leaf.clone().disabled(true).expanded(true); + assert!(leaf.is_disabled()); + assert!(leaf.is_expanded()); + + let ancestors = root.ancestors(&"leaf".into()).unwrap(); + assert_eq!( + ancestors + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["branch", "root"] + ); + } + + #[gpui::test] + fn state_flattens_expanded_items_and_resets_selection(cx: &mut gpui::TestAppContext) { + let items = vec![ + TreeItem::new("src", "src") + .expanded(true) + .child(TreeItem::new("src/lib.rs", "lib.rs")), + TreeItem::new("README.md", "README.md"), + ]; + let state = cx.new(|cx| TreeState::new(cx).items(items)); + + state.update(cx, |state, cx| { + assert_eq!(state.entries.len(), 3); + assert_eq!(state.entries[1].depth(), 1); + state.set_selected_index(Some(1), cx); + state.set_items(vec![TreeItem::new("Cargo.toml", "Cargo.toml")], cx); + assert_eq!(state.selected_index(), None); + assert_eq!(state.entries.len(), 1); + }); + } + + #[gpui::test] + fn selecting_hidden_item_expands_its_ancestors(cx: &mut gpui::TestAppContext) { + let target = TreeItem::new("src/ui/tree.rs", "tree.rs"); + let root = + TreeItem::new("src", "src").child(TreeItem::new("src/ui", "ui").child(target.clone())); + let state = cx.new(|cx| TreeState::new(cx).items(vec![root])); + + state.update(cx, |state, cx| { + state.set_selected_item(Some(&target), cx); + assert_eq!(state.entries.len(), 3); + assert_eq!( + state.selected_item().map(|item| item.id.as_str()), + Some("src/ui/tree.rs") + ); + }); + } + + #[gpui::test] + fn toggling_folder_rebuilds_visible_entries(cx: &mut gpui::TestAppContext) { + let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs")); + let state = cx.new(|cx| TreeState::new(cx).items(vec![root])); + + state.update(cx, |state, cx| { + state.toggle_expand(0, cx); + assert_eq!(state.entries.len(), 2); + state.toggle_expand(0, cx); + assert_eq!(state.entries.len(), 1); + }); + } + + #[gpui::test] + fn expansion_events_preserve_ids_and_set_items_stays_silent(cx: &mut gpui::TestAppContext) { + let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs")); + let state = cx.new(|cx| TreeState::new(cx).items(vec![root])); + let collector = cx.new(|cx| EventCollector::new(&state, cx)); + + state.update(cx, |state, cx| { + state.toggle_expand(0, cx); + state.toggle_expand(0, cx); + state.set_items(vec![TreeItem::new("README.md", "README.md")], cx); + }); + + let events = collector.read_with(cx, |collector, _| collector.events.borrow().clone()); + assert_eq!( + events, + vec![ + TreeEvent::Expanded("src".into()), + TreeEvent::Collapsed("src".into()), + ] + ); + } +} diff --git a/vendor/gpui-base/src/virtual_list.rs b/vendor/gpui-base/src/virtual_list.rs new file mode 100644 index 00000000..280bf0c0 --- /dev/null +++ b/vendor/gpui-base/src/virtual_list.rs @@ -0,0 +1,845 @@ +//! Virtual List for render a large number of differently sized rows/columns. +//! +//! > NOTE: This must ensure each column width or row height. +//! +//! Only visible range are rendered for performance reasons. +//! +//! Inspired by `gpui::uniform_list`. +//! https://github.com/zed-industries/zed/blob/0ae1603610ab6b265bdfbee7b8dbc23c5ab06edc/crates/gpui/src/elements/uniform_list.rs +//! +//! Unlike the `uniform_list`, the each item can have different size. +//! +//! This is useful for more complex layout, for example, a table with different row height. +use std::{ + cell::RefCell, + cmp, + ops::{Deref, Range}, + rc::Rc, +}; + +use gpui::{ + Along, AnyElement, App, AvailableSpace, Axis, Bounds, ContentMask, Context, + DeferredScrollToItem, Div, Element, ElementId, Entity, GlobalElementId, Half, Hitbox, + InteractiveElement, IntoElement, IsZero as _, ListSizingBehavior, Pixels, Point, Render, + ScrollHandle, ScrollStrategy, Size, Stateful, StatefulInteractiveElement, StyleRefinement, + Styled, Window, div, point, px, size, +}; +use smallvec::SmallVec; + +use crate::{AxisExt, InteractiveElementExt as _}; + +struct VirtualListScrollHandleState { + axis: Axis, + items_count: usize, + /// Content bounds size (excluding padding and border) from the last frame. + last_content_size: Option>, + pub deferred_scroll_to_item: Option, +} + +/// A scroll handle for [`VirtualList`]. +/// +/// See also [`ScrollHandle`]. +#[derive(Clone)] +pub struct VirtualListScrollHandle { + state: Rc>, + base_handle: ScrollHandle, +} + +impl From for VirtualListScrollHandle { + fn from(handle: ScrollHandle) -> Self { + let mut this = VirtualListScrollHandle::new(); + this.base_handle = handle; + this + } +} + +impl AsRef for VirtualListScrollHandle { + fn as_ref(&self) -> &ScrollHandle { + &self.base_handle + } +} + +impl crate::ScrollbarHandle for VirtualListScrollHandle { + fn viewport_bounds(&self) -> Bounds { + self.base_handle.bounds() + } + + fn offset(&self) -> Point { + self.base_handle.offset() + } + + fn set_offset(&self, offset: Point) { + self.base_handle.set_offset(offset); + } + + fn content_size(&self) -> Size { + self.base_handle.content_size() + } +} + +impl Deref for VirtualListScrollHandle { + type Target = ScrollHandle; + + fn deref(&self) -> &Self::Target { + &self.base_handle + } +} + +impl VirtualListScrollHandle { + /// Create a new VirtualListScrollHandle. + pub fn new() -> Self { + VirtualListScrollHandle { + state: Rc::new(RefCell::new(VirtualListScrollHandleState { + axis: Axis::Vertical, + items_count: 0, + last_content_size: None, + deferred_scroll_to_item: None, + })), + base_handle: ScrollHandle::default(), + } + } + + /// Get the base scroll handle. + pub fn base_handle(&self) -> &ScrollHandle { + &self.base_handle + } + + /// Scroll to the item at the given index. + pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) { + self.scroll_to_item_with_offset(ix, strategy, 0); + } + + /// Scroll to the item at the given index, with an additional offset items. + fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) { + let mut state = self.state.borrow_mut(); + state.deferred_scroll_to_item = Some(DeferredScrollToItem { + item_index: ix, + strategy, + offset, + scroll_strict: false, + }); + } + + /// Scrolls to the bottom of the list. + pub fn scroll_to_bottom(&self) { + let items_count = self.state.borrow().items_count; + self.scroll_to_item(items_count.saturating_sub(1), ScrollStrategy::Top); + } +} + +/// Create a [`VirtualList`] in vertical direction. +/// +/// This is like `uniform_list` in GPUI, but support two axis. +/// +/// The `item_sizes` is the size of each row. Only the `height` is used; `width` is inferred +/// by measuring the item selected with [`VirtualList::with_item_to_measure_index`], defaulting +/// to the first item. +/// +/// See also [`h_virtual_list`] +#[inline] +pub fn v_virtual_list( + view: Entity, + id: impl Into, + item_sizes: Rc>>, + f: impl 'static + Fn(&mut V, Range, &mut Window, &mut Context) -> Vec, +) -> VirtualList +where + R: IntoElement, + V: Render, +{ + virtual_list(view, id, Axis::Vertical, item_sizes, f) +} + +/// Create a [`VirtualList`] in horizontal direction. +/// +/// The `item_sizes` is the size of each column. Only the `width` is used; `height` is inferred +/// by measuring the item selected with [`VirtualList::with_item_to_measure_index`], defaulting +/// to the first item. +/// +/// See also [`v_virtual_list`] +#[inline] +pub fn h_virtual_list( + view: Entity, + id: impl Into, + item_sizes: Rc>>, + f: impl 'static + Fn(&mut V, Range, &mut Window, &mut Context) -> Vec, +) -> VirtualList +where + R: IntoElement, + V: Render, +{ + virtual_list(view, id, Axis::Horizontal, item_sizes, f) +} + +#[doc(hidden)] +pub fn virtual_list( + view: Entity, + id: impl Into, + axis: Axis, + item_sizes: Rc>>, + f: impl 'static + Fn(&mut V, Range, &mut Window, &mut Context) -> Vec, +) -> VirtualList +where + R: IntoElement, + V: Render, +{ + let id: ElementId = id.into(); + let scroll_handle = VirtualListScrollHandle::new(); + let render_range = move |visible_range, window: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| { + f(this, visible_range, window, cx) + .into_iter() + .map(|component| component.into_any_element()) + .collect() + }) + }; + + VirtualList { + id: id.clone(), + axis, + base: div() + .id(id) + .size_full() + .overflow_scroll() + .lock_scroll_axis() + .track_scroll(&scroll_handle), + scroll_handle, + items_count: item_sizes.len(), + item_sizes, + render_items: Box::new(render_range), + sizing_behavior: ListSizingBehavior::default(), + item_to_measure_index: 0, + fixed_cross_axis_size: None, + } +} + +/// VirtualList component for rendering a large number of differently sized items. +pub struct VirtualList { + id: ElementId, + axis: Axis, + base: Stateful
, + scroll_handle: VirtualListScrollHandle, + items_count: usize, + item_sizes: Rc>>, + render_items: Box< + dyn for<'a> Fn(Range, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>, + >, + sizing_behavior: ListSizingBehavior, + item_to_measure_index: usize, + fixed_cross_axis_size: Option, +} + +impl Styled for VirtualList { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl VirtualList { + pub fn track_scroll(mut self, scroll_handle: &VirtualListScrollHandle) -> Self { + self.base = self.base.track_scroll(&scroll_handle); + self.scroll_handle = scroll_handle.clone(); + self + } + + /// Set the sizing behavior for the list. + pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self { + self.sizing_behavior = behavior; + self + } + + /// Set the item index used to infer the list's cross-axis size. + pub fn with_item_to_measure_index(mut self, index: usize) -> Self { + self.item_to_measure_index = index; + self + } + + /// Set the list's non-scrolling axis size when the caller already knows it. + pub fn with_fixed_cross_axis_size(mut self, size: Pixels) -> Self { + self.fixed_cross_axis_size = Some(size); + self + } + + /// Specify for table. + /// + /// Table is special, because the `scroll_handle` is based on Table head (That is not a virtual list). + #[doc(hidden)] + pub fn with_scroll_handle(mut self, scroll_handle: &VirtualListScrollHandle) -> Self { + self.base = div().id(self.id.clone()).size_full(); + self.scroll_handle = scroll_handle.clone(); + self + } + + fn scroll_to_deferred_item( + &self, + scroll_offset: Point, + items_bounds: &[Bounds], + content_bounds: &Bounds, + scroll_to_item: DeferredScrollToItem, + ) -> Point { + let Some(bounds) = items_bounds + .get(scroll_to_item.item_index + scroll_to_item.offset) + .cloned() + else { + return scroll_offset; + }; + + let mut scroll_offset = scroll_offset; + match scroll_to_item.strategy { + ScrollStrategy::Center => { + if self.axis.is_vertical() { + scroll_offset.y = content_bounds.top() + content_bounds.size.height.half() + - bounds.top() + - bounds.size.height.half() + } else { + scroll_offset.x = content_bounds.left() + content_bounds.size.width.half() + - bounds.left() + - bounds.size.width.half() + } + } + _ => { + // Ref: https://github.com/zed-industries/zed/blob/0d145289e0867a8d5d63e5e1397a5ca69c9d49c3/crates/gpui/src/elements/div.rs#L3026 + if self.axis.is_vertical() { + if bounds.top() + scroll_offset.y < content_bounds.top() { + scroll_offset.y = content_bounds.top() - bounds.top() + } else if bounds.bottom() + scroll_offset.y > content_bounds.bottom() { + scroll_offset.y = content_bounds.bottom() - bounds.bottom(); + } + } else { + if bounds.left() + scroll_offset.x < content_bounds.left() { + scroll_offset.x = content_bounds.left() - bounds.left(); + } else if bounds.right() + scroll_offset.x > content_bounds.right() { + scroll_offset.x = content_bounds.right() - bounds.right(); + } + } + } + } + self.scroll_handle.set_offset(scroll_offset); + scroll_offset + } + + /// Ref from: https://github.com/zed-industries/zed/blob/83f9f9d9e3f5914392cab9a09e3472711a1d7b38/crates/gpui/src/elements/uniform_list.rs#L660 + fn measure_item( + &self, + list_width: Option, + window: &mut Window, + cx: &mut App, + ) -> Size { + if self.items_count == 0 { + return Size::default(); + } + + let item_ix = self.item_to_measure_index.min(self.items_count - 1); + let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx); + let Some(mut item_to_measure) = items.pop() else { + return Size::default(); + }; + let available_space = size( + list_width.map_or(AvailableSpace::MinContent, |width| { + AvailableSpace::Definite(width) + }), + AvailableSpace::MinContent, + ); + item_to_measure.layout_as_root(available_space, window, cx) + } +} + +/// Frame state used by the [VirtualItem]. +pub struct VirtualListFrameState { + /// Visible items to be painted. + items: SmallVec<[AnyElement; 32]>, + size_layout: ItemSizeLayout, +} + +#[derive(Default, Clone)] +pub struct ItemSizeLayout { + items_sizes: Rc>>, + content_size: Size, + sizes: Rc>, + origins: Rc>, + last_layout_bounds: Bounds, +} + +fn first_item_ending_after(sizes: &[Pixels], origins: &[Pixels], target: Pixels) -> usize { + let mut low = 0; + let mut high = sizes.len().min(origins.len()); + while low < high { + let mid = low + (high - low) / 2; + if origins[mid] + sizes[mid] > target { + high = mid; + } else { + low = mid + 1; + } + } + low +} + +impl IntoElement for VirtualList { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for VirtualList { + type RequestLayoutState = VirtualListFrameState; + type PrepaintState = Option; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let rem_size = window.rem_size(); + let font_size = window.text_style().font_size.to_pixels(rem_size); + let mut size_layout = ItemSizeLayout::default(); + // Measure vertical lists at the last known content width so relative + // widths and text truncation resolve like the visible rows; an + // unconstrained measure would turn one long non-wrapping text into a + // phantom horizontal scroll range. + let list_width = if self.axis.is_vertical() { + self.scroll_handle + .state + .borrow() + .last_content_size + .map(|size| size.width) + .filter(|width| !width.is_zero()) + } else { + None + }; + let longest_item_size = if let Some(cross_axis_size) = self.fixed_cross_axis_size { + match self.axis { + Axis::Horizontal => size(px(0.), cross_axis_size), + Axis::Vertical => size(cross_axis_size, px(0.)), + } + } else { + self.measure_item(list_width, window, cx) + }; + + let layout_id = self.base.interactivity().request_layout( + global_id, + inspector_id, + window, + cx, + |style, window, cx| { + size_layout = window.with_element_state( + global_id.unwrap(), + |state: Option, _window| { + let mut state = state.unwrap_or(ItemSizeLayout::default()); + + // Including the gap between items for calculate the item size + let gap = style + .gap + .along(self.axis) + .to_pixels(font_size.into(), rem_size); + + if !Rc::ptr_eq(&state.items_sizes, &self.item_sizes) { + state.items_sizes = self.item_sizes.clone(); + let mut sizes = Vec::with_capacity(self.items_count); + let mut origins = Vec::with_capacity(self.items_count); + let mut cumulative = px(0.); + for (i, item_size) in self.item_sizes.iter().enumerate() { + origins.push(cumulative); + let item_size = item_size.along(self.axis) + + if i + 1 == self.items_count { px(0.) } else { gap }; + sizes.push(item_size); + cumulative += item_size; + } + state.sizes = Rc::new(sizes); + state.origins = Rc::new(origins); + + if self.axis.is_horizontal() { + state.content_size.width = + cumulative; + } else { + state.content_size.height = + cumulative; + } + } + + if self.axis.is_horizontal() { + state.content_size.height = longest_item_size.height; + } else { + state.content_size.width = longest_item_size.width; + } + + (state.clone(), state) + }, + ); + + let axis = self.axis; + let layout_id = + match self.sizing_behavior { + ListSizingBehavior::Infer => { + window.with_text_style(style.text_style().cloned(), |window| { + let size_layout = size_layout.clone(); + + window.request_measured_layout(style, { + move |known_dimensions, available_space, _, _| { + let mut size = Size::default(); + if axis.is_horizontal() { + size.width = known_dimensions.width.unwrap_or( + match available_space.width { + AvailableSpace::Definite(x) => x, + AvailableSpace::MinContent + | AvailableSpace::MaxContent => { + size_layout.content_size.width + } + }, + ); + size.height = known_dimensions.width.unwrap_or( + match available_space.height { + AvailableSpace::Definite(x) => x, + AvailableSpace::MinContent + | AvailableSpace::MaxContent => { + size_layout.content_size.height + } + }, + ); + } else { + size.width = known_dimensions.width.unwrap_or( + match available_space.width { + AvailableSpace::Definite(x) => x, + AvailableSpace::MinContent + | AvailableSpace::MaxContent => { + size_layout.content_size.width + } + }, + ); + size.height = known_dimensions.height.unwrap_or( + match available_space.height { + AvailableSpace::Definite(x) => x, + AvailableSpace::MinContent + | AvailableSpace::MaxContent => { + size_layout.content_size.height + } + }, + ); + } + + size + } + }) + }) + } + ListSizingBehavior::Auto => window + .with_text_style(style.text_style().cloned(), |window| { + window.request_layout(style, None, cx) + }), + }; + + layout_id + }, + ); + + ( + layout_id, + VirtualListFrameState { + items: SmallVec::new(), + size_layout, + }, + ) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + layout.size_layout.last_layout_bounds = bounds; + + let style = self + .base + .interactivity() + .compute_style(global_id, None, window, cx); + let border_widths = style.border_widths.to_pixels(window.rem_size()); + let paddings = style + .padding + .to_pixels(bounds.size.into(), window.rem_size()); + + let item_sizes = &layout.size_layout.sizes; + let item_origins = &layout.size_layout.origins; + + let content_bounds = Bounds::from_corners( + bounds.origin + + point( + border_widths.left + paddings.left, + border_widths.top + paddings.top, + ), + bounds.bottom_right() + - point( + border_widths.right + paddings.right, + border_widths.bottom + paddings.bottom, + ), + ); + + // Update scroll_handle with the item bounds + let items_bounds = item_origins + .iter() + .enumerate() + .map(|(i, &origin)| { + let item_size = item_sizes[i]; + + Bounds { + origin: match self.axis { + Axis::Horizontal => point(content_bounds.left() + origin, px(0.)), + Axis::Vertical => point(px(0.), content_bounds.top() + origin), + }, + size: match self.axis { + Axis::Horizontal => size(item_size, content_bounds.size.height), + Axis::Vertical => size(content_bounds.size.width, item_size), + }, + } + }) + .collect::>(); + + let axis = self.axis; + + let mut scroll_state = self.scroll_handle.state.borrow_mut(); + scroll_state.axis = axis; + scroll_state.items_count = self.items_count; + scroll_state.last_content_size = Some(content_bounds.size); + + let mut scroll_offset = self.scroll_handle.offset(); + if let Some(scroll_to_item) = scroll_state.deferred_scroll_to_item.take() { + scroll_offset = self.scroll_to_deferred_item( + scroll_offset, + &items_bounds, + &content_bounds, + scroll_to_item, + ); + } + + scroll_offset = scroll_offset + .max(&point( + content_bounds.size.width - layout.size_layout.content_size.width, + content_bounds.size.height - layout.size_layout.content_size.height, + )) + .min(&point(px(0.), px(0.))); + if scroll_offset != self.scroll_handle.offset() { + self.scroll_handle.set_offset(scroll_offset); + } + + self.base.interactivity().prepaint( + global_id, + inspector_id, + bounds, + layout.size_layout.content_size, + window, + cx, + |_style, _, hitbox, window, cx| { + if self.items_count > 0 { + let min_scroll_offset = content_bounds.size.along(self.axis) + - layout.size_layout.content_size.along(self.axis); + + let is_scrolled = !scroll_offset.along(self.axis).is_zero(); + if is_scrolled { + match self.axis { + Axis::Horizontal if scroll_offset.x < min_scroll_offset => { + scroll_offset.x = min_scroll_offset; + self.scroll_handle.set_offset(scroll_offset); + } + Axis::Vertical if scroll_offset.y < min_scroll_offset => { + scroll_offset.y = min_scroll_offset; + self.scroll_handle.set_offset(scroll_offset); + } + _ => {} + } + } + + let (start, end) = match self.axis { + Axis::Horizontal => ( + -(scroll_offset.x + paddings.left), + -scroll_offset.x + content_bounds.size.width, + ), + Axis::Vertical => ( + -(scroll_offset.y + paddings.top), + -scroll_offset.y + content_bounds.size.height, + ), + }; + let first_visible_element_ix = + first_item_ending_after(item_sizes, item_origins, start); + let last_visible_element_ix = first_item_ending_after(item_sizes, item_origins, end) + .saturating_add(2) + .min(self.items_count); + + let visible_range = first_visible_element_ix + ..cmp::min(last_visible_element_ix, self.items_count); + + let items = (self.render_items)(visible_range.clone(), window, cx); + + let content_mask = ContentMask { bounds }; + window.with_content_mask(Some(content_mask), |window| { + for (mut item, ix) in items.into_iter().zip(visible_range.clone()) { + let item_origin = match self.axis { + Axis::Horizontal => { + content_bounds.origin + + point(item_origins[ix] + scroll_offset.x, scroll_offset.y) + } + Axis::Vertical => { + content_bounds.origin + + point(scroll_offset.x, item_origins[ix] + scroll_offset.y) + } + }; + + let available_space = match self.axis { + Axis::Horizontal => size( + AvailableSpace::Definite(item_sizes[ix]), + AvailableSpace::Definite(content_bounds.size.height), + ), + Axis::Vertical => size( + AvailableSpace::Definite(content_bounds.size.width), + AvailableSpace::Definite(item_sizes[ix]), + ), + }; + + item.layout_as_root(available_space, window, cx); + item.prepaint_at(item_origin, window, cx); + layout.items.push(item); + } + }); + } + + hitbox + }, + ) + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.base.interactivity().paint( + global_id, + inspector_id, + bounds, + hitbox.as_ref(), + window, + cx, + |_, window, cx| { + for item in &mut layout.items { + item.paint(window, cx); + } + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use gpui::{Context, TestAppContext}; + + struct VirtualListHarness { + axis: Axis, + item_sizes: Rc>>, + scroll_handle: VirtualListScrollHandle, + visible_ranges: Rc>>>, + } + + impl Render for VirtualListHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let item_sizes = self.item_sizes.clone(); + let visible_ranges = self.visible_ranges.clone(); + virtual_list( + cx.entity(), + "virtual-list-test", + self.axis, + self.item_sizes.clone(), + move |_, visible_range, _, _| { + visible_ranges.borrow_mut().push(visible_range.clone()); + visible_range + .map(|ix| div().w(item_sizes[ix].width).h(item_sizes[ix].height)) + .collect::>() + }, + ) + .track_scroll(&self.scroll_handle) + .w(px(60.)) + .h(px(60.)) + } + } + + fn exercise_axis(cx: &mut TestAppContext, axis: Axis) { + let item_size = match axis { + Axis::Horizontal => size(px(20.), px(10.)), + Axis::Vertical => size(px(10.), px(20.)), + }; + let item_sizes = Rc::new(vec![item_size; 20]); + let scroll_handle = VirtualListScrollHandle::new(); + let visible_ranges = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let item_sizes = item_sizes.clone(); + let scroll_handle = scroll_handle.clone(); + let visible_ranges = visible_ranges.clone(); + move |_, _| VirtualListHarness { + axis, + item_sizes, + scroll_handle, + visible_ranges, + } + }); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + let initial_range = visible_ranges.borrow().last().cloned().unwrap(); + assert_eq!(initial_range.start, 0); + assert!(initial_range.end < item_sizes.len()); + + scroll_handle.scroll_to_item(12, ScrollStrategy::Top); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let scrolled_range = visible_ranges.borrow().last().cloned().unwrap(); + assert!(scrolled_range.contains(&12)); + match axis { + Axis::Horizontal => assert!(scroll_handle.offset().x < px(0.)), + Axis::Vertical => assert!(scroll_handle.offset().y < px(0.)), + } + } + + #[gpui::test] + fn vertical_visible_range_and_deferred_scroll_are_preserved(cx: &mut TestAppContext) { + exercise_axis(cx, Axis::Vertical); + } + + #[gpui::test] + fn horizontal_visible_range_and_deferred_scroll_are_preserved(cx: &mut TestAppContext) { + exercise_axis(cx, Axis::Horizontal); + } + + #[gpui::test] + fn empty_list_draws_without_requesting_items(cx: &mut TestAppContext) { + let visible_ranges = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let visible_ranges = visible_ranges.clone(); + move |_, _| VirtualListHarness { + axis: Axis::Vertical, + item_sizes: Rc::new(Vec::new()), + scroll_handle: VirtualListScrollHandle::new(), + visible_ranges, + } + }); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(visible_ranges.borrow().is_empty()); + } +} diff --git a/vendor/gpui-base/tests/element_ext.rs b/vendor/gpui-base/tests/element_ext.rs new file mode 100644 index 00000000..7cfb21ad --- /dev/null +++ b/vendor/gpui-base/tests/element_ext.rs @@ -0,0 +1,30 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{Context, IntoElement, Render, Styled as _, TestAppContext, Window, div, px}; +use gpui_base::ElementExt as _; + +struct PrepaintHarness { + captured: Rc>>>, +} + +impl Render for PrepaintHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let captured = self.captured.clone(); + div() + .size(px(80.)) + .on_prepaint(move |bounds, _, _| *captured.borrow_mut() = Some(bounds)) + } +} + +#[gpui::test] +fn prepaint_callback_observes_the_parent_bounds(cx: &mut TestAppContext) { + let captured = Rc::new(RefCell::new(None)); + let result = captured.clone(); + let (_, cx) = cx.add_window_view(move |_, _| PrepaintHarness { captured }); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let bounds = result.borrow().expect("prepaint callback should run"); + assert_eq!(bounds.size.width, px(80.)); + assert_eq!(bounds.size.height, px(80.)); +} diff --git a/vendor/gpui-component-assets/Cargo.toml b/vendor/gpui-component-assets/Cargo.toml new file mode 100644 index 00000000..313fd5b3 --- /dev/null +++ b/vendor/gpui-component-assets/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "gpui-component-assets" +description = "Default bundled assets for GPUI Component." +keywords = ["desktop", "gpui", "shadcn", "ui", "uikit"] +license = "Apache-2.0" +documentation = "https://docs.rs/gpui-component" +homepage = "https://longbridge.github.io/gpui-component" +repository = "https://github.com/longbridge/gpui-component" +readme = "README.md" +edition.workspace = true +publish = true +version = "0.5.1" + +# `links` is the documented cargo mechanism for sharing build-time metadata +# across the dep graph: every dependent (regular OR build-dep) receives the +# `cargo:KEY=VALUE` lines from our build.rs as `DEP__` env vars +# in its own build script. We use this to publish the absolute path of our +# `assets/icons` directory so `gpui-component` can generate its `IconName` +# enum at proc-macro expansion time without a sibling-crate reference +# (which would break under `cargo vendor` / `cargo publish`). The value is +# conventionally a native-library name; we use a unique identifier scoped +# to this crate's purpose. Cargo enforces uniqueness across the build +# graph, which also prevents accidental double-linkage in dependents. +links = "gpui-component-default-icons" + +[lib] +doctest = false + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +log.workspace = true + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +rust-embed = { version = "8.7.2", features = ["interpolate-folder-path", "include-exclude"] } + +[target.'cfg(target_family = "wasm")'.dependencies] +reqwest = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" diff --git a/vendor/gpui-component-assets/README.md b/vendor/gpui-component-assets/README.md new file mode 100644 index 00000000..878057c2 --- /dev/null +++ b/vendor/gpui-component-assets/README.md @@ -0,0 +1,7 @@ +# GPUI Component Assets + +The default assets bundle for [GPUI Component](https://github.com/longbridge/gpui-component). + +## License + +Apache-2.0 diff --git a/vendor/gpui-component-assets/assets/.gitkeep b/vendor/gpui-component-assets/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/vendor/gpui-component-assets/assets/icons/a-large-small.svg b/vendor/gpui-component-assets/assets/icons/a-large-small.svg new file mode 100644 index 00000000..cfba2deb --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/a-large-small.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/arrow-down.svg b/vendor/gpui-component-assets/assets/icons/arrow-down.svg new file mode 100644 index 00000000..bb5b04ca --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/arrow-down.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/arrow-left.svg b/vendor/gpui-component-assets/assets/icons/arrow-left.svg new file mode 100644 index 00000000..ed034950 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/arrow-left.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/arrow-right.svg b/vendor/gpui-component-assets/assets/icons/arrow-right.svg new file mode 100644 index 00000000..519fffdc --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/arrow-right.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/arrow-up.svg b/vendor/gpui-component-assets/assets/icons/arrow-up.svg new file mode 100644 index 00000000..ad125196 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/arrow-up.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/asterisk.svg b/vendor/gpui-component-assets/assets/icons/asterisk.svg new file mode 100644 index 00000000..b2d266f7 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/asterisk.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/battery-charging.svg b/vendor/gpui-component-assets/assets/icons/battery-charging.svg new file mode 100644 index 00000000..91392c3d --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/battery-charging.svg @@ -0,0 +1,16 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/battery-full.svg b/vendor/gpui-component-assets/assets/icons/battery-full.svg new file mode 100644 index 00000000..4279ecf9 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/battery-full.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/battery-low.svg b/vendor/gpui-component-assets/assets/icons/battery-low.svg new file mode 100644 index 00000000..45bab8d5 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/battery-low.svg @@ -0,0 +1,18 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/battery-medium.svg b/vendor/gpui-component-assets/assets/icons/battery-medium.svg new file mode 100644 index 00000000..65d6ac16 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/battery-medium.svg @@ -0,0 +1,18 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/battery-warning.svg b/vendor/gpui-component-assets/assets/icons/battery-warning.svg new file mode 100644 index 00000000..aa358196 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/battery-warning.svg @@ -0,0 +1,16 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/battery.svg b/vendor/gpui-component-assets/assets/icons/battery.svg new file mode 100644 index 00000000..96d8ce1d --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/battery.svg @@ -0,0 +1,18 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/bell.svg b/vendor/gpui-component-assets/assets/icons/bell.svg new file mode 100644 index 00000000..eecc3ef4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/bell.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/book-open.svg b/vendor/gpui-component-assets/assets/icons/book-open.svg new file mode 100644 index 00000000..0264197a --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/book-open.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/bot.svg b/vendor/gpui-component-assets/assets/icons/bot.svg new file mode 100644 index 00000000..efcd1bbf --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/bot.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/building-2.svg b/vendor/gpui-component-assets/assets/icons/building-2.svg new file mode 100644 index 00000000..a5b21833 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/building-2.svg @@ -0,0 +1,16 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/calendar.svg b/vendor/gpui-component-assets/assets/icons/calendar.svg new file mode 100644 index 00000000..2724a480 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/calendar.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/case-sensitive.svg b/vendor/gpui-component-assets/assets/icons/case-sensitive.svg new file mode 100644 index 00000000..648134fa --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/case-sensitive.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/chart-pie.svg b/vendor/gpui-component-assets/assets/icons/chart-pie.svg new file mode 100644 index 00000000..b8439f41 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/chart-pie.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/check.svg b/vendor/gpui-component-assets/assets/icons/check.svg new file mode 100644 index 00000000..16bb3793 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/chevron-down.svg b/vendor/gpui-component-assets/assets/icons/chevron-down.svg new file mode 100644 index 00000000..1d316c76 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/chevron-left.svg b/vendor/gpui-component-assets/assets/icons/chevron-left.svg new file mode 100644 index 00000000..f27e97cd --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/chevron-right.svg b/vendor/gpui-component-assets/assets/icons/chevron-right.svg new file mode 100644 index 00000000..c1b76ee4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/chevron-up.svg b/vendor/gpui-component-assets/assets/icons/chevron-up.svg new file mode 100644 index 00000000..f1bb1ad6 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/chevrons-up-down.svg b/vendor/gpui-component-assets/assets/icons/chevrons-up-down.svg new file mode 100644 index 00000000..106fac00 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/chevrons-up-down.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/circle-check.svg b/vendor/gpui-component-assets/assets/icons/circle-check.svg new file mode 100644 index 00000000..4a7e1014 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/circle-check.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/circle-user.svg b/vendor/gpui-component-assets/assets/icons/circle-user.svg new file mode 100644 index 00000000..1d9b51d1 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/circle-user.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/circle-x.svg b/vendor/gpui-component-assets/assets/icons/circle-x.svg new file mode 100644 index 00000000..803c8cbe --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/circle-x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/close.svg b/vendor/gpui-component-assets/assets/icons/close.svg new file mode 100644 index 00000000..346ec0b4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/close.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/copy.svg b/vendor/gpui-component-assets/assets/icons/copy.svg new file mode 100644 index 00000000..f3b629c6 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/cpu.svg b/vendor/gpui-component-assets/assets/icons/cpu.svg new file mode 100644 index 00000000..d2a40451 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/cpu.svg @@ -0,0 +1,24 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/dash.svg b/vendor/gpui-component-assets/assets/icons/dash.svg new file mode 100644 index 00000000..a1264e78 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/dash.svg @@ -0,0 +1,3 @@ + + + diff --git a/vendor/gpui-component-assets/assets/icons/delete.svg b/vendor/gpui-component-assets/assets/icons/delete.svg new file mode 100644 index 00000000..eb058d9e --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/ellipsis-vertical.svg b/vendor/gpui-component-assets/assets/icons/ellipsis-vertical.svg new file mode 100644 index 00000000..c75a25a4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/ellipsis-vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/ellipsis.svg b/vendor/gpui-component-assets/assets/icons/ellipsis.svg new file mode 100644 index 00000000..07dcab51 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/ellipsis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/external-link.svg b/vendor/gpui-component-assets/assets/icons/external-link.svg new file mode 100644 index 00000000..dfef5df4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/external-link.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/eye-off.svg b/vendor/gpui-component-assets/assets/icons/eye-off.svg new file mode 100644 index 00000000..abf12804 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/eye.svg b/vendor/gpui-component-assets/assets/icons/eye.svg new file mode 100644 index 00000000..4b3a970b --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/file.svg b/vendor/gpui-component-assets/assets/icons/file.svg new file mode 100644 index 00000000..32c6b4a3 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/file.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/folder-closed.svg b/vendor/gpui-component-assets/assets/icons/folder-closed.svg new file mode 100644 index 00000000..aa197b01 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/folder-closed.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/folder-open.svg b/vendor/gpui-component-assets/assets/icons/folder-open.svg new file mode 100644 index 00000000..594de4c1 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/folder-open.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/folder.svg b/vendor/gpui-component-assets/assets/icons/folder.svg new file mode 100644 index 00000000..c66c4721 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/folder.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/frame.svg b/vendor/gpui-component-assets/assets/icons/frame.svg new file mode 100644 index 00000000..6511851a --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/frame.svg @@ -0,0 +1,22 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/gallery-vertical-end.svg b/vendor/gpui-component-assets/assets/icons/gallery-vertical-end.svg new file mode 100644 index 00000000..39b1f806 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/gallery-vertical-end.svg @@ -0,0 +1,18 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/github.svg b/vendor/gpui-component-assets/assets/icons/github.svg new file mode 100644 index 00000000..92adec97 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/globe.svg b/vendor/gpui-component-assets/assets/icons/globe.svg new file mode 100644 index 00000000..2082a439 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/hard-drive.svg b/vendor/gpui-component-assets/assets/icons/hard-drive.svg new file mode 100644 index 00000000..d49ce556 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/hard-drive.svg @@ -0,0 +1,19 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/heart-off.svg b/vendor/gpui-component-assets/assets/icons/heart-off.svg new file mode 100644 index 00000000..68cda020 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/heart-off.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/heart.svg b/vendor/gpui-component-assets/assets/icons/heart.svg new file mode 100644 index 00000000..2f267c91 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/heart.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/inbox.svg b/vendor/gpui-component-assets/assets/icons/inbox.svg new file mode 100644 index 00000000..15d6fae4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/inbox.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/info.svg b/vendor/gpui-component-assets/assets/icons/info.svg new file mode 100644 index 00000000..8f9c59ad --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/inspector.svg b/vendor/gpui-component-assets/assets/icons/inspector.svg new file mode 100644 index 00000000..c3517a42 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/inspector.svg @@ -0,0 +1,18 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/layout-dashboard.svg b/vendor/gpui-component-assets/assets/icons/layout-dashboard.svg new file mode 100644 index 00000000..15cb8de8 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/layout-dashboard.svg @@ -0,0 +1,24 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/loader-circle.svg b/vendor/gpui-component-assets/assets/icons/loader-circle.svg new file mode 100644 index 00000000..12cb72f6 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/loader-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/loader.svg b/vendor/gpui-component-assets/assets/icons/loader.svg new file mode 100644 index 00000000..f6ff93fa --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/loader.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/map.svg b/vendor/gpui-component-assets/assets/icons/map.svg new file mode 100644 index 00000000..bb909ec3 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/map.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/maximize.svg b/vendor/gpui-component-assets/assets/icons/maximize.svg new file mode 100644 index 00000000..b3504b57 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/maximize.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/memory-stick.svg b/vendor/gpui-component-assets/assets/icons/memory-stick.svg new file mode 100644 index 00000000..7d12b575 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/memory-stick.svg @@ -0,0 +1,22 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/menu.svg b/vendor/gpui-component-assets/assets/icons/menu.svg new file mode 100644 index 00000000..6598697f --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/menu.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/minimize.svg b/vendor/gpui-component-assets/assets/icons/minimize.svg new file mode 100644 index 00000000..3f81a42d --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/minimize.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/vendor/gpui-component-assets/assets/icons/minus.svg b/vendor/gpui-component-assets/assets/icons/minus.svg new file mode 100644 index 00000000..ab04a168 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/moon.svg b/vendor/gpui-component-assets/assets/icons/moon.svg new file mode 100644 index 00000000..dfadc201 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/network.svg b/vendor/gpui-component-assets/assets/icons/network.svg new file mode 100644 index 00000000..a6f0aaa5 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/network.svg @@ -0,0 +1,20 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/palette.svg b/vendor/gpui-component-assets/assets/icons/palette.svg new file mode 100644 index 00000000..b50674d3 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/palette.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-bottom-open.svg b/vendor/gpui-component-assets/assets/icons/panel-bottom-open.svg new file mode 100644 index 00000000..df77e5b2 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-bottom-open.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-bottom.svg b/vendor/gpui-component-assets/assets/icons/panel-bottom.svg new file mode 100644 index 00000000..ebe599c7 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-bottom.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-left-close.svg b/vendor/gpui-component-assets/assets/icons/panel-left-close.svg new file mode 100644 index 00000000..5f26cc6d --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-left-close.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-left-open.svg b/vendor/gpui-component-assets/assets/icons/panel-left-open.svg new file mode 100644 index 00000000..579e4583 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-left-open.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-left.svg b/vendor/gpui-component-assets/assets/icons/panel-left.svg new file mode 100644 index 00000000..2eed2667 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-left.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-right-close.svg b/vendor/gpui-component-assets/assets/icons/panel-right-close.svg new file mode 100644 index 00000000..5fe9dcdb --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-right-close.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-right-open.svg b/vendor/gpui-component-assets/assets/icons/panel-right-open.svg new file mode 100644 index 00000000..3b5ff0b4 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-right-open.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/panel-right.svg b/vendor/gpui-component-assets/assets/icons/panel-right.svg new file mode 100644 index 00000000..d29a4a51 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/panel-right.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/pause.svg b/vendor/gpui-component-assets/assets/icons/pause.svg new file mode 100644 index 00000000..a36563a1 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/pause.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/play.svg b/vendor/gpui-component-assets/assets/icons/play.svg new file mode 100644 index 00000000..2d5f0674 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/play.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/plus.svg b/vendor/gpui-component-assets/assets/icons/plus.svg new file mode 100644 index 00000000..651bbd49 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/redo-2.svg b/vendor/gpui-component-assets/assets/icons/redo-2.svg new file mode 100644 index 00000000..7ea3df93 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/redo-2.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/redo.svg b/vendor/gpui-component-assets/assets/icons/redo.svg new file mode 100644 index 00000000..b90dfa5c --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/redo.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/replace.svg b/vendor/gpui-component-assets/assets/icons/replace.svg new file mode 100644 index 00000000..5d7b480c --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/replace.svg @@ -0,0 +1,16 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/resize-corner.svg b/vendor/gpui-component-assets/assets/icons/resize-corner.svg new file mode 100644 index 00000000..31065be5 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/resize-corner.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/search.svg b/vendor/gpui-component-assets/assets/icons/search.svg new file mode 100644 index 00000000..a6583491 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/settings-2.svg b/vendor/gpui-component-assets/assets/icons/settings-2.svg new file mode 100644 index 00000000..31b06f70 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/settings-2.svg @@ -0,0 +1,16 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/settings.svg b/vendor/gpui-component-assets/assets/icons/settings.svg new file mode 100644 index 00000000..9cc2027a --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/settings.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/sort-ascending.svg b/vendor/gpui-component-assets/assets/icons/sort-ascending.svg new file mode 100644 index 00000000..bc17bd79 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/sort-ascending.svg @@ -0,0 +1,15 @@ + + + + diff --git a/vendor/gpui-component-assets/assets/icons/sort-descending.svg b/vendor/gpui-component-assets/assets/icons/sort-descending.svg new file mode 100644 index 00000000..f41eecbe --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/sort-descending.svg @@ -0,0 +1,15 @@ + + + + diff --git a/vendor/gpui-component-assets/assets/icons/square-terminal.svg b/vendor/gpui-component-assets/assets/icons/square-terminal.svg new file mode 100644 index 00000000..917dd1fb --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/square-terminal.svg @@ -0,0 +1,19 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/star-fill.svg b/vendor/gpui-component-assets/assets/icons/star-fill.svg new file mode 100644 index 00000000..3d542a9c --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/star-fill.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/star-off.svg b/vendor/gpui-component-assets/assets/icons/star-off.svg new file mode 100644 index 00000000..a2620605 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/star-off.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/star.svg b/vendor/gpui-component-assets/assets/icons/star.svg new file mode 100644 index 00000000..8d931311 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/star.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/sun.svg b/vendor/gpui-component-assets/assets/icons/sun.svg new file mode 100644 index 00000000..668d7789 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vendor/gpui-component-assets/assets/icons/thumbs-down.svg b/vendor/gpui-component-assets/assets/icons/thumbs-down.svg new file mode 100644 index 00000000..816273e9 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/thumbs-down.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/thumbs-up.svg b/vendor/gpui-component-assets/assets/icons/thumbs-up.svg new file mode 100644 index 00000000..01fae76f --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/thumbs-up.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/triangle-alert.svg b/vendor/gpui-component-assets/assets/icons/triangle-alert.svg new file mode 100644 index 00000000..1861c2e9 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/triangle-alert.svg @@ -0,0 +1 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/undo-2.svg b/vendor/gpui-component-assets/assets/icons/undo-2.svg new file mode 100644 index 00000000..660356e6 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/undo-2.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/undo.svg b/vendor/gpui-component-assets/assets/icons/undo.svg new file mode 100644 index 00000000..e66126ab --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/undo.svg @@ -0,0 +1,14 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/user.svg b/vendor/gpui-component-assets/assets/icons/user.svg new file mode 100644 index 00000000..aabc54fb --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/user.svg @@ -0,0 +1,16 @@ + diff --git a/vendor/gpui-component-assets/assets/icons/window-close.svg b/vendor/gpui-component-assets/assets/icons/window-close.svg new file mode 100644 index 00000000..62b6b0c7 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/window-close.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/vendor/gpui-component-assets/assets/icons/window-maximize.svg b/vendor/gpui-component-assets/assets/icons/window-maximize.svg new file mode 100644 index 00000000..9da4cd7e --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/window-maximize.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/vendor/gpui-component-assets/assets/icons/window-minimize.svg b/vendor/gpui-component-assets/assets/icons/window-minimize.svg new file mode 100644 index 00000000..708e27be --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/window-minimize.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/vendor/gpui-component-assets/assets/icons/window-restore.svg b/vendor/gpui-component-assets/assets/icons/window-restore.svg new file mode 100644 index 00000000..896b6b60 --- /dev/null +++ b/vendor/gpui-component-assets/assets/icons/window-restore.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/vendor/gpui-component-assets/build.rs b/vendor/gpui-component-assets/build.rs new file mode 100644 index 00000000..2f8cd05d --- /dev/null +++ b/vendor/gpui-component-assets/build.rs @@ -0,0 +1,38 @@ +use std::{env, path::Path}; + +fn main() { + // Publish the absolute path of `assets/icons` so dependents that need + // the icons at *build time* (notably `gpui-component`, whose `IconName` + // enum is generated at proc-macro expansion time) can find them without + // a sibling-crate reference. Cargo turns the `cargo:icons-dir=...` line + // below into the `DEP_GPUI_COMPONENT_DEFAULT_ICONS_ICONS_DIR` env var in + // every dependent's build script — see the `links` field in our + // `Cargo.toml` for the full mechanism. + // + // Within this crate, `assets/icons` is also consumed at *runtime* by + // `RustEmbed` (see `src/native_assets.rs`), which finds the files + // relative to our own `CARGO_MANIFEST_DIR`. So this build script does + // not need to copy or move anything — it only advertises the location. + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by cargo"); + let icons_dir = Path::new(&manifest_dir).join("assets/icons"); + + // Sanity-check that the directory we're advertising actually exists. + // Bail loudly if it's missing — silently publishing a bad path would + // give dependents a confusing "failed to read directory" later. + if !icons_dir.is_dir() { + panic!( + "expected default icons at {}, but the directory is missing", + icons_dir.display(), + ); + } + + println!("cargo:icons-dir={}", icons_dir.display()); + + // Rerun if the icon set changes (rename/add/remove). Per-SVG watching + // isn't needed because the dependent reads from this advertised path + // at *its own* expansion time, not via cached build-script output. + println!("cargo:rerun-if-changed=assets/icons"); + + // Also rerun if anyone fiddles with this script itself. + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/vendor/gpui-component-assets/src/lib.rs b/vendor/gpui-component-assets/src/lib.rs new file mode 100644 index 00000000..2d923616 --- /dev/null +++ b/vendor/gpui-component-assets/src/lib.rs @@ -0,0 +1,31 @@ +/// Embed application assets for GPUI Component. +/// +/// This assets provides icons svg files for [IconName](https://docs.rs/gpui-component/latest/gpui_component/enum.IconName.html). +/// +/// ## Usage +/// +/// ```rust,no_run +/// use gpui::*; +/// use gpui_component_assets::Assets; +/// +/// let app = gpui_platform::application().with_assets(Assets); +/// ``` +/// +/// ## Platform Differences +/// +/// - **Native (Desktop)**: Icons are embedded in the binary using RustEmbed +/// - **WASM (Web)**: Icons are downloaded from CDN using web_sys::Request +/// - This significantly reduces WASM bundle size +/// - Icons are downloaded on-demand when first used +/// - Downloaded icons are cached in memory +#[cfg(not(target_family = "wasm"))] +mod native_assets; + +#[cfg(target_family = "wasm")] +mod wasm_assets; + +#[cfg(not(target_family = "wasm"))] +pub use native_assets::Assets; + +#[cfg(target_family = "wasm")] +pub use wasm_assets::Assets; diff --git a/vendor/gpui-component-assets/src/native_assets.rs b/vendor/gpui-component-assets/src/native_assets.rs new file mode 100644 index 00000000..49e55eb9 --- /dev/null +++ b/vendor/gpui-component-assets/src/native_assets.rs @@ -0,0 +1,34 @@ +use anyhow::anyhow; +use gpui::{AssetSource, Result, SharedString}; +use std::borrow::Cow; + +/// Native implementation using RustEmbed +#[derive(rust_embed::RustEmbed)] +#[folder = "assets"] +#[include = "icons/**/*.svg"] +pub struct Assets; + +impl Assets { + /// Create a new Assets instance. The endpoint parameter is ignored for native builds. + pub fn new(_endpoint: impl Into) -> Self { + Self + } +} + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + if path.is_empty() { + return Ok(None); + } + + Self::get(path) + .map(|f| Some(f.data)) + .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path)) + } + + fn list(&self, path: &str) -> Result> { + Ok(Self::iter() + .filter_map(|p| p.starts_with(path).then(|| p.into())) + .collect()) + } +} diff --git a/vendor/gpui-component-assets/src/wasm_assets.rs b/vendor/gpui-component-assets/src/wasm_assets.rs new file mode 100644 index 00000000..3da9268d --- /dev/null +++ b/vendor/gpui-component-assets/src/wasm_assets.rs @@ -0,0 +1,108 @@ +use anyhow::anyhow; +use gpui::{AssetSource, Result, SharedString}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use wasm_bindgen_futures::spawn_local; + +/// WASM implementation - download assets on-demand +pub struct Assets { + endpoint: SharedString, + cache: Arc>>>, + pending: Arc>>, +} + +impl Assets { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + cache: Arc::new(RwLock::new(HashMap::new())), + pending: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +impl Default for Assets { + fn default() -> Self { + Self::new("") + } +} + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + if path.is_empty() { + return Ok(None); + } + + if path.starts_with("icons/") && path.ends_with(".svg") { + // Check if already cached + if let Ok(cache) = self.cache.read() { + if let Some(data) = cache.get(path) { + return Ok(Some(Cow::Owned(data.clone()))); + } + } + + // Check if download is already pending + let is_pending = self + .pending + .read() + .map(|p| p.contains_key(path)) + .unwrap_or(false); + + if !is_pending { + // Mark as pending and start download + if let Ok(mut pending) = self.pending.write() { + pending.insert(path.to_string(), true); + } + + let url = format!("{}/assets/{}", self.endpoint, path); + let path_clone = path.to_string(); + let cache = self.cache.clone(); + let pending = self.pending.clone(); + + spawn_local(async move { + match reqwest::get(&url).await { + Ok(response) => { + if response.status().is_success() { + match response.bytes().await { + Ok(bytes) => { + if let Ok(mut cache) = cache.write() { + cache.insert(path_clone.clone(), bytes.to_vec()); + } + } + Err(e) => { + log::warn!("Failed to read icon {}: {}", path_clone, e); + } + } + } else { + log::warn!( + "Failed to download icon {}: HTTP {}", + path_clone, + response.status() + ); + } + } + Err(e) => { + log::warn!("Failed to fetch icon {}: {}", path_clone, e); + } + } + + // Remove from pending + if let Ok(mut pending) = pending.write() { + pending.remove(&path_clone); + } + }); + } + + // Return error so GPUI will retry (but only log once per icon) + Err(anyhow!("Wasm assets loading, will be available soon...")) + } else { + Ok(None) + } + } + + fn list(&self, path: &str) -> Result> { + let _ = path; + Ok(Vec::new()) + } +} diff --git a/vendor/gpui-component-macros/Cargo.toml b/vendor/gpui-component-macros/Cargo.toml new file mode 100644 index 00000000..3deae6dc --- /dev/null +++ b/vendor/gpui-component-macros/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gpui-component-macros" +description = "Macros for GPUI Component." +version = "0.5.1" +license = "Apache-2.0" +publish = true +edition.workspace = true + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = "2.0" + +[package.metadata.cargo-machete] +ignored = ["proc-macro2", "core-text"] diff --git a/vendor/gpui-component-macros/LICENSE-APACHE b/vendor/gpui-component-macros/LICENSE-APACHE new file mode 120000 index 00000000..1cd601d0 --- /dev/null +++ b/vendor/gpui-component-macros/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/vendor/gpui-component-macros/src/derive_into_plot.rs b/vendor/gpui-component-macros/src/derive_into_plot.rs new file mode 100644 index 00000000..2a16c986 --- /dev/null +++ b/vendor/gpui-component-macros/src/derive_into_plot.rs @@ -0,0 +1,201 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +pub fn derive_into_plot(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + let type_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); + + let expanded = quote! { + impl #impl_generics gpui::IntoElement for #type_name #type_generics #where_clause { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl #impl_generics #type_name #type_generics #where_clause { + /// Element-local cell holding the last cursor position (plot-relative), shared by + /// the generated `prepaint`/`paint` so the cell type lives in a single place. + #[doc(hidden)] + fn __plot_tooltip_cursor( + global_id: &gpui::GlobalElementId, + window: &mut gpui::Window, + ) -> std::rc::Rc>>> { + window.with_element_state(global_id, |prev, _| { + let cell: std::rc::Rc< + std::cell::Cell>>, + > = prev.unwrap_or_default(); + (cell.clone(), cell) + }) + } + } + + impl #impl_generics gpui::Element for #type_name #type_generics #where_clause { + type RequestLayoutState = (); + // Carries the hitbox used for occlusion-aware hover detection, the plot's + // prepainted child elements and the prepainted tooltip overlay (if any) + // from `prepaint` to `paint`. + type PrepaintState = ( + Option, + Vec, + Option, + ); + + fn id(&self) -> Option { + // `Some` opts the plot in to interactive tooltips; `None` (the default) + // keeps the element a pure, non-interactive plot identical to before. + ::id(self) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let style = gpui::Style { + size: gpui::Size::full(), + ..Default::default() + }; + + (window.request_layout(style, None, cx), ()) + } + + fn prepaint( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + _: &mut Self::RequestLayoutState, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> Self::PrepaintState { + // Child elements must be laid out here: `layout_as_root` / `prepaint_at` + // are prepaint-only. Above the early return below, so plots without an + // id still get their children. + let children = ::prepaint(self, bounds, window, cx); + + // No id => tooltips disabled => behave exactly like a non-interactive plot. + let Some(global_id) = global_id else { + return (None, children, None); + }; + + // The hitbox lets the mouse handler hit-test with occlusion awareness: + // `Hitbox::is_hovered` returns false while an occluding hitbox (e.g. an + // open popup menu) is above the plot, unlike a plain bounds test. + let hitbox = window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal); + + let overlay = (|| { + // The cell only gates visibility: it holds the cursor recorded by the + // mouse handler / per-frame sync in `paint`, which is one frame stale + // during scrolling. Rendering that cached point while the bounds move + // makes the tooltip jitter, so derive the position from the live mouse + // position and this frame's bounds instead. + Self::__plot_tooltip_cursor(global_id, window).get()?; + let mouse = window.mouse_position(); + if !bounds.contains(&mouse) { + return None; + } + let position = mouse - bounds.origin; + let state = ::tooltip_state(self, position, bounds, cx)?; + + // Pass the live cursor so the tooltip box can follow it; the crosshair and + // dots in `state` stay snapped to the data point by `tooltip_state`. + // + // The overlay paints in the plot's own layer, so the crosshair and dots stay + // below content drawn over the plot. The tooltip box defers itself (see + // `plot::tooltip::Tooltip`) to paint above sibling content, since it can + // extend past the plot bounds. + let mut overlay = ::tooltip(self, &state, position, bounds, window, cx)?; + overlay.prepaint_as_root(bounds.origin, bounds.size.into(), window, cx); + Some(overlay) + })(); + + (Some(hitbox), children, overlay) + } + + fn paint( + &mut self, + global_id: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) { + ::paint(self, bounds, window, cx); + + let (hitbox, children, overlay) = prepaint; + + // Child elements (e.g. element labels) paint above the plot graphics. + for child in children.iter_mut() { + child.paint(window, cx); + } + + if let (Some(global_id), Some(hitbox)) = (global_id, hitbox.as_ref()) { + // Record the cursor position into element-local state on every move so the + // next frame can hit-test it. The handler never touches `self`, satisfying + // the `'static` bound; it only captures the (Copy) bounds, the hitbox id + // and the state cell. + let cell = Self::__plot_tooltip_cursor(global_id, window); + let hitbox = hitbox.clone(); + + // Scrolling (or any relayout) moves the plot under a stationary cursor + // without emitting MouseMoveEvent, so re-derive the cursor state every + // frame: `mouse_hit_test` is recomputed against this frame's hitboxes + // right before paint, so `is_hovered` is fresh here. Only a visibility + // flip needs a corrective frame — `prepaint` derives the tooltip position + // from the live mouse, not from the cell. `refresh()` is a no-op while + // drawing; schedule via `request_animation_frame`. + let next = if hitbox.is_hovered(window) { + Some(window.mouse_position() - bounds.origin) + } else { + None + }; + if cell.get() != next { + let visibility_changed = cell.get().is_some() != next.is_some(); + cell.set(next); + if visibility_changed { + window.request_animation_frame(); + } + } + + window.on_mouse_event( + move |e: &gpui::MouseMoveEvent, _, window: &mut gpui::Window, _| { + // `is_hovered` is false when an occluding hitbox (popup menu, + // modal, ...) is above the cursor, so the tooltip clears instead + // of tracking the mouse through the overlay. + let next = if hitbox.is_hovered(window) { + Some(e.position - bounds.origin) + } else { + None + }; + + if cell.get() != next { + cell.set(next); + window.refresh(); + } + }, + ); + } + + // Paint the tooltip overlay (crosshair, dots) above the plot graphics; the + // deferred box paints later, above everything. + if let Some(overlay) = overlay.as_mut() { + overlay.paint(window, cx); + } + } + } + }; + + TokenStream::from(expanded) +} diff --git a/vendor/gpui-component-macros/src/lib.rs b/vendor/gpui-component-macros/src/lib.rs new file mode 100644 index 00000000..56674e42 --- /dev/null +++ b/vendor/gpui-component-macros/src/lib.rs @@ -0,0 +1,239 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; + +mod derive_into_plot; + +/// Input for icon_name! macro: EnumName, "path", [optional derives] +struct IconNameInput { + enum_name: syn::Ident, + _comma: syn::Token![,], + path: syn::LitStr, + derives: Option<( + syn::Token![,], + syn::punctuated::Punctuated, + )>, +} + +impl Parse for IconNameInput { + fn parse(input: ParseStream) -> syn::Result { + let enum_name = input.parse()?; + let _comma = input.parse()?; + let path = input.parse()?; + + // Check if there's an optional derives list + let derives = if input.peek(syn::Token![,]) { + let comma = input.parse()?; + let content; + syn::bracketed!(content in input); + let derives = content.parse_terminated(syn::Path::parse, syn::Token![,])?; + Some((comma, derives)) + } else { + None + }; + + Ok(IconNameInput { + enum_name, + _comma, + path, + derives, + }) + } +} + +#[proc_macro_derive(IntoPlot)] +pub fn derive_into_plot(input: TokenStream) -> TokenStream { + derive_into_plot::derive_into_plot(input) +} + +/// Convert an SVG filename to PascalCase identifier. +/// +/// Strips `.svg` extension, splits on separators (`-`, `_`, `.`), +/// and capitalizes each word following Rust naming conventions. +/// +/// # Examples +/// +/// ```ignore +/// assert_eq!(pascal_case("arrow-right.svg"), "ArrowRight"); +/// assert_eq!(pascal_case("some_icon_name.svg"), "SomeIconName"); +/// assert_eq!(pascal_case("icon-123.svg"), "Icon123"); +/// ``` +fn pascal_case(filename: &str) -> String { + filename + .strip_suffix(".svg") + .unwrap_or(filename) + .split(|c: char| c == '-' || c == '_' || c == '.') + .filter(|part| !part.is_empty()) + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) if first.is_ascii_digit() => word.to_string(), + Some(first) => { + let mut result = String::with_capacity(word.len()); + result.extend(first.to_uppercase()); + result.push_str(&chars.as_str().to_lowercase()); + result + } + } + }) + .collect() +} + +/// Generate a custom icon enum and its `IconNamed` impl by scanning a directory of SVG files. +/// +/// Accepts an enum name, a path, and optionally a list of additional derive traits. +/// Each `.svg` file becomes an enum variant using PascalCase conversion. +/// +/// The path may be either: +/// +/// - **A literal path** (the common case), resolved relative to the calling crate's +/// `CARGO_MANIFEST_DIR`. Use this when the icons live inside your own package. +/// - **An env-var reference** of the form `"$NAME"`, where `NAME` names a build-time +/// environment variable whose value is the absolute path to the icons directory. +/// Use this when the icons live in *another* crate and the path is plumbed +/// through cargo's `links` / `DEP__` propagation mechanism. The default +/// `IconName` enum in `gpui-component` uses this pattern to consume icons from +/// `gpui-component-assets` without a sibling-crate reference, which would +/// otherwise break `cargo vendor` and `cargo publish`. +/// +/// # Example +/// +/// ```ignore +/// // Literal path (relative to the calling crate's CARGO_MANIFEST_DIR) +/// icon_named!(IconName, "icons"); +/// +/// // Env-var reference (resolved at macro expansion time) +/// icon_named!(IconName, "$GPUI_COMPONENT_DEFAULT_ICONS_DIR"); +/// +/// // With custom derives +/// icon_named!(IconName, "icons", [Debug, Copy, PartialEq, Eq]); +/// ``` +#[proc_macro] +pub fn icon_named(input: TokenStream) -> TokenStream { + let IconNameInput { + enum_name, + path, + derives, + .. + } = syn::parse_macro_input!(input as IconNameInput); + + let raw_path = path.value(); + + // Resolve the path. A leading `$` switches us into env-var mode: the + // remainder of the string is an env var name whose value (set by the + // caller's `build.rs` via `cargo:rustc-env=`) is the absolute path of + // the icons directory. Otherwise treat the string as a path relative + // to the calling crate's `CARGO_MANIFEST_DIR`, the original behavior. + let icons_dir = if let Some(env_name) = raw_path.strip_prefix('$') { + let env_value = std::env::var(env_name).unwrap_or_else(|_| { + panic!( + "icon_named!: env var `{env_name}` is not set at expansion time. \ + Ensure the calling crate's build.rs propagates it via \ + `cargo:rustc-env={env_name}=`." + ) + }); + std::path::PathBuf::from(env_value) + } else { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + std::path::Path::new(&manifest_dir).join(&raw_path) + }; + + let mut entries: Vec<(String, String)> = Vec::new(); + + let dir = std::fs::read_dir(&icons_dir).unwrap_or_else(|e| { + panic!( + "generate_icon_enum: failed to read '{}': {}", + icons_dir.display(), + e + ) + }); + + for entry in dir { + let entry = entry.expect("failed to read directory entry"); + let filename = entry.file_name().to_string_lossy().to_string(); + if filename.ends_with(".svg") { + let variant_name = pascal_case(&filename); + let path = format!("icons/{}", filename); + entries.push((variant_name, path)); + } + } + + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let variants: Vec = entries + .iter() + .map(|(name, _)| proc_macro2::Ident::new(name, proc_macro2::Span::call_site())) + .collect(); + let paths: Vec<&str> = entries.iter().map(|(_, p)| p.as_str()).collect(); + + // Build derive list: always include IntoElement and Clone, then add custom derives + let derive_attrs = if let Some((_, custom_derives)) = derives { + let derives_vec: Vec<_> = custom_derives.iter().collect(); + quote! { + #[derive(IntoElement, Clone, #(#derives_vec),*)] + } + } else { + quote! { + #[derive(IntoElement, Clone)] + } + }; + + let expanded = quote! { + #derive_attrs + + pub enum #enum_name { + #(#variants,)* + } + + impl IconNamed for #enum_name { + fn path(self) -> SharedString { + match self { + #(Self::#variants => #paths,)* + } + .into() + } + } + }; + + TokenStream::from(expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pascal_case_basic() { + assert_eq!(pascal_case("arrow-right.svg"), "ArrowRight"); + assert_eq!(pascal_case("home.svg"), "Home"); + assert_eq!(pascal_case("x-circle.svg"), "XCircle"); + + assert_eq!(pascal_case("some_icon_name.svg"), "SomeIconName"); + assert_eq!(pascal_case("arrow_up_down.svg"), "ArrowUpDown"); + + assert_eq!(pascal_case("kebab-case_mixed.svg"), "KebabCaseMixed"); + assert_eq!(pascal_case("icon-with_under.svg"), "IconWithUnder"); + + assert_eq!(pascal_case("icon-123.svg"), "Icon123"); + assert_eq!(pascal_case("arrow-2x.svg"), "Arrow2x"); + assert_eq!(pascal_case("24-hour.svg"), "24Hour"); + + assert_eq!(pascal_case("arrow--right.svg"), "ArrowRight"); + assert_eq!(pascal_case("icon__name.svg"), "IconName"); + assert_eq!(pascal_case("multiple---dash.svg"), "MultipleDash"); + + assert_eq!(pascal_case("a.svg"), "A"); + assert_eq!(pascal_case("-leading.svg"), "Leading"); + assert_eq!(pascal_case("trailing-.svg"), "Trailing"); + assert_eq!(pascal_case("-.svg"), ""); + + assert_eq!(pascal_case("arrow-right"), "ArrowRight"); + assert_eq!(pascal_case("home"), "Home"); + + assert_eq!(pascal_case("hello.svg"), "Hello"); + assert_eq!(pascal_case("WORLD.svg"), "World"); + assert_eq!(pascal_case("iOS-icon.svg"), "IosIcon"); + assert_eq!(pascal_case("API-key.svg"), "ApiKey"); + } +} diff --git a/vendor/gpui-component/Cargo.toml b/vendor/gpui-component/Cargo.toml index 1126bb57..8839847e 100644 --- a/vendor/gpui-component/Cargo.toml +++ b/vendor/gpui-component/Cargo.toml @@ -1,330 +1,226 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - [package] -edition = "2024" -name = "gpui-component" -version = "0.5.1" -build = false -publish = true -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false description = "UI components for building fantastic desktop application by using GPUI." -homepage = "https://longbridge.github.io/gpui-component" -documentation = "https://docs.rs/gpui-component" -readme = "README.md" -keywords = [ - "desktop", - "gpui", - "shadcn", - "ui", - "uikit", -] +keywords = ["desktop", "gpui", "shadcn", "ui", "uikit"] license = "Apache-2.0" +name = "gpui-component" +documentation = "https://docs.rs/gpui-component" +homepage = "https://longbridge.github.io/gpui-component" repository = "https://github.com/longbridge/gpui-component" -resolver = "2" - -[features] -decimal = ["dep:rust_decimal"] -inspector = [ - "gpui-macros/inspector", - "gpui/inspector", - "wry/devtools", -] -tree-sitter-languages = [ - "dep:tree-sitter-bash", - "dep:tree-sitter-c", - "dep:tree-sitter-c-sharp", - "dep:tree-sitter-cmake", - "dep:tree-sitter-cpp", - "dep:tree-sitter-css", - "dep:tree-sitter-diff", - "dep:tree-sitter-elixir", - "dep:tree-sitter-embedded-template", - "dep:tree-sitter-go", - "dep:tree-sitter-graphql", - "dep:tree-sitter-html", - "dep:tree-sitter-java", - "dep:tree-sitter-javascript", - "dep:tree-sitter-jsdoc", - "dep:tree-sitter-make", - "dep:tree-sitter-md", - "dep:tree-sitter-proto", - "dep:tree-sitter-python", - "dep:tree-sitter-ruby", - "dep:tree-sitter-rust", - "dep:tree-sitter-scala", - "dep:tree-sitter-sequel", - "dep:tree-sitter-swift", - "dep:tree-sitter-toml-ng", - "dep:tree-sitter-typescript", - "dep:tree-sitter-yaml", - "dep:tree-sitter-zig", -] -webview = ["dep:wry"] +readme = "../../README.md" +version = "0.5.2" +publish = true +edition.workspace = true [lib] -name = "gpui_component" -path = "src/lib.rs" doctest = false -[dependencies.aho-corasick] -version = "1.1.3" - -[dependencies.anyhow] -version = "1" - -[dependencies.chrono] -version = "0.4.38" - -[dependencies.enum-iterator] -version = "2.1.0" - -[dependencies.gpui] -version = "0.2.2" - -[dependencies.gpui-component-macros] -version = "0.5.1" - -[dependencies.gpui-macros] -version = "0.2.2" - -[dependencies.html5ever] -version = "0.27" - -[dependencies.itertools] -version = "0.13.0" - -[dependencies.lsp-types] -version = "0.97.0" -features = ["proposed"] - -[dependencies.markdown] -version = "1.0.0" - -[dependencies.markup5ever_rcdom] -version = "0.3.0" - -[dependencies.notify] -version = "7.0.0" - -[dependencies.num-traits] -version = "0.2" - -[dependencies.once_cell] -version = "1.19.0" - -[dependencies.paste] -version = "1" - -[dependencies.regex] -version = "1" +[features] +decimal = ["dep:rust_decimal"] +inspector = ["gpui_macros/inspector", "gpui/inspector", "gpui-base/inspector"] +tree-sitter = ["dep:tree-sitter", "dep:tree-sitter-json"] -[dependencies.ropey] -version = "=2.0.0-beta.1" -features = [ - "metric_lines_lf", - "metric_utf16", +# For syntax highlighting in Markdown and CodeEditor. +tree-sitter-languages = [ + "tree-sitter-astro", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-csharp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-ejs", + "tree-sitter-elixir", + "tree-sitter-erb", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", + "tree-sitter-kotlin", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-markdown", + "tree-sitter-php", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sql", + "tree-sitter-svelte", + "tree-sitter-swift", + "tree-sitter-toml", + "tree-sitter-tsx", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", ] - -[dependencies.rust-i18n] -version = "3" - -[dependencies.rust_decimal] -version = "1.37.0" -optional = true - -[dependencies.schemars] -version = "1" - -[dependencies.serde] -version = "1.0.219" -features = ["derive"] - -[dependencies.serde_json] -version = "1" - -[dependencies.serde_repr] -version = "0.1" - -[dependencies.smallvec] -version = "1" - -[dependencies.smol] -version = "2" - -[dependencies.sum-tree] -version = "0.2.0" -package = "zed-sum-tree" - -[dependencies.tracing] -version = "0.1.41" - -[dependencies.tree-sitter] -version = "0.25.4" - -[dependencies.tree-sitter-bash] -version = "0.23.3" -optional = true - -[dependencies.tree-sitter-c] -version = "0.24.1" -optional = true - -[dependencies.tree-sitter-c-sharp] -version = "0.23.1" -optional = true - -[dependencies.tree-sitter-cmake] -version = "0.7.1" -optional = true - -[dependencies.tree-sitter-cpp] -version = "0.23.4" -optional = true - -[dependencies.tree-sitter-css] -version = "0.23.2" -optional = true - -[dependencies.tree-sitter-diff] -version = "0.1.0" -optional = true - -[dependencies.tree-sitter-elixir] -version = "0.3" -optional = true - -[dependencies.tree-sitter-embedded-template] -version = "0.23.0" -optional = true - -[dependencies.tree-sitter-go] -version = "0.23.4" -optional = true - -[dependencies.tree-sitter-graphql] -version = "0.1.0" -optional = true - -[dependencies.tree-sitter-html] -version = "0.23.2" -optional = true - -[dependencies.tree-sitter-java] -version = "0.23.5" -optional = true - -[dependencies.tree-sitter-javascript] -version = "0.23.1" -optional = true - -[dependencies.tree-sitter-jsdoc] -version = "0.23.2" -optional = true - -[dependencies.tree-sitter-json] -version = "0.24.8" - -[dependencies.tree-sitter-make] -version = "1.1.1" -optional = true - -[dependencies.tree-sitter-md] -version = "0.5.1" -optional = true - -[dependencies.tree-sitter-proto] -version = "0.2.0" -optional = true - -[dependencies.tree-sitter-python] -version = "0.23.6" -optional = true - -[dependencies.tree-sitter-ruby] -version = "0.23.1" -optional = true - -[dependencies.tree-sitter-rust] -version = "0.24.0" -optional = true - -[dependencies.tree-sitter-scala] -version = "0.23.4" -optional = true - -[dependencies.tree-sitter-sequel] -version = "0.3.8" -optional = true - -[dependencies.tree-sitter-swift] -version = "0.7.0" -optional = true - -[dependencies.tree-sitter-toml-ng] -version = "0.7.0" -optional = true - -[dependencies.tree-sitter-typescript] -version = "0.23.2" -optional = true - -[dependencies.tree-sitter-yaml] -version = "0.7.1" -optional = true - -[dependencies.tree-sitter-zig] -version = "1.1.2" -optional = true - -[dependencies.unicode-segmentation] -version = "1.12.0" - -[dependencies.uuid] -version = "1.10" - -[dependencies.wry] -version = "0.53.3" -optional = true -package = "lb-wry" - -[dev-dependencies.gpui] -version = "0.2.2" -features = ["test-support"] - -[dev-dependencies.indoc] -version = "2" - -[target.'cfg(target_os = "macos")'.dependencies.core-text] -version = "=21.0.0" - -[lints.clippy] -almost_complete_range = "allow" -arc_with_non_send_sync = "allow" -borrowed_box = "allow" -dbg_macro = "deny" -let_underscore_future = "allow" -manual_is_multiple_of = "allow" -map_entry = "allow" -module_inception = "allow" -non_canonical_partial_ord_impl = "allow" -reversed_empty_ranges = "allow" -single_range_in_vec_init = "allow" -todo = "deny" -type_complexity = "allow" - -[lints.clippy.style] -level = "allow" -priority = -1 +tree-sitter-astro = ["tree-sitter", "dep:tree-sitter-astro-next"] +tree-sitter-bash = ["tree-sitter", "dep:tree-sitter-bash"] +tree-sitter-c = ["tree-sitter", "dep:tree-sitter-c"] +tree-sitter-cmake = ["tree-sitter", "dep:tree-sitter-cmake"] +tree-sitter-cpp = ["tree-sitter", "dep:tree-sitter-cpp"] +tree-sitter-csharp = ["tree-sitter", "dep:tree-sitter-c-sharp"] +tree-sitter-css = ["tree-sitter", "dep:tree-sitter-css"] +tree-sitter-diff = ["tree-sitter", "dep:tree-sitter-diff"] +tree-sitter-ejs = ["tree-sitter", "dep:tree-sitter-embedded-template"] +tree-sitter-elixir = ["tree-sitter", "dep:tree-sitter-elixir"] +tree-sitter-erb = ["tree-sitter", "dep:tree-sitter-embedded-template"] +tree-sitter-go = ["tree-sitter", "dep:tree-sitter-go"] +tree-sitter-graphql = ["tree-sitter", "dep:tree-sitter-graphql"] +tree-sitter-html = ["tree-sitter", "dep:tree-sitter-html"] +tree-sitter-java = ["tree-sitter", "dep:tree-sitter-java"] +tree-sitter-javascript = ["tree-sitter", "dep:tree-sitter-javascript"] +tree-sitter-jsdoc = ["tree-sitter", "dep:tree-sitter-jsdoc"] +tree-sitter-kotlin = ["tree-sitter", "dep:tree-sitter-kotlin-sg"] +tree-sitter-lua = ["tree-sitter", "dep:tree-sitter-lua"] +tree-sitter-make = ["tree-sitter", "dep:tree-sitter-make"] +tree-sitter-markdown = ["tree-sitter", "dep:tree-sitter-md"] +tree-sitter-markdown-inline = ["tree-sitter-markdown"] +tree-sitter-php = ["tree-sitter", "dep:tree-sitter-php"] +tree-sitter-proto = ["tree-sitter", "dep:tree-sitter-proto"] +tree-sitter-python = ["tree-sitter", "dep:tree-sitter-python"] +tree-sitter-ruby = ["tree-sitter", "dep:tree-sitter-ruby"] +tree-sitter-rust = ["tree-sitter", "dep:tree-sitter-rust"] +tree-sitter-scala = ["tree-sitter", "dep:tree-sitter-scala"] +tree-sitter-sql = ["tree-sitter", "dep:tree-sitter-sequel"] +tree-sitter-svelte = ["tree-sitter", "dep:tree-sitter-svelte-next"] +tree-sitter-swift = ["tree-sitter", "dep:tree-sitter-swift"] +tree-sitter-toml = ["tree-sitter", "dep:tree-sitter-toml-ng"] +tree-sitter-tsx = ["tree-sitter", "dep:tree-sitter-typescript"] +tree-sitter-typescript = ["tree-sitter", "dep:tree-sitter-typescript"] +tree-sitter-yaml = ["tree-sitter", "dep:tree-sitter-yaml"] +tree-sitter-zig = ["tree-sitter", "dep:tree-sitter-zig"] + +[dependencies] +anyhow.workspace = true +gpui = { workspace = true } +gpui_macros.workspace = true +gpui-base.workspace = true +gpui-component-macros = { workspace = true } +# This regular dependency propagates the canonical icon directory from the +# assets build script into this crate's build script through Cargo DEP metadata. +gpui-component-assets = { workspace = true } +notify.workspace = true +ropey.workspace = true +rust-i18n.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +smallvec.workspace = true +sum-tree.workspace = true +tracing.workspace = true +log.workspace = true + +enum-iterator = "2.1.0" +itertools = "0.13.0" +once_cell = "1.19.0" +paste = "1" +uuid = "1.10" + +# Async primitives (cross-platform, including WASM) +futures = "0.3" + +# Chart +num-traits = "0.2" +rust_decimal = { version = "1.37.0", optional = true } + +# Markdown Parser +markdown = { version = "1.0.0", features = ["serde"] } + +# HTML Parser +html5ever = "0.27" +markup5ever_rcdom = "0.3.0" + +# Calendar +chrono = "0.4.38" + +# Code Editor +lsp-types.workspace = true + +# Cross-platform time APIs (zero-cost on native, uses web APIs on WASM) +instant.workspace = true + +# Native-only dependencies (not available on WASM) +[target.'cfg(not(target_family = "wasm"))'.dependencies] +smol.workspace = true +tree-sitter = { version = "0.26", optional = true } +tree-sitter-astro-next = { version="0.1.1", optional = true } +tree-sitter-bash = { version = "0.23.3", optional = true } +tree-sitter-c = { version = "0.24.1", optional = true } +tree-sitter-c-sharp = { version = "0.23.1", optional = true } +tree-sitter-cmake = { version = "0.7.1", optional = true } +tree-sitter-cpp = { version = "0.23.4", optional = true } +tree-sitter-css = { version = "0.23.2", optional = true } +tree-sitter-diff = { version = "0.1.0", optional = true } +tree-sitter-elixir = { version = "0.3", optional = true } +tree-sitter-embedded-template = { version = "0.23.0", optional = true } +tree-sitter-go = { version = "0.23.4", optional = true } +tree-sitter-graphql = { version = "0.1.0", optional = true } +tree-sitter-html = { version = "0.23.2", optional = true } +tree-sitter-java = { version = "0.23.5", optional = true } +tree-sitter-javascript = { version = "0.23.1", optional = true } +tree-sitter-jsdoc = { version = "0.23.2", optional = true } +tree-sitter-json = { version = "0.24.8", optional = true } +tree-sitter-kotlin-sg = { version = "0.4.0", optional = true } +tree-sitter-lua = { version = "0.4.1", optional = true } +tree-sitter-make = { version = "1.1.1", optional = true } +tree-sitter-md = { version = "0.5.1", optional = true } +tree-sitter-php = { version = "0.24.2", optional = true } +tree-sitter-proto = { version = "0.2.0", optional = true } +tree-sitter-python = { version = "0.23.6", optional = true } +tree-sitter-ruby = { version = "0.23.1", optional = true } +tree-sitter-rust = { version = "0.24.0", optional = true } +tree-sitter-scala = { version = "0.23.4", optional = true } +tree-sitter-sequel = { version = "0.3.8", optional = true } +tree-sitter-svelte-next = { version = "0.1.1", optional = true } +tree-sitter-swift = { version = "0.7.0", optional = true } +tree-sitter-toml-ng = { version = "0.7.0", optional = true } +tree-sitter-typescript = { version = "0.23.2", optional = true } +tree-sitter-yaml = { version = "0.7.1", optional = true } +tree-sitter-zig = { version = "1.1.2", optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-text = "=21.0.0" +# Native menu (NativeMenu) — drives AppKit NSMenu via objc2. +raw-window-handle = { workspace = true } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = [ + "NSMenu", + "NSMenuItem", + "NSView", + "NSWindow", + "NSResponder", + "NSEvent", + "NSImage", +] } +objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSGeometry"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# Native menu (NativeMenu) — drives Win32 popup menus. +raw-window-handle = { workspace = true } +resvg = "0.45.1" +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Graphics_GdiPlus", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", +] } + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +indoc = "2" + +[lints] +workspace = true + +[package.metadata.cargo-machete] +ignored = ["gpui_macros"] diff --git a/vendor/gpui-component/build.rs b/vendor/gpui-component/build.rs new file mode 100644 index 00000000..44c7f0a6 --- /dev/null +++ b/vendor/gpui-component/build.rs @@ -0,0 +1,33 @@ +use std::env; + +fn main() { + // `gpui-component-assets` exposes the absolute path of its default + // icons directory via cargo's `links` mechanism (see its `Cargo.toml` + // and `build.rs`). We receive that path as + // `DEP_GPUI_COMPONENT_DEFAULT_ICONS_ICONS_DIR` here, then re-publish + // it as a `rustc-env` so it's visible to the + // `icon_named!("$GPUI_COMPONENT_DEFAULT_ICONS_DIR")` proc-macro call + // in `src/icon.rs` at expansion time. This is what lets the default + // `IconName` enum be generated from the assets crate's icon set + // without a sibling-crate reference, which would otherwise break + // `cargo vendor` and `cargo publish`. + // + // Cargo only propagates `DEP__` through *regular* + // dependencies, not through build-deps — see the `dependencies` + // (not `build-dependencies`) entry for `gpui-component-assets` in + // `Cargo.toml`. + let icons_dir = env::var("DEP_GPUI_COMPONENT_DEFAULT_ICONS_ICONS_DIR").expect( + "DEP_GPUI_COMPONENT_DEFAULT_ICONS_ICONS_DIR is set by gpui-component-assets's \ + build.rs via its `links` field; make sure the regular dependency on \ + gpui-component-assets is intact in Cargo.toml", + ); + + println!("cargo:rustc-env=GPUI_COMPONENT_DEFAULT_ICONS_DIR={icons_dir}"); + + // Rerun if the icons directory we point at changes. The assets crate's + // build.rs already declares the same `rerun-if-changed`, but cargo + // invalidates each build script independently, so this keeps us in + // lockstep. + println!("cargo:rerun-if-changed={icons_dir}"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/vendor/gpui-component/locales/ui.yml b/vendor/gpui-component/locales/ui.yml index 3ede8421..5b9e1ef3 100644 --- a/vendor/gpui-component/locales/ui.yml +++ b/vendor/gpui-component/locales/ui.yml @@ -4,198 +4,328 @@ Calendar: en: Su zh-CN: 日 zh-HK: 日 + zh-TW: 日 it: Do week.1: en: Mo zh-CN: 一 zh-HK: 一 + zh-TW: 一 it: Lu week.2: en: Tu zh-CN: 二 zh-HK: 二 + zh-TW: 二 it: Ma week.3: en: We zh-CN: 三 zh-HK: 三 + zh-TW: 三 it: Me week.4: en: Th zh-CN: 四 zh-HK: 四 + zh-TW: 四 it: Gi week.5: en: Fr zh-CN: 五 zh-HK: 五 + zh-TW: 五 it: Ve week.6: en: Sa zh-CN: 六 zh-HK: 六 + zh-TW: 六 it: Sa month.January: en: January zh-CN: 一月 zh-HK: 一月 + zh-TW: 一月 it: Gennaio month.February: en: February zh-CN: 二月 zh-HK: 二月 + zh-TW: 二月 it: Febbraio month.March: en: March zh-CN: 三月 zh-HK: 三月 + zh-TW: 三月 it: Marzo month.April: en: April zh-CN: 四月 zh-HK: 四月 + zh-TW: 四月 it: Aprile month.May: en: May zh-CN: 五月 zh-HK: 五月 + zh-TW: 五月 it: Maggio month.June: en: June zh-CN: 六月 zh-HK: 六月 + zh-TW: 六月 it: Giugno month.July: en: July zh-CN: 七月 zh-HK: 七月 + zh-TW: 七月 it: Luglio month.August: en: August zh-CN: 八月 zh-HK: 八月 + zh-TW: 八月 it: Agosto month.September: en: September zh-CN: 九月 zh-HK: 九月 + zh-TW: 九月 it: Settembre month.October: en: October zh-CN: 十月 zh-HK: 十月 + zh-TW: 十月 it: Ottobre month.November: en: November zh-CN: 十一月 zh-HK: 十一月 + zh-TW: 十一月 it: Novembre month.December: en: December zh-CN: 十二月 zh-HK: 十二月 + zh-TW: 十二月 it: Dicembre DatePicker: placeholder: en: "Select date" zh-CN: 选择日期 zh-HK: 選擇日期 + zh-TW: 選取日期 it: "Seleziona data" Select: placeholder: en: "Please select" zh-CN: "请选择" zh-HK: "請選擇" + zh-TW: "請選擇" it: Seleziona +ComboBox: + placeholder: + en: "Please select" + zh-CN: "请选择" + zh-HK: "請選擇" + zh-TW: "請選擇" + it: Seleziona + search_placeholder: + en: "Search..." + zh-CN: "搜索..." + zh-HK: "搜索..." + zh-TW: "搜尋..." + it: "Cerca..." + empty: + en: "No results" + zh-CN: "暂无数据" + zh-HK: "暫無數據" + zh-TW: "沒有結果" + it: "Nessun risultato" Dock: Unnamed: en: Unnamed zh-CN: 未命名 zh-HK: 未命名 + zh-TW: 未命名 it: "Senza nome" Close: en: Close zh-CN: 关闭 zh-HK: 關閉 + zh-TW: 關閉 it: Chiudi Zoom In: en: Zoom In zh-CN: 放大 zh-HK: 放大 + zh-TW: 放大 it: Zoom In Zoom Out: en: Zoom Out zh-CN: 缩小 zh-HK: 縮小 + zh-TW: 縮小 it: Zoom Out Collapse: en: Collapse zh-CN: 隐藏 zh-HK: 隱藏 + zh-TW: 收合 it: Nascondi Expand: en: Expand zh-CN: 展开 zh-HK: 展開 + zh-TW: 展開 it: Espandi +ColorPicker: + Palette: + en: Palette + zh-CN: 调色板 + zh-HK: 調色板 + zh-TW: 調色盤 + it: Tavolozza + HSLA: + en: HSLA + zh-CN: HSLA + zh-HK: HSLA + zh-TW: HSLA + it: HSLA + Hue: + en: Hue + zh-CN: 色相 + zh-HK: 色相 + zh-TW: 色相 + it: Tonalità + Saturation: + en: Saturation + zh-CN: 饱和度 + zh-HK: 飽和度 + zh-TW: 飽和度 + it: Saturazione + Lightness: + en: Lightness + zh-CN: 亮度 + zh-HK: 亮度 + zh-TW: 亮度 + it: Luminosità + Alpha: + en: Alpha + zh-CN: 透明度 + zh-HK: 透明度 + zh-TW: 透明度 + it: Alfa Dialog: ok: en: OK zh-CN: 确定 zh-HK: 確定 + zh-TW: 確定 it: OK cancel: en: Cancel zh-CN: 取消 zh-HK: 取消 + zh-TW: 取消 it: Annulla +Command: + placeholder: + en: Type a command or search... + zh-CN: 输入命令或搜索... + zh-HK: 輸入命令或搜尋... + zh-TW: 輸入命令或搜尋... + it: Digita un comando o cerca... + empty: + en: No results found. + zh-CN: 未找到结果。 + zh-HK: 未找到結果。 + zh-TW: 未找到結果。 + it: Nessun risultato trovato. List: search_placeholder: en: Search... zh-CN: 搜索... zh-HK: 搜索... + zh-TW: 搜尋... it: Ricerca... Input: + Decrement: + en: Decrement + zh-CN: 减少 + zh-HK: 減少 + zh-TW: 減少 + Increment: + en: Increment + zh-CN: 增加 + zh-HK: 增加 + zh-TW: 增加 Replace: en: Replace zh-CN: 替换 zh-HK: 替換 + zh-TW: 取代 Replace All: en: Replace All zh-CN: 全部替换 zh-HK: 全部替換 + zh-TW: 全部取代 Cut: en: Cut zh-CN: 剪切 zh-HK: 剪切 + zh-TW: 剪下 Copy: en: Copy zh-CN: 复制 zh-HK: 複製 + zh-TW: 複製 Paste: en: Paste zh-CN: 粘贴 zh-HK: 貼上 + zh-TW: 貼上 Select All: en: Select All zh-CN: 全选 zh-HK: 全選 + zh-TW: 全選 Go to Definition: en: Go to Definition zh-CN: 跳转到定义 zh-HK: 跳轉到定義 + zh-TW: 前往定義 Show Code Actions: en: Show Code Actions zh-CN: 显示代码操作 zh-HK: 顯示代碼操作 + zh-TW: 顯示程式碼動作 Settings: search_placeholder: en: Search... zh-CN: 搜索... zh-HK: 搜索... + zh-TW: 搜尋... it: Ricerca... Reset All: en: Reset All zh-CN: 重置全部 zh-HK: 重置全部 + zh-TW: 全部重設 it: Resetta Tutto +Pagination: + previous: + en: Previous + zh-CN: 上一页 + zh-HK: 上一頁 + zh-TW: 上一頁 + next: + en: Next + zh-CN: 下一页 + zh-HK: 下一頁 + zh-TW: 下一頁 diff --git a/vendor/gpui-component/src/accordion.rs b/vendor/gpui-component/src/accordion.rs index 07be2afd..3fa4dc43 100644 --- a/vendor/gpui-component/src/accordion.rs +++ b/vendor/gpui-component/src/accordion.rs @@ -1,17 +1,30 @@ -use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc}; +use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc, time::Duration}; use gpui::{ - div, prelude::FluentBuilder as _, rems, AnyElement, App, ElementId, InteractiveElement as _, - IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, - Window, + AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, + InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, ParentElement, Pixels, + RenderOnce, SharedString, StatefulInteractiveElement as _, Style, StyleRefinement, Styled, + Window, div, percentage, prelude::FluentBuilder as _, px, relative, rems, size, }; -use crate::{h_flex, v_flex, ActiveTheme as _, Icon, IconName, Sizable, Size}; +use crate::{ + ActiveTheme as _, Icon, IconName, Sizable, Size, StyledExt as _, animation::ease_out_cubic, + h_flex, +}; +use gpui_base::{ + Accordion as BaseAccordion, AccordionHeader as BaseAccordionHeader, + AccordionItem as BaseAccordionItem, AccordionPanel as BaseAccordionPanel, AccordionTrigger, + Transition, transition, +}; + +/// Duration of the expand/collapse animation. +const ANIMATION_DURATION: Duration = Duration::from_millis(200); /// Accordion element. #[derive(IntoElement)] pub struct Accordion { id: ElementId, + style: StyleRefinement, multiple: bool, size: Size, bordered: bool, @@ -25,6 +38,7 @@ impl Accordion { pub fn new(id: impl Into) -> Self { Self { id: id.into(), + style: StyleRefinement::default(), multiple: false, size: Size::default(), bordered: true, @@ -81,40 +95,55 @@ impl Sizable for Accordion { } } +impl Styled for Accordion { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + impl RenderOnce for Accordion { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let open_ixs = Rc::new(RefCell::new(HashSet::new())); - let is_multiple = self.multiple; + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let open_indices = Rc::new(RefCell::new(HashSet::new())); + let multiple = self.multiple; + let last_ix = self.children.len().saturating_sub(1); - v_flex() - .id(self.id) + BaseAccordion::new(self.id) + .v_flex() .size_full() - .when(self.bordered, |this| this.gap_1()) + // The bordered accordion is a single rounded card, the items are + // joined by their separators. + .when(self.bordered, |this| { + this.border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .overflow_hidden() + }) + .refine_style(&self.style) .children( self.children .into_iter() .enumerate() .map(|(ix, accordion)| { if accordion.open { - open_ixs.borrow_mut().insert(ix); + open_indices.borrow_mut().insert(ix); } accordion .index(ix) + .last(ix == last_ix) .with_size(self.size) - .bordered(self.bordered) .disabled(self.disabled) .on_toggle_click({ - let open_ixs = Rc::clone(&open_ixs); + let open_indices = open_indices.clone(); move |open, _, _| { - let mut open_ixs = open_ixs.borrow_mut(); + let mut open_indices = open_indices.borrow_mut(); if *open { - if !is_multiple { - open_ixs.clear(); + if !multiple { + open_indices.clear(); } - open_ixs.insert(ix); + open_indices.insert(ix); } else { - open_ixs.remove(&ix); + open_indices.remove(&ix); } } }) @@ -122,28 +151,158 @@ impl RenderOnce for Accordion { ) .when_some( self.on_toggle_click.filter(|_| !self.disabled), - move |this, on_toggle_click| { - let open_ixs = Rc::clone(&open_ixs); + |this, on_toggle| { this.on_click(move |_, window, cx| { - let open_ixs: Vec = open_ixs.borrow().iter().map(|&ix| ix).collect(); - - on_toggle_click(&open_ixs, window, cx); + let open_indices = + open_indices.borrow().iter().copied().collect::>(); + on_toggle(&open_indices, window, cx) }) }, ) } } +/// The content of an [`AccordionItem`], animating its height on toggle. +/// +/// It stays in the tree while closed, clipped to zero height, to be able to +/// animate the collapse and to keep its natural height measured. +struct AnimatedAccordionPanel { + id: ElementId, + progress: f32, + child: AnyElement, +} + +#[derive(Clone, Copy, Default)] +struct AnimatedAccordionPanelState { + /// The natural height measured in the last prepaint. + height: Option, +} + +impl AnimatedAccordionPanel { + fn new(id: impl Into, progress: f32, child: AnyElement) -> Self { + Self { + id: id.into(), + progress, + child, + } + } +} + +impl IntoElement for AnimatedAccordionPanel { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for AnimatedAccordionPanel { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let height = window.with_element_state( + global_id.expect("AnimatedAccordionPanel must have an id"), + |state: Option, _| { + let state = state.unwrap_or_default(); + (state.height, state) + }, + ); + + let mut style = Style::default(); + style.size.width = relative(1.).into(); + match height { + // Not measured yet, let the content lay itself out. + None if self.progress > 0. => {} + None => style.size.height = px(0.).into(), + Some(height) => style.size.height = (height * self.progress).into(), + } + + (window.request_layout(style, None, cx), ()) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + // Lay out at the natural height, `bounds` clips the visible part. + let available = size( + AvailableSpace::Definite(bounds.size.width), + AvailableSpace::MinContent, + ); + let measured = self.child.layout_as_root(available, window, cx); + + let changed = window.with_element_state( + global_id.expect("AnimatedAccordionPanel must have an id"), + |state: Option, _| { + let mut state = state.unwrap_or_default(); + let changed = state.height != Some(measured.height); + state.height = Some(measured.height); + (changed, state) + }, + ); + + // The measured height is only used by the next `request_layout`, ask + // for that frame, or the new height never gets painted. + if changed { + window.request_animation_frame(); + } + + // Masked here too, so the hidden content takes no mouse events. + window.with_content_mask(Some(ContentMask { bounds }), |window| { + self.child.prepaint_at(bounds.origin, window, cx); + }); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask(Some(ContentMask { bounds }), |window| { + self.child.paint(window, cx); + }); + } +} + /// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it. #[derive(IntoElement)] pub struct AccordionItem { index: usize, + last: bool, + style: StyleRefinement, + hover_style: Option, + title_style: StyleRefinement, + content_style: StyleRefinement, icon: Option, title: AnyElement, children: Vec, open: bool, size: Size, - bordered: bool, disabled: bool, on_toggle_click: Option>, } @@ -153,6 +312,11 @@ impl AccordionItem { pub fn new() -> Self { Self { index: 0, + last: false, + style: StyleRefinement::default(), + hover_style: None, + title_style: StyleRefinement::default(), + content_style: StyleRefinement::default(), icon: None, title: SharedString::default().into_any_element(), children: Vec::new(), @@ -160,7 +324,6 @@ impl AccordionItem { disabled: false, on_toggle_click: None, size: Size::default(), - bordered: true, } } @@ -176,11 +339,6 @@ impl AccordionItem { self } - pub fn bordered(mut self, bordered: bool) -> Self { - self.bordered = bordered; - self - } - pub fn open(mut self, open: bool) -> Self { self.open = open; self @@ -191,11 +349,38 @@ impl AccordionItem { self } + /// Set extra style for the title row. + pub fn title_style(mut self, style: StyleRefinement) -> Self { + self.title_style = style; + self + } + + /// Set the style of the title row while the mouse is over it. + /// + /// There is no hover style by default. The title row is the part that + /// toggles the item, so the hover feedback belongs there, not on the + /// whole item. + pub fn hover(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self { + self.hover_style = Some(f(StyleRefinement::default())); + self + } + + /// Set extra style for the content below the title. + pub fn content_style(mut self, style: StyleRefinement) -> Self { + self.content_style = style; + self + } + fn index(mut self, index: usize) -> Self { self.index = index; self } + fn last(mut self, last: bool) -> Self { + self.last = last; + self + } + fn on_toggle_click( mut self, on_toggle_click: impl Fn(&bool, &mut Window, &mut App) + 'static, @@ -218,94 +403,154 @@ impl Sizable for AccordionItem { } } +impl Styled for AccordionItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + impl RenderOnce for AccordionItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let text_size = match self.size { - Size::XSmall => rems(0.875), - Size::Small => rems(0.875), - _ => rems(1.0), + Size::XSmall => rems(0.8125), + Size::Large => rems(1.0), + _ => rems(0.875), }; + let progress = transition( + (self.index, "accordion-panel"), + if self.open { 1. } else { 0. }, + Transition::new(ANIMATION_DURATION).ease(ease_out_cubic), + window, + cx, + ); + let trigger = AccordionTrigger::new(("trigger", self.index)) + .open(self.open) + .disabled(self.disabled) + .h_flex() + .justify_between() + .gap_3() + .font_medium() + .map(|this| match self.size { + Size::XSmall => this.py_1().px_1p5(), + Size::Small => this.py_1p5().px_2(), + Size::Large => this.py_3().px_4(), + _ => this.py_2().px_3(), + }) + .when(self.open, |this| this.text_color(cx.theme().foreground)) + .refine_style(&self.title_style) + .child( + h_flex() + .flex_1() + .min_w_0() + .items_center() + .map(|this| match self.size { + Size::XSmall | Size::Small => this.gap_1(), + _ => this.gap_2(), + }) + .when_some(self.icon, |this, icon| { + this.child(icon.with_size(self.size)) + }) + .child(self.title), + ) + .when(!self.disabled, |this| { + this.when_some(self.hover_style, |this, hover_style| { + this.hover(move |this| this.refine_style(&hover_style)) + }) + .child( + Icon::new(IconName::ChevronDown) + .xsmall() + .flex_none() + .text_color(cx.theme().muted_foreground) + .rotate(percentage(if self.open { 0.5 } else { 0. })), + ) + .when_some(self.on_toggle_click, |this, on_toggle_click| { + this.on_change(move |open, _, window, cx| { + on_toggle_click(&open, window, cx); + }) + }) + }); div().flex_1().child( - v_flex() + BaseAccordionItem::new() + .open(self.open) + .disabled(self.disabled) + .header( + BaseAccordionHeader::new(trigger) + .id(("header", self.index)) + .w_full(), + ) + .panel( + BaseAccordionPanel::new() + .id(("panel", self.index)) + .open(self.open) + .keep_mounted(true) + .w_full() + .child(AnimatedAccordionPanel::new( + ("content", self.index), + progress, + div() + .map(|this| match self.size { + Size::XSmall => this.pb_1().px_1p5(), + Size::Small => this.pb_1p5().px_2(), + Size::Large => this.pb_3().px_4(), + _ => this.pb_2().px_3(), + }) + .refine_style(&self.content_style) + .children(self.children) + .into_any_element(), + )), + ) + .v_flex() .w_full() - .bg(cx.theme().accordion) + .bg(cx.theme().tokens.accordion) .overflow_hidden() - .when(self.bordered, |this| { - this.border_1() - .rounded(cx.theme().radius) - .border_color(cx.theme().border) + .when(!self.last, |this| { + this.border_b_1().border_color(cx.theme().border) }) .text_size(text_size) - .child( - h_flex() - .id(self.index) - .justify_between() - .gap_3() - .map(|this| match self.size { - Size::XSmall => this.py_0().px_1p5(), - Size::Small => this.py_0p5().px_2(), - Size::Large => this.py_1p5().px_4(), - _ => this.py_1().px_3(), - }) - .when(self.open, |this| { - this.when(self.bordered, |this| { - this.text_color(cx.theme().foreground) - .border_b_1() - .border_color(cx.theme().border) - }) - }) - .when(!self.bordered, |this| { - this.border_b_1().border_color(cx.theme().border) - }) - .child( - h_flex() - .items_center() - .map(|this| match self.size { - Size::XSmall => this.gap_1(), - Size::Small => this.gap_1(), - _ => this.gap_2(), - }) - .when_some(self.icon, |this, icon| { - this.child( - icon.with_size(self.size) - .text_color(cx.theme().muted_foreground), - ) - }) - .child(self.title), - ) - .when(!self.disabled, |this| { - this.hover(|this| this.bg(cx.theme().accordion_hover)) - .child( - Icon::new(if self.open { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .xsmall() - .text_color(cx.theme().muted_foreground), - ) - .when_some(self.on_toggle_click, |this, on_toggle_click| { - this.on_click({ - move |_, window, cx| { - on_toggle_click(&!self.open, window, cx); - } - }) - }) - }), - ) - .when(self.open, |this| { - this.child( + .refine_style(&self.style), + ) + } +} + +#[cfg(test)] +mod tests { + use gpui::{Context, InteractiveElement as _, Render, TestAppContext, div, px}; + + use super::*; + + struct Harness; + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Accordion::new("accordion-layout") + .w(px(240.)) + .h(px(100.)) + .item(|item| { + item.open(true) + .title(div().debug_selector(|| "first-title".into()).child("First")) + .child(div().debug_selector(|| "first-content".into()).h(px(60.))) + }) + .item(|item| { + item.title( div() - .map(|this| match self.size { - Size::XSmall => this.p_1p5(), - Size::Small => this.p_2(), - Size::Large => this.p_4(), - _ => this.p_3(), - }) - .children(self.children), + .debug_selector(|| "second-title".into()) + .child("Second"), ) - }), - ) + }) + } + } + + #[gpui::test] + fn expanded_panel_keeps_content_between_its_header_and_the_next_item(cx: &mut TestAppContext) { + cx.update(crate::theme::init); + let (_, cx) = cx.add_window_view(|_, _| Harness); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let first = cx.debug_bounds("first-title").unwrap(); + let content = cx.debug_bounds("first-content").unwrap(); + let second = cx.debug_bounds("second-title").unwrap(); + assert!(first.origin.y < content.origin.y); + assert!(content.origin.y + content.size.height <= second.origin.y); } } diff --git a/vendor/gpui-component/src/actions.rs b/vendor/gpui-component/src/actions.rs deleted file mode 100644 index e73b5136..00000000 --- a/vendor/gpui-component/src/actions.rs +++ /dev/null @@ -1,11 +0,0 @@ -use gpui::{actions, Action}; -use serde::Deserialize; - -#[derive(Clone, Action, PartialEq, Eq, Deserialize)] -#[action(namespace = ui, no_json)] -pub struct Confirm { - /// Is confirm with secondary. - pub secondary: bool, -} - -actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]); diff --git a/vendor/gpui-component/src/alert.rs b/vendor/gpui-component/src/alert.rs index 08757e02..7ceb3a9f 100644 --- a/vendor/gpui-component/src/alert.rs +++ b/vendor/gpui-component/src/alert.rs @@ -1,22 +1,21 @@ use std::rc::Rc; use gpui::{ - div, prelude::FluentBuilder as _, px, rems, App, ClickEvent, ElementId, Empty, Hsla, - InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, - StatefulInteractiveElement, StyleRefinement, Styled, Window, + App, ClickEvent, ElementId, Empty, Hsla, InteractiveElement, IntoElement, ParentElement as _, + RenderOnce, Role, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, + div, prelude::FluentBuilder as _, px, rems, transparent_white, }; use crate::{ - h_flex, + ActiveTheme as _, Colorize, Icon, IconName, Sizable, Size, StyledExt, h_flex, text::{Text, TextViewStyle}, - ActiveTheme as _, Icon, IconName, Sizable, Size, StyledExt, }; /// The variant of the [`Alert`]. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum AlertVariant { #[default] - Secondary, + Default, Info, Success, Warning, @@ -26,31 +25,31 @@ pub enum AlertVariant { impl AlertVariant { fn fg(&self, cx: &App) -> Hsla { match self { - AlertVariant::Secondary => cx.theme().secondary_foreground, - AlertVariant::Info => cx.theme().info, - AlertVariant::Success => cx.theme().success, - AlertVariant::Warning => cx.theme().warning, - AlertVariant::Error => cx.theme().danger, + Self::Default => cx.theme().foreground, + Self::Info => cx.theme().info, + Self::Success => cx.theme().success, + Self::Warning => cx.theme().warning, + Self::Error => cx.theme().danger, } } - fn color(&self, cx: &App) -> Hsla { + fn bg(&self, cx: &App) -> Hsla { match self { - AlertVariant::Secondary => cx.theme().secondary, - AlertVariant::Info => cx.theme().info, - AlertVariant::Success => cx.theme().success, - AlertVariant::Warning => cx.theme().warning, - AlertVariant::Error => cx.theme().danger, + Self::Default => cx.theme().background, + Self::Info => cx.theme().info.mix_oklab(transparent_white(), 0.04), + Self::Success => cx.theme().success.mix_oklab(transparent_white(), 0.04), + Self::Warning => cx.theme().warning.mix_oklab(transparent_white(), 0.04), + Self::Error => cx.theme().danger.mix_oklab(transparent_white(), 0.04), } } fn border_color(&self, cx: &App) -> Hsla { match self { - AlertVariant::Secondary => cx.theme().border, - AlertVariant::Info => cx.theme().info, - AlertVariant::Success => cx.theme().success, - AlertVariant::Warning => cx.theme().warning, - AlertVariant::Error => cx.theme().danger, + Self::Default => cx.theme().border, + Self::Info => cx.theme().info.mix_oklab(transparent_white(), 0.3), + Self::Success => cx.theme().success.mix_oklab(transparent_white(), 0.3), + Self::Warning => cx.theme().warning.mix_oklab(transparent_white(), 0.3), + Self::Error => cx.theme().danger.mix_oklab(transparent_white(), 0.3), } } } @@ -184,15 +183,16 @@ impl RenderOnce for Alert { _ => (cx.theme().radius, px(16.), px(10.), px(12.)), }; - let color = self.variant.color(cx); + let bg = self.variant.bg(cx); let fg = self.variant.fg(cx); let border_color = self.variant.border_color(cx); h_flex() .id(self.id) + .role(Role::Alert) .w_full() .text_color(fg) - .bg(color.opacity(0.08)) + .bg(bg) .px(padding_x) .py(padding_y) .gap(gap) @@ -238,8 +238,8 @@ impl RenderOnce for Alert { .id("close") .p_0p5() .rounded(cx.theme().radius) - .hover(|this| this.bg(color.opacity(0.1))) - .active(|this| this.bg(color.opacity(0.2))) + .hover(|this| this.bg(bg.opacity(0.8))) + .active(|this| this.bg(bg.opacity(0.9))) .on_click(move |ev, window, cx| { on_close(ev, window, cx); }) diff --git a/vendor/gpui-component/src/animation.rs b/vendor/gpui-component/src/animation.rs deleted file mode 100644 index be3539ea..00000000 --- a/vendor/gpui-component/src/animation.rs +++ /dev/null @@ -1,19 +0,0 @@ -/// A cubic bezier function like CSS `cubic-bezier`. -/// -/// Builder: -/// -/// https://cubic-bezier.com -pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 { - move |t: f32| { - let one_t = 1.0 - t; - let one_t2 = one_t * one_t; - let t2 = t * t; - let t3 = t2 * t; - - // The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1 - let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3; - let y = 3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3; - - y - } -} diff --git a/vendor/gpui-component/src/async_util.rs b/vendor/gpui-component/src/async_util.rs new file mode 100644 index 00000000..3e9a0376 --- /dev/null +++ b/vendor/gpui-component/src/async_util.rs @@ -0,0 +1 @@ +pub use gpui_base::async_util::{Receiver, Sender, unbounded}; diff --git a/vendor/gpui-component/src/avatar/avatar.rs b/vendor/gpui-component/src/avatar/avatar.rs index bb8e6307..93763b6d 100644 --- a/vendor/gpui-component/src/avatar/avatar.rs +++ b/vendor/gpui-component/src/avatar/avatar.rs @@ -1,12 +1,12 @@ use gpui::{ - div, img, prelude::FluentBuilder, App, Div, Hsla, ImageSource, InteractiveElement, - Interactivity, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement, - Styled, Window, + App, Hsla, ImageSource, InteractiveElement, Interactivity, IntoElement, ParentElement as _, + RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder, }; +use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage}; use crate::{ - avatar::{avatar_size, AvatarSized as _}, - ActiveTheme, Colorize, Icon, IconName, Sizable, Size, StyledExt, + ActiveTheme, Colorize, Icon, IconName, Sizable, Size, StyledExt, ThemeStyled as _, + avatar::{AvatarSized as _, avatar_size}, }; /// User avatar element. @@ -14,7 +14,7 @@ use crate::{ /// We can use [`Sizable`] trait to set the size of the avatar (see also: [`avatar_size`] about the size in pixels). #[derive(IntoElement)] pub struct Avatar { - base: Div, + base: BaseAvatar, style: StyleRefinement, src: Option, name: Option, @@ -26,7 +26,7 @@ pub struct Avatar { impl Avatar { pub fn new() -> Self { Self { - base: div(), + base: BaseAvatar::new(), style: StyleRefinement::default(), src: None, name: None, @@ -92,37 +92,43 @@ impl RenderOnce for Avatar { const BG_OPACITY: f32 = 0.2; - self.base - .avatar_size(self.size) + let fallback = AvatarFallback::new() + .size_full() .flex() .items_center() .justify_center() - .flex_shrink_0() - .rounded_full() + .rounded_full_style(cx) .overflow_hidden() - .bg(cx.theme().secondary) - .text_color(cx.theme().background) - .border_1() - .border_color(cx.theme().background) - .when(self.name.is_none() && self.src.is_none(), |this| { + .when(self.name.is_none(), |this| { this.text_size(avatar_size(self.size) * 0.6) .child(self.placeholder) }) - .map(|this| match self.src { - None => this.when(self.name.is_some(), |this| { - let color_ix = gpui::hash(&self.short_name) % COLOR_COUNT; - let color = default_color(color_ix, cx); - - this.bg(color.opacity(BG_OPACITY)) - .text_color(color) - .child(div().avatar_text_size(self.size).child(self.short_name)) - }), - Some(src) => this.child( - img(src) - .avatar_size(self.size) - .rounded_full() + .when(self.name.is_some(), |this| { + let color_ix = gpui::hash(&self.short_name) % COLOR_COUNT; + let color = default_color(color_ix, cx); + this.bg(color.opacity(BG_OPACITY)) + .text_color(color) + .child(div().avatar_text_size(self.size).child(self.short_name)) + }) + .refine_style(&inner_style); + + self.base + .size(avatar_size(self.size)) + .flex_shrink_0() + .rounded_full_style(cx) + .overflow_hidden() + .bg(cx.theme().tokens.secondary) + .text_color(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .fallback(fallback) + .when_some(self.src, |this, src| { + this.image( + AvatarImage::new(src) + .size_full() + .rounded_full_style(cx) .refine_style(&inner_style), - ), + ) }) .refine_style(&self.style) } @@ -153,4 +159,16 @@ mod tests { assert_eq!(extract_text_initials(&"Foo Bar Dar"), "FB".to_string()); assert_eq!(extract_text_initials(&"huacnlee"), "HU".to_string()); } + + #[gpui::test] + fn test_avatar_builder(_cx: &mut gpui::TestAppContext) { + let avatar = Avatar::new() + .name("Jason Lee") + .placeholder(Icon::new(IconName::User)) + .large(); + + assert_eq!(avatar.name, Some(SharedString::from("Jason Lee"))); + assert_eq!(avatar.short_name, SharedString::from("JL")); + assert_eq!(avatar.size, Size::Large); + } } diff --git a/vendor/gpui-component/src/avatar/avatar_group.rs b/vendor/gpui-component/src/avatar/avatar_group.rs index 24b035c2..6a0147da 100644 --- a/vendor/gpui-component/src/avatar/avatar_group.rs +++ b/vendor/gpui-component/src/avatar/avatar_group.rs @@ -1,9 +1,9 @@ use gpui::{ - div, prelude::FluentBuilder as _, Div, InteractiveElement, Interactivity, IntoElement, - ParentElement as _, RenderOnce, StyleRefinement, Styled, + Div, InteractiveElement, Interactivity, IntoElement, ParentElement as _, RenderOnce, + StyleRefinement, Styled, div, prelude::FluentBuilder as _, }; -use crate::{avatar::Avatar, ActiveTheme, Sizable, Size, StyledExt as _}; +use crate::{ActiveTheme, Sizable, Size, StyledExt as _, avatar::Avatar}; /// A grouped avatars to display in a compact layout. #[derive(IntoElement)] @@ -86,7 +86,7 @@ impl RenderOnce for AvatarGroup { Some( Avatar::new() .name("⋯") - .bg(cx.theme().secondary) + .bg(cx.theme().tokens.secondary) .text_color(cx.theme().muted_foreground) .with_size(self.size) .ml_1(), @@ -107,3 +107,25 @@ impl RenderOnce for AvatarGroup { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[gpui::test] + fn test_avatar_group_builder(_cx: &mut gpui::TestAppContext) { + let group = AvatarGroup::new() + .child(Avatar::new().name("Alice")) + .child(Avatar::new().name("Bob")) + .child(Avatar::new().name("Charlie")) + .child(Avatar::new().name("David")) + .large() + .limit(3) + .ellipsis(); + + assert_eq!(group.avatars.len(), 4); + assert_eq!(group.size, Size::Large); + assert_eq!(group.limit, 3); + assert!(group.ellipsis); + } +} diff --git a/vendor/gpui-component/src/avatar/mod.rs b/vendor/gpui-component/src/avatar/mod.rs index 2db67785..b1452f19 100644 --- a/vendor/gpui-component/src/avatar/mod.rs +++ b/vendor/gpui-component/src/avatar/mod.rs @@ -5,7 +5,7 @@ pub use avatar::*; pub use avatar_group::*; use crate::{Icon, Size, StyledExt as _}; -use gpui::{px, rems, Div, Img, IntoElement, Pixels, Styled}; +use gpui::{Div, Img, IntoElement, Pixels, Styled, px, rems}; /// Returns the size of the avatar based on the given [`Size`]. pub(super) fn avatar_size(size: Size) -> Pixels { @@ -13,17 +13,13 @@ pub(super) fn avatar_size(size: Size) -> Pixels { Size::Large => px(80.), Size::Medium => px(48.), Size::Small => px(24.), - Size::XSmall => px(20.), + Size::XSmall => px(16.), Size::Size(size) => size, } } /// Extension for add `avatar_size` method to `IntoElement` to apply avatar size to element. pub(super) trait AvatarSized: IntoElement + Styled { - fn avatar_size(self, size: Size) -> Self { - self.size(avatar_size(size)) - } - fn avatar_text_size(self, size: Size) -> Self { match size { Size::Large => self.text_3xl().font_semibold(), diff --git a/vendor/gpui-component/src/badge.rs b/vendor/gpui-component/src/badge.rs index 490cacec..bccdefec 100644 --- a/vendor/gpui-component/src/badge.rs +++ b/vendor/gpui-component/src/badge.rs @@ -1,9 +1,9 @@ use gpui::{ - div, prelude::FluentBuilder, px, relative, AnyElement, App, Hsla, IntoElement, ParentElement, - RenderOnce, StyleRefinement, Styled, Window, + AnyElement, App, Hsla, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, + div, prelude::FluentBuilder, px, relative, }; -use crate::{h_flex, white, ActiveTheme, Icon, Sizable, Size, StyledExt}; +use crate::{ActiveTheme, Icon, Sizable, Size, StyledExt, ThemeStyled as _, h_flex, white}; #[derive(Default, Clone)] enum BadgeVariant { @@ -121,7 +121,7 @@ impl RenderOnce for Badge { .absolute() .justify_center() .items_center() - .rounded_full() + .rounded_full_style(cx) .bg(self.color.unwrap_or(cx.theme().red)) .text_color(white()) .text_size(text_size) diff --git a/vendor/gpui-component/src/breadcrumb.rs b/vendor/gpui-component/src/breadcrumb.rs index 328c110f..e831ce2f 100644 --- a/vendor/gpui-component/src/breadcrumb.rs +++ b/vendor/gpui-component/src/breadcrumb.rs @@ -1,12 +1,12 @@ use std::rc::Rc; use gpui::{ - div, prelude::FluentBuilder as _, App, ClickEvent, ElementId, InteractiveElement as _, - IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, - StyleRefinement, Styled, Window, + App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, + Role, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, }; -use crate::{h_flex, ActiveTheme, Icon, IconName, StyledExt}; +use crate::{ActiveTheme, Icon, IconName, StyledExt, h_flex}; /// A breadcrumb navigation element. #[derive(IntoElement)] @@ -92,6 +92,11 @@ impl RenderOnce for BreadcrumbItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() .id(self.id) + .role(if self.on_click.is_some() && !self.disabled { + Role::Link + } else { + Role::ListItem + }) .child(self.label) .text_color(cx.theme().muted_foreground) .when(self.is_last, |this| this.text_color(cx.theme().foreground)) diff --git a/vendor/gpui-component/src/button/button.rs b/vendor/gpui-component/src/button/button.rs index 4c264143..ca0fb1ff 100644 --- a/vendor/gpui-component/src/button/button.rs +++ b/vendor/gpui-component/src/button/button.rs @@ -1,14 +1,19 @@ use std::rc::Rc; +use crate::ThemeStyled as _; use crate::{ - ActiveTheme, Colorize as _, Disableable, FocusableExt as _, Icon, IconName, Selectable, - Sizable, Size, StyleSized, StyledExt, h_flex, spinner::Spinner, tooltip::Tooltip, + ActiveTheme, Colorize as _, Disableable, Icon, RoleOverride, Selectable, Sizable, Size, + StyleSized, StyledExt, + button::ButtonIcon, + h_flex, + select::Caret, + tooltip::{ManagedTooltipExt as _, Tooltip}, }; use gpui::{ - Action, AnyElement, App, ClickEvent, Corners, Div, Edges, ElementId, Hsla, InteractiveElement, - Interactivity, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, - Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, px, relative, + AnyElement, App, Background, ClickEvent, Corners, Edges, ElementId, Hsla, InteractiveElement, + Interactivity, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Role, SharedString, + StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, relative, transparent_white, }; #[derive(Default, Clone, Copy)] @@ -27,11 +32,10 @@ impl From for ButtonRounded { } } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct ButtonCustomVariant { color: Hsla, foreground: Hsla, - border: Hsla, shadow: bool, hover: Hsla, active: Hsla, @@ -45,6 +49,11 @@ pub trait ButtonVariants: Sized { self.with_variant(ButtonVariant::Primary) } + /// With the secondary style for the Button. + fn secondary(self) -> Self { + self.with_variant(ButtonVariant::Secondary) + } + /// With the danger style for the Button. fn danger(self) -> Self { self.with_variant(ButtonVariant::Danger) @@ -91,7 +100,6 @@ impl ButtonCustomVariant { Self { color: cx.theme().transparent, foreground: cx.theme().foreground, - border: cx.theme().transparent, hover: cx.theme().transparent, active: cx.theme().transparent, shadow: false, @@ -110,12 +118,6 @@ impl ButtonCustomVariant { self } - /// Set border color, default is transparent. - pub fn border(mut self, color: Hsla) -> Self { - self.border = color; - self - } - /// Set hover background color, default is transparent. pub fn hover(mut self, color: Hsla) -> Self { self.hover = color; @@ -135,11 +137,12 @@ impl ButtonCustomVariant { } } -/// The veriant of the Button. -#[derive(Clone, Copy, PartialEq, Eq, Default)] +/// The variant of the Button. +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum ButtonVariant { - Primary, #[default] + Default, + Primary, Secondary, Danger, Info, @@ -171,19 +174,25 @@ impl ButtonVariant { fn no_padding(&self) -> bool { self.is_link() || self.is_text() } + + #[inline] + fn is_default(&self) -> bool { + matches!(self, Self::Default) + } } /// A Button element. #[derive(IntoElement)] pub struct Button { id: ElementId, - base: Stateful
, - style: StyleRefinement, - icon: Option, + base: gpui_base::Button, + icon: Option, label: Option, children: Vec, disabled: bool, pub(crate) selected: bool, + toggled: Option, + role: RoleOverride, variant: ButtonVariant, rounded: ButtonRounded, outline: bool, @@ -194,12 +203,14 @@ pub struct Button { compact: bool, tooltip: Option<( SharedString, - Option<(Rc>, Option)>, + Option<(Rc>, Option)>, )>, + tooltip_builder: Option gpui::AnyView>>, on_click: Option>, on_hover: Option>, loading: bool, loading_icon: Option, + focus_ring_enabled: bool, tab_index: isize, tab_stop: bool, @@ -217,26 +228,32 @@ impl Button { Self { id: id.clone(), - // ID must be set after div is created; - // `dropdown_menu` uses this id to create the popup menu. - base: div().flex_shrink_0().id(id), - style: StyleRefinement::default(), + base: gpui_base::Button::new(id), icon: None, label: None, + children: Vec::new(), disabled: false, selected: false, + toggled: None, + role: RoleOverride::default(), variant: ButtonVariant::default(), rounded: ButtonRounded::Medium, - border_corners: Corners::all(true), + border_corners: Corners { + top_left: true, + top_right: true, + bottom_right: true, + bottom_left: true, + }, border_edges: Edges::all(true), size: Size::Medium, tooltip: None, + tooltip_builder: None, on_click: None, + focus_ring_enabled: true, on_hover: None, loading: false, compact: false, outline: false, - children: Vec::new(), loading_icon: None, dropdown_caret: false, tab_index: 0, @@ -244,6 +261,23 @@ impl Button { } } + pub(super) fn variant(&self) -> ButtonVariant { + self.variant + } + + pub(super) fn button_size(&self) -> Size { + self.size + } + + pub(super) fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + /// Set the outline style of the Button. pub fn outline(mut self) -> Self { self.outline = true; @@ -263,7 +297,7 @@ impl Button { } /// Set the border edges of the Button. - pub fn border_edges(mut self, edges: impl Into>) -> Self { + pub(crate) fn border_edges(mut self, edges: impl Into>) -> Self { self.border_edges = edges.into(); self } @@ -274,8 +308,14 @@ impl Button { self } + /// Set the developer-assigned identifier exposed to accessibility clients. + pub fn accessibility_id(mut self, id: impl Into) -> Self { + self.base = self.base.accessibility_id(id); + self + } + /// Set the icon of the button, if the Button have no label, the button well in Icon Button mode. - pub fn icon(mut self, icon: impl Into) -> Self { + pub fn icon(mut self, icon: impl Into) -> Self { self.icon = Some(icon.into()); self } @@ -290,7 +330,7 @@ impl Button { pub fn tooltip_with_action( mut self, tooltip: impl Into, - action: &dyn Action, + action: &dyn gpui::Action, context: Option<&str>, ) -> Self { self.tooltip = Some(( @@ -360,14 +400,29 @@ impl Button { self } + /// Expose this button as a toggle button to assistive technology, with + /// `toggled` as its pressed state. + /// + /// Only affects accessibility metadata. Use [`Selectable::selected`] for + /// the selected styling, and call this in addition when the button really + /// is a toggle, otherwise the button stays an ordinary push button. + pub fn toggled(mut self, toggled: bool) -> Self { + self.toggled = Some(toggled); + self + } + + /// Whether the button responds to the pointer at all. + /// + /// A loading button is as inert as a disabled one, it just keeps looking + /// like itself instead of taking the disabled styling. #[inline] - fn clickable(&self) -> bool { - !(self.disabled || self.loading) && self.on_click.is_some() + fn interactive(&self) -> bool { + !(self.disabled || self.loading) } #[inline] fn hoverable(&self) -> bool { - !(self.disabled || self.loading) && self.on_hover.is_some() + self.interactive() && self.on_hover.is_some() } } @@ -378,6 +433,17 @@ impl Disableable for Button { } } +impl crate::FocusableExt for Button { + fn focus_ring(mut self, enabled: bool) -> Self { + self.focus_ring_enabled = enabled; + self + } + + fn is_focus_ring_enabled(&self) -> bool { + self.focus_ring_enabled + } +} + impl Selectable for Button { fn selected(mut self, selected: bool) -> Self { self.selected = selected; @@ -405,7 +471,7 @@ impl ButtonVariants for Button { impl Styled for Button { fn style(&mut self) -> &mut StyleRefinement { - &mut self.style + self.base.style() } } @@ -424,14 +490,21 @@ impl InteractiveElement for Button { impl RenderOnce for Button { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let style: ButtonVariant = self.variant; - let clickable = self.clickable(); - let is_disabled = self.disabled; + let interactive = self.interactive(); let hoverable = self.hoverable(); + let disabled = self.disabled; + let loading = self.loading; + let mut base = self.base; + let children = self.children; + let instance_style = base.style().clone(); let normal_style = style.normal(self.outline, cx); + let selected_style = style.selected(self.outline, cx); + let disabled_style = style.disabled(self.outline, cx); let icon_size = match self.size { Size::Size(v) => Size::Size(v * 0.75), _ => self.size, }; + let has_content = self.icon.is_some() || self.label.is_some() || !children.is_empty(); let focus_handle = window .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) @@ -447,26 +520,23 @@ impl RenderOnce for Button { ButtonRounded::None => Pixels::ZERO, }; - self.base - .when(!self.disabled, |this| { - this.track_focus( - &focus_handle - .tab_index(self.tab_index) - .tab_stop(self.tab_stop), - ) - }) + let root = base .cursor_default() .flex() .flex_shrink_0() .items_center() .justify_center() .cursor_default() - .when(self.variant.is_link(), |this| this.cursor_pointer()) - .when(cx.theme().shadow && normal_style.shadow, |this| { - this.shadow_xs() - }) + .when( + interactive && (self.variant.is_link() || self.variant.is_text()), + |this| this.cursor_pointer(), + ) + .when( + !disabled && cx.theme().shadow && normal_style.shadow, + |this| this.shadow_xs(), + ) .when(!style.no_padding(), |this| { - if self.label.is_none() && self.children.is_empty() { + if self.label.is_none() && children.is_empty() { // Icon Button match self.size { Size::Size(px) => this.size(px), @@ -478,9 +548,19 @@ impl RenderOnce for Button { // Normal Button match self.size { Size::Size(size) => this.px(size * 0.2), - Size::XSmall => this.h_5().px_1(), - Size::Small => this.h_6().px_3().when(self.compact, |this| this.px_1p5()), - _ => this.h_8().px_4().when(self.compact, |this| this.px_2()), + Size::XSmall => this.h_5().px_1().when(self.compact, |this| this.min_w_5()), + Size::Small => this + .h_6() + .px_2() + .when(self.compact, |this| this.min_w_6().px_1p5()), + Size::Medium => this + .h_8() + .px_2p5() + .when(self.compact, |this| this.min_w_8().px_2()), + Size::Large => this + .h_8() + .px_3() + .when(self.compact, |this| this.min_w_8().px_2()), } } }) @@ -496,108 +576,146 @@ impl RenderOnce for Button { .when(self.border_corners.bottom_right, |this| { this.rounded_br(rounding) }) - .when(self.border_edges.left, |this| this.border_l_1()) - .when(self.border_edges.right, |this| this.border_r_1()) - .when(self.border_edges.top, |this| this.border_t_1()) - .when(self.border_edges.bottom, |this| this.border_b_1()) - .text_color(normal_style.fg) - .when(self.selected, |this| { - let selected_style = style.selected(self.outline, cx); - this.bg(selected_style.bg) - .border_color(selected_style.border) - .text_color(selected_style.fg) + .when(self.variant.is_default() || self.outline, |this| { + this.when(self.border_edges.left, |this| this.border_l_1()) + .when(self.border_edges.right, |this| this.border_r_1()) + .when(self.border_edges.top, |this| this.border_t_1()) + .when(self.border_edges.bottom, |this| this.border_b_1()) }) .when(!self.disabled && !self.selected, |this| { this.border_color(normal_style.border) .bg(normal_style.bg) + .text_color(normal_style.fg) .when(normal_style.underline, |this| this.text_decoration_1()) - .hover(|this| { - let hover_style = style.hovered(self.outline, cx); - this.bg(hover_style.bg) - .border_color(hover_style.border) - .text_color(hover_style.fg) - }) - .active(|this| { - let active_style = style.active(self.outline, cx); - this.bg(active_style.bg) - .border_color(active_style.border) - .text_color(active_style.fg) + // A loading button keeps its normal colors, but must not react + // to the pointer, it is not waiting for another click. + .when(interactive, |this| { + this.hover(|this| { + let hover_style = style.hovered(self.outline, cx); + this.bg(hover_style.bg) + .border_color(hover_style.border) + .text_color(hover_style.fg) + }) + .active(|this| { + let active_style = style.active(self.outline, cx); + this.bg(active_style.bg) + .border_color(active_style.border) + .text_color(active_style.fg) + }) }) }) - .when(self.disabled, |this| { - let disabled_style = style.disabled(self.outline, cx); - this.bg(disabled_style.bg) - .text_color(disabled_style.fg) - .border_color(disabled_style.border) - .shadow_none() + .refine_style(&instance_style); + + let accessibility_label = self.label.clone(); + let content = h_flex() + .id("label") + .size_full() + .items_center() + .justify_center() + .button_text_size(self.size) + .map(|this| match self.size { + Size::XSmall => this.gap_1(), + Size::Small => this.gap_1(), + _ => this.gap_2(), + }) + .when_some(self.icon, |this, icon| { + this.child( + icon.loading_icon(self.loading_icon) + .loading(self.loading) + .with_size(icon_size), + ) + }) + .when_some(self.label, |this, label| { + this.child(div().flex_none().line_height(relative(1.)).child(label)) + }) + .children(children) + .when(self.dropdown_caret, |this| { + this.when(has_content, |this| this.justify_between()) + .child(Caret::new(self.size).text_color(normal_style.fg.opacity(0.75))) + }); + root.role(self.role.resolve(|| { + if self.variant.is_link() { + Role::Link + } else { + Role::Button + } + })) + .selected(self.selected) + .disabled(disabled) + // Base layers semantic states over the builder chain, so the caller's + // own style is replayed inside each state to keep it the closest layer. + .styles(|styles| { + styles + .selected(|style| { + style + .bg(selected_style.bg) + .border_color(selected_style.border) + .text_color(selected_style.fg) + .refine_style(&instance_style) + }) + .disabled(|style| { + style + .bg(disabled_style.bg) + .text_color(disabled_style.fg) + .border_color(disabled_style.border) + .shadow_none() + .refine_style(&instance_style) + }) + }) + .when_some(accessibility_label, |this, label| { + this.accessibility_label(label) + }) + .when_some(self.toggled, |this, toggled| { + this.aria_toggled(if toggled { + gpui::accesskit::Toggled::True + } else { + gpui::accesskit::Toggled::False }) - .refine_style(&self.style) - .on_mouse_down(MouseButton::Left, move |_, window, cx| { - // Stop handle any click event when disabled. - // To avoid handle dropdown menu open when button is disabled. - if is_disabled { + }) + .track_focus(&focus_handle) + .tab_index(self.tab_index) + .tab_stop(self.tab_stop) + .child(content) + // Fade the whole button while loading, so every variant is dimmed by + // the same amount. Fading `bg`, `border` and `fg` one by one instead + // only shows up on variants that have a background to begin with: + // `Ghost`, `Link` and `Text` are transparent, so an alpha on their + // background changes nothing. + .when(loading && !disabled, |this| this.opacity(0.8)) + .when(!disabled, |this| { + this.on_mouse_down(MouseButton::Left, move |_, window, cx| { + if loading { cx.stop_propagation(); return; } // Avoid focus on mouse down. window.prevent_default(); - }) - .when_some(self.on_click, |this, on_click| { - this.on_click(move |event, window, cx| { - // Stop handle any click event when disabled. - // To avoid handle dropdown menu open when button is disabled. - if !clickable { - cx.stop_propagation(); - return; - } - (on_click)(event, window, cx); - }) - }) - .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| { - this.on_hover(move |hovered, window, cx| { - (on_hover)(hovered, window, cx); - }) + // Pressing a button must not start the window-level text selection. + crate::global_state::GlobalState::suppress_text_selection(cx); }) - .child({ - h_flex() - .id("label") - .items_center() - .justify_center() - .button_text_size(self.size) - .map(|this| match self.size { - Size::XSmall => this.gap_1(), - Size::Small => this.gap_1(), - _ => this.gap_2(), - }) - .when(!self.loading, |this| { - this.when_some(self.icon, |this, icon| { - this.child(icon.with_size(icon_size)) - }) - }) - .when(self.loading, |this| { - this.child( - Spinner::new() - .with_size(self.size) - .when_some(self.loading_icon, |this, icon| this.icon(icon)), - ) - }) - .when_some(self.label, |this, label| { - this.child(div().flex_none().line_height(relative(1.)).child(label)) - }) - .children(self.children) - .when(self.dropdown_caret, |this| { - this.child(Icon::new(IconName::ChevronDown).with_size(icon_size)) - }) + }) + .when_some(self.on_click, |this, on_click| { + this.on_click(move |event, window, cx| { + if loading { + cx.stop_propagation(); + return; + } + + on_click(event, window, cx); }) - .when(self.loading && !self.disabled, |this| { - this.bg(normal_style.bg.opacity(0.8)) - .border_color(normal_style.border.opacity(0.8)) - .text_color(normal_style.fg.opacity(0.8)) + }) + .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| { + this.on_hover(move |hovered, window, cx| { + on_hover(hovered, window, cx); }) - .when_some(self.tooltip, |this, (tooltip, action)| { - this.tooltip(move |window, cx| { + }) + .map(|this| { + if let Some(builder) = self.tooltip_builder { + this.managed_tooltip(move |window, cx| builder(window, cx)) + } else if let Some((tooltip, action)) = self.tooltip { + this.managed_tooltip(move |window, cx| { Tooltip::new(tooltip.clone()) .when_some(action.clone(), |this, (action, context)| { this.action( @@ -607,158 +725,235 @@ impl RenderOnce for Button { }) .build(window, cx) }) - }) - .focus_ring(is_focused, px(0.), window, cx) + } else { + this + } + }) + .when(is_focused && self.focus_ring_enabled, |this| { + this.focus_ring_style(window, cx) + }) } } struct ButtonVariantStyle { - bg: Hsla, + bg: Background, border: Hsla, fg: Hsla, underline: bool, shadow: bool, } +#[derive(Clone, Copy)] +enum ButtonStyleState { + Normal, + Hovered, + Active, +} + impl ButtonVariant { - fn bg_color(&self, outline: bool, cx: &mut App) -> Hsla { + fn outline_background(&self, state: ButtonStyleState, cx: &mut App) -> Background { + match (self, state) { + (Self::Default, ButtonStyleState::Normal) => cx.theme().input_background().into(), + (Self::Default, ButtonStyleState::Hovered) => cx + .theme() + .input + .mix_oklab(cx.theme().transparent, 0.5) + .into(), + (Self::Default, ButtonStyleState::Active) => cx + .theme() + .input + .mix_oklab(cx.theme().transparent, 0.7) + .into(), + (Self::Primary, ButtonStyleState::Normal) => { + cx.theme().tokens.primary.background.opacity(0.1) + } + (Self::Primary, ButtonStyleState::Hovered) => { + cx.theme().tokens.primary_hover.background.opacity(0.2) + } + (Self::Primary, ButtonStyleState::Active) => { + cx.theme().tokens.primary_active.background.opacity(0.4) + } + (Self::Secondary, ButtonStyleState::Normal) => { + cx.theme().tokens.secondary.background.opacity(0.1) + } + (Self::Secondary, ButtonStyleState::Hovered) => { + cx.theme().tokens.secondary_hover.background.opacity(0.2) + } + (Self::Secondary, ButtonStyleState::Active) => { + cx.theme().tokens.secondary_active.background.opacity(0.4) + } + (Self::Danger, ButtonStyleState::Normal) => { + cx.theme().tokens.danger.background.opacity(0.1) + } + (Self::Danger, ButtonStyleState::Hovered) => { + cx.theme().tokens.danger_hover.background.opacity(0.2) + } + (Self::Danger, ButtonStyleState::Active) => { + cx.theme().tokens.danger_active.background.opacity(0.4) + } + (Self::Warning, ButtonStyleState::Normal) => { + cx.theme().tokens.warning.background.opacity(0.1) + } + (Self::Warning, ButtonStyleState::Hovered) => { + cx.theme().tokens.warning_hover.background.opacity(0.2) + } + (Self::Warning, ButtonStyleState::Active) => { + cx.theme().tokens.warning_active.background.opacity(0.4) + } + (Self::Success, ButtonStyleState::Normal) => { + cx.theme().tokens.success.background.opacity(0.1) + } + (Self::Success, ButtonStyleState::Hovered) => { + cx.theme().tokens.success_hover.background.opacity(0.2) + } + (Self::Success, ButtonStyleState::Active) => { + cx.theme().tokens.success_active.background.opacity(0.4) + } + (Self::Info, ButtonStyleState::Normal) => { + cx.theme().tokens.info.background.opacity(0.1) + } + (Self::Info, ButtonStyleState::Hovered) => { + cx.theme().tokens.info_hover.background.opacity(0.2) + } + (Self::Info, ButtonStyleState::Active) => { + cx.theme().tokens.info_active.background.opacity(0.4) + } + (Self::Ghost | Self::Link | Self::Text, _) => cx.theme().transparent.into(), + (Self::Custom(colors), _) => colors.color.mix_oklab(cx.theme().transparent, 0.2).into(), + } + } + + fn bg_color(&self, outline: bool, cx: &mut App) -> Background { if outline { - return cx.theme().background; + return self.outline_background(ButtonStyleState::Normal, cx); } match self { - ButtonVariant::Primary => cx.theme().primary, - ButtonVariant::Secondary => cx.theme().secondary, - ButtonVariant::Danger => cx.theme().danger, - ButtonVariant::Warning => cx.theme().warning, - ButtonVariant::Success => cx.theme().success, - ButtonVariant::Info => cx.theme().info, - ButtonVariant::Ghost | ButtonVariant::Link | ButtonVariant::Text => { - cx.theme().transparent - } - ButtonVariant::Custom(colors) => colors.color, + Self::Default => cx.theme().tokens.button.into(), + Self::Primary => cx.theme().tokens.button_primary.into(), + Self::Secondary => cx.theme().tokens.button_secondary.into(), + Self::Danger => cx.theme().tokens.button_danger.into(), + Self::Warning => cx.theme().tokens.button_warning.into(), + Self::Success => cx.theme().tokens.button_success.into(), + Self::Info => cx.theme().tokens.button_info.into(), + Self::Ghost | Self::Link | Self::Text => cx.theme().transparent.into(), + Self::Custom(colors) => colors.color.mix_oklab(cx.theme().transparent, 0.2).into(), } } fn text_color(&self, outline: bool, cx: &mut App) -> Hsla { match self { - ButtonVariant::Primary => { + Self::Default => cx.theme().button_foreground, + Self::Primary => { if outline { cx.theme().primary } else { - cx.theme().primary_foreground + cx.theme().button_primary_foreground } } - ButtonVariant::Secondary | ButtonVariant::Ghost => cx.theme().secondary_foreground, - ButtonVariant::Danger => { + Self::Secondary => { if outline { - cx.theme().danger + cx.theme().secondary_foreground } else { - cx.theme().danger_foreground + cx.theme().button_secondary_foreground } } - ButtonVariant::Warning => { + Self::Ghost => cx.theme().secondary_foreground, + Self::Danger => { if outline { - cx.theme().warning + cx.theme().danger } else { - cx.theme().warning_foreground + cx.theme().button_danger_foreground } } - ButtonVariant::Success => { + Self::Warning => { if outline { - cx.theme().success + cx.theme().warning } else { - cx.theme().success_foreground + cx.theme().button_warning_foreground } } - ButtonVariant::Info => { + Self::Success => { if outline { - cx.theme().info + cx.theme().success } else { - cx.theme().info_foreground + cx.theme().button_success_foreground } } - ButtonVariant::Link => cx.theme().link, - ButtonVariant::Text => cx.theme().foreground, - ButtonVariant::Custom(colors) => { + Self::Info => { if outline { - colors.color + cx.theme().info } else { - colors.foreground + cx.theme().button_info_foreground } } + Self::Link => cx.theme().link, + Self::Text => cx.theme().foreground.opacity(0.9), + Self::Custom(colors) => colors.foreground, } } - fn border_color(&self, bg: Hsla, outline: bool, cx: &mut App) -> Hsla { + fn border_color(&self, outline: bool, cx: &mut App) -> Hsla { match self { - ButtonVariant::Secondary => { - if outline { - cx.theme().border - } else { - bg - } - } - ButtonVariant::Primary => { + Self::Default => cx.theme().input, + Self::Secondary => cx.theme().border, + Self::Primary => cx.theme().primary, + Self::Danger => { if outline { - cx.theme().primary + cx.theme().danger.mix_oklab(transparent_white(), 0.4) } else { - bg + cx.theme().button_danger } } - ButtonVariant::Danger => { + Self::Info => { if outline { - cx.theme().danger + cx.theme().info.mix_oklab(transparent_white(), 0.4) } else { - bg + cx.theme().button_info } } - ButtonVariant::Info => { + Self::Warning => { if outline { - cx.theme().info + cx.theme().warning.mix_oklab(transparent_white(), 0.4) } else { - bg + cx.theme().button_warning } } - ButtonVariant::Warning => { + Self::Success => { if outline { - cx.theme().warning + cx.theme().success.mix_oklab(transparent_white(), 0.4) } else { - bg + cx.theme().button_success } } - ButtonVariant::Success => { + Self::Ghost | Self::Link | Self::Text => cx.theme().transparent, + Self::Custom(colors) => { if outline { - cx.theme().success + colors.color.mix_oklab(transparent_white(), 0.4) } else { - bg + colors.color } } - ButtonVariant::Ghost | ButtonVariant::Link | ButtonVariant::Text => { - cx.theme().transparent - } - ButtonVariant::Custom(colors) => colors.border, } } fn underline(&self, _: &App) -> bool { match self { - ButtonVariant::Link => true, + Self::Link => true, _ => false, } } - fn shadow(&self, outline: bool, _: &App) -> bool { + fn shadow(&self, _outline: bool, _: &App) -> bool { match self { - ButtonVariant::Primary | ButtonVariant::Secondary | ButtonVariant::Danger => outline, - ButtonVariant::Custom(c) => c.shadow, + Self::Custom(c) => c.shadow, _ => false, } } fn normal(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle { let bg = self.bg_color(outline, cx); - let border = self.border_color(bg, outline, cx); + let border = self.border_color(outline, cx); let fg = self.text_color(outline, cx); let underline = self.underline(cx); let shadow = self.shadow(outline, cx); @@ -773,64 +968,71 @@ impl ButtonVariant { } fn hovered(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle { - let bg = match self { - ButtonVariant::Primary => { + let bg: Background = match self { + Self::Default => { if outline { - cx.theme().secondary_hover + self.outline_background(ButtonStyleState::Hovered, cx) } else { - cx.theme().primary_hover + cx.theme().tokens.button_hover.into() } } - ButtonVariant::Secondary => cx.theme().secondary_hover, - ButtonVariant::Danger => { + Self::Primary => { if outline { - cx.theme().secondary_hover + self.outline_background(ButtonStyleState::Hovered, cx) } else { - cx.theme().danger_hover + cx.theme().tokens.button_primary_hover.into() } } - ButtonVariant::Warning => { + Self::Secondary => { if outline { - cx.theme().secondary_hover + self.outline_background(ButtonStyleState::Hovered, cx) } else { - cx.theme().warning_hover + cx.theme().tokens.button_secondary_hover.into() } } - ButtonVariant::Success => { + Self::Danger => { if outline { - cx.theme().secondary_hover + self.outline_background(ButtonStyleState::Hovered, cx) } else { - cx.theme().success_hover + cx.theme().tokens.button_danger_hover.into() } } - ButtonVariant::Info => { + Self::Warning => { if outline { - cx.theme().secondary_hover + self.outline_background(ButtonStyleState::Hovered, cx) } else { - cx.theme().info_hover + cx.theme().tokens.button_warning_hover.into() } } - ButtonVariant::Ghost => { - if cx.theme().mode.is_dark() { - cx.theme().secondary.lighten(0.1).opacity(0.8) + Self::Success => { + if outline { + self.outline_background(ButtonStyleState::Hovered, cx) } else { - cx.theme().secondary.darken(0.1).opacity(0.8) + cx.theme().tokens.button_success_hover.into() } } - ButtonVariant::Link => cx.theme().transparent, - ButtonVariant::Text => cx.theme().transparent, - ButtonVariant::Custom(colors) => { + Self::Info => { if outline { - cx.theme().secondary_hover + self.outline_background(ButtonStyleState::Hovered, cx) } else { - colors.hover + cx.theme().tokens.button_info_hover.into() } } + Self::Custom(colors) => colors.hover.into(), + Self::Ghost => if cx.theme().mode.is_dark() { + cx.theme().secondary.lighten(0.1).opacity(0.8) + } else { + cx.theme().secondary.darken(0.1).opacity(0.8) + } + .into(), + Self::Link => cx.theme().transparent.into(), + Self::Text => cx.theme().transparent.into(), }; - let border = self.border_color(bg, outline, cx); + let border = self.border_color(outline, cx); let fg = match self { - ButtonVariant::Link => cx.theme().link_hover, + Self::Link => cx.theme().link_hover, + Self::Text => cx.theme().foreground, _ => self.text_color(outline, cx), }; @@ -848,63 +1050,69 @@ impl ButtonVariant { fn active(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle { let bg = match self { - ButtonVariant::Primary => { + Self::Default => { if outline { - cx.theme().primary_active.opacity(0.1) + self.outline_background(ButtonStyleState::Active, cx) } else { - cx.theme().primary_active + cx.theme().tokens.button_active.into() } } - ButtonVariant::Secondary => cx.theme().secondary_active, - ButtonVariant::Ghost => { - if cx.theme().mode.is_dark() { - cx.theme().secondary.lighten(0.2).opacity(0.8) + Self::Primary => { + if outline { + self.outline_background(ButtonStyleState::Active, cx) } else { - cx.theme().secondary.darken(0.2).opacity(0.8) + cx.theme().tokens.button_primary_active.into() } } - ButtonVariant::Danger => { + Self::Secondary => { if outline { - cx.theme().danger_active.opacity(0.1) + self.outline_background(ButtonStyleState::Active, cx) } else { - cx.theme().danger_active + cx.theme().tokens.button_secondary_active.into() } } - ButtonVariant::Warning => { + Self::Ghost => if cx.theme().mode.is_dark() { + cx.theme().secondary.lighten(0.2).opacity(0.8) + } else { + cx.theme().secondary.darken(0.2).opacity(0.8) + } + .into(), + Self::Danger => { if outline { - cx.theme().warning_active.opacity(0.1) + self.outline_background(ButtonStyleState::Active, cx) } else { - cx.theme().warning_active + cx.theme().tokens.button_danger_active.into() } } - ButtonVariant::Success => { + Self::Warning => { if outline { - cx.theme().success_active.opacity(0.1) + self.outline_background(ButtonStyleState::Active, cx) } else { - cx.theme().success_active + cx.theme().tokens.button_warning_active.into() } } - ButtonVariant::Info => { + Self::Success => { if outline { - cx.theme().info_active.opacity(0.1) + self.outline_background(ButtonStyleState::Active, cx) } else { - cx.theme().info_active + cx.theme().tokens.button_success_active.into() } } - ButtonVariant::Link => cx.theme().transparent, - ButtonVariant::Text => cx.theme().transparent, - ButtonVariant::Custom(colors) => { + Self::Info => { if outline { - colors.active.opacity(0.1) + self.outline_background(ButtonStyleState::Active, cx) } else { - colors.active + cx.theme().tokens.button_info_active.into() } } + Self::Custom(colors) => colors.active.into(), + Self::Link => cx.theme().transparent.into(), + Self::Text => cx.theme().transparent.into(), }; - let border = self.border_color(bg, outline, cx); + let border = self.border_color(outline, cx); let fg = match self { - ButtonVariant::Link => cx.theme().link_active, - ButtonVariant::Text => cx.theme().foreground.opacity(0.7), + Self::Link => cx.theme().link_active, + Self::Text => cx.theme().foreground.opacity(0.7), _ => self.text_color(outline, cx), }; let underline = self.underline(cx); @@ -920,22 +1128,33 @@ impl ButtonVariant { } fn selected(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle { + if outline { + let active_style = self.active(outline, cx); + + return ButtonVariantStyle { + fg: self.text_color(outline, cx), + ..active_style + }; + } + let bg = match self { - ButtonVariant::Primary => cx.theme().primary_active, - ButtonVariant::Secondary | ButtonVariant::Ghost => cx.theme().secondary_active, - ButtonVariant::Danger => cx.theme().danger_active, - ButtonVariant::Warning => cx.theme().warning_active, - ButtonVariant::Success => cx.theme().success_active, - ButtonVariant::Info => cx.theme().info_active, - ButtonVariant::Link => cx.theme().transparent, - ButtonVariant::Text => cx.theme().transparent, - ButtonVariant::Custom(colors) => colors.active, + Self::Default => cx.theme().tokens.button_active.into(), + Self::Primary => cx.theme().tokens.button_primary_active.into(), + Self::Secondary => cx.theme().tokens.button_secondary_active.into(), + Self::Ghost => cx.theme().tokens.secondary_active.into(), + Self::Danger => cx.theme().tokens.button_danger_active.into(), + Self::Warning => cx.theme().tokens.button_warning_active.into(), + Self::Success => cx.theme().tokens.button_success_active.into(), + Self::Info => cx.theme().tokens.button_info_active.into(), + Self::Link => cx.theme().transparent.into(), + Self::Text => cx.theme().transparent.into(), + Self::Custom(colors) => colors.active.into(), }; - let border = self.border_color(bg, outline, cx); + let border = self.border_color(outline, cx); let fg = match self { - ButtonVariant::Link => cx.theme().link_active, - ButtonVariant::Text => cx.theme().foreground.opacity(0.7), + Self::Link => cx.theme().link_active, + Self::Text => cx.theme().foreground.opacity(0.7), _ => self.text_color(false, cx), }; let underline = self.underline(cx); @@ -952,22 +1171,39 @@ impl ButtonVariant { fn disabled(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle { let bg = match self { - ButtonVariant::Link | ButtonVariant::Ghost | ButtonVariant::Text => { - cx.theme().transparent - } - ButtonVariant::Primary => cx.theme().primary.opacity(0.15), - ButtonVariant::Danger => cx.theme().danger.opacity(0.15), - ButtonVariant::Warning => cx.theme().warning.opacity(0.15), - ButtonVariant::Success => cx.theme().success.opacity(0.15), - ButtonVariant::Info => cx.theme().info.opacity(0.15), - ButtonVariant::Secondary => cx.theme().secondary.opacity(1.5), - ButtonVariant::Custom(style) => style.color.opacity(0.15), + Self::Default | Self::Link | Self::Ghost | Self::Text => cx.theme().transparent.into(), + Self::Primary => cx.theme().tokens.button_primary.background.opacity(0.15), + Self::Danger => cx.theme().tokens.button_danger.background.opacity(0.15), + Self::Warning => cx.theme().tokens.button_warning.background.opacity(0.15), + Self::Success => cx.theme().tokens.button_success.background.opacity(0.15), + Self::Info => cx.theme().tokens.button_info.background.opacity(0.15), + Self::Secondary => cx.theme().tokens.button_secondary.background.opacity(1.5), + Self::Custom(style) => style.color.opacity(0.15).into(), }; let fg = cx.theme().muted_foreground.opacity(0.5); let (bg, border) = if outline { - (cx.theme().transparent, cx.theme().border.opacity(0.5)) + ( + self.outline_background(ButtonStyleState::Normal, cx) + .opacity(0.5), + self.border_color(true, cx).opacity(0.5), + ) + } else if let Self::Default = self { + ( + cx.theme().input_background().opacity(0.5).into(), + cx.theme().input.opacity(0.5), + ) } else { - (bg, bg) + let border = match self { + Self::Primary => cx.theme().button_primary.opacity(0.15), + Self::Secondary => cx.theme().button_secondary.opacity(1.5), + Self::Danger => cx.theme().button_danger.opacity(0.15), + Self::Warning => cx.theme().button_warning.opacity(0.15), + Self::Success => cx.theme().button_success.opacity(0.15), + Self::Info => cx.theme().button_info.opacity(0.15), + Self::Custom(style) => style.color.opacity(0.15), + Self::Default | Self::Link | Self::Ghost | Self::Text => cx.theme().transparent, + }; + (bg, border) }; let underline = self.underline(cx); @@ -982,3 +1218,528 @@ impl ButtonVariant { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::IconName; + use gpui::{linear_color_stop, linear_gradient, px}; + + #[gpui::test] + fn disabled_legacy_button_keeps_existing_pointer_blocking(cx: &mut gpui::TestAppContext) { + use std::{cell::Cell, rc::Rc}; + + use gpui::{Context, Modifiers, Render, point}; + + struct Harness(Rc>, Rc>); + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let button_clicks = self.0.clone(); + let parent_clicks = self.1.clone(); + div() + .id("disabled-button-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Button::new("disabled-legacy") + .disabled(true) + .size_full() + .on_click(move |_, _, _| button_clicks.set(button_clicks.get() + 1)), + ) + } + } + + cx.update(crate::init); + let button_clicks = Rc::new(Cell::new(0)); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let button_clicks = button_clicks.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| Harness(button_clicks, parent_clicks) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.update(|window, cx| window.focus_next(cx)); + cx.simulate_keystrokes("enter space"); + + assert_eq!(button_clicks.get(), 0); + assert_eq!(parent_clicks.get(), 0); + cx.update(|window, cx| assert!(window.focused(cx).is_none())); + } + + #[gpui::test] + fn enabled_button_delegates_pointer_enter_and_space_once(cx: &mut gpui::TestAppContext) { + use std::{cell::Cell, rc::Rc}; + + use gpui::{ + ClickEvent, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, point, + }; + + struct Harness(Rc>, Rc>); + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let clicks = self.0.clone(); + let keyboard_clicks = self.1.clone(); + div().tab_group().size(px(100.)).child( + Button::new("enabled-legacy") + .size_full() + .on_click(move |event, _, _| { + clicks.set(clicks.get() + 1); + if matches!(event, ClickEvent::Keyboard(_)) { + keyboard_clicks.set(keyboard_clicks.get() + 1); + } + }), + ) + } + } + + cx.update(crate::init); + let clicks = Rc::new(Cell::new(0)); + let keyboard_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let clicks = clicks.clone(); + let keyboard_clicks = keyboard_clicks.clone(); + move |_, _| Harness(clicks, keyboard_clicks) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.update(|window, cx| window.focus_next(cx)); + cx.update(|window, cx| { + assert!(window.focused(cx).is_some()); + window.draw(cx).clear(cx); + }); + for key in ["enter", "space"] { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + assert_eq!(clicks.get(), 3); + assert_eq!(keyboard_clicks.get(), 2); + } + + #[gpui::test] + fn loading_button_keeps_existing_pointer_blocking(cx: &mut gpui::TestAppContext) { + use std::{cell::Cell, rc::Rc}; + + use gpui::{Context, Modifiers, Render, point}; + + struct Harness(Rc>, Rc>); + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let button_clicks = self.0.clone(); + let parent_clicks = self.1.clone(); + div() + .id("loading-button-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Button::new("loading-legacy") + .loading(true) + .size_full() + .on_click(move |_, _, _| button_clicks.set(button_clicks.get() + 1)), + ) + } + } + + cx.update(crate::init); + let button_clicks = Rc::new(Cell::new(0)); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let button_clicks = button_clicks.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| Harness(button_clicks, parent_clicks) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.update(|window, cx| window.focus_next(cx)); + cx.simulate_keystrokes("enter space"); + + assert_eq!(button_clicks.get(), 0); + assert_eq!(parent_clicks.get(), 0); + cx.update(|window, cx| assert!(window.focused(cx).is_some())); + } + + #[gpui::test] + fn loading_button_without_callback_still_blocks_parent_activation( + cx: &mut gpui::TestAppContext, + ) { + use std::{cell::Cell, rc::Rc}; + + use gpui::{Context, Modifiers, Render, point}; + + struct Harness(Rc>); + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let parent_clicks = self.0.clone(); + div() + .id("loading-without-callback-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Button::new("loading-without-callback") + .loading(true) + .size_full(), + ) + } + } + + cx.update(crate::init); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let parent_clicks = parent_clicks.clone(); + move |_, _| Harness(parent_clicks) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + cx.update(|window, cx| window.focus_next(cx)); + cx.simulate_keystrokes("enter space"); + + assert_eq!(parent_clicks.get(), 0); + cx.update(|window, cx| assert!(window.focused(cx).is_some())); + } + + #[gpui::test] + fn test_button_builder(_cx: &mut gpui::TestAppContext) { + let button = Button::new("complex-button") + .label("Save Changes") + .primary() + .outline() + .large() + .tooltip("Click to save") + .compact() + .loading(false) + .disabled(false) + .selected(false) + .tab_index(1) + .tab_stop(true) + .dropdown_caret(false) + .rounded(ButtonRounded::Medium) + .on_click(|_, _, _| {}); + + assert_eq!(button.label, Some("Save Changes".into())); + assert_eq!(button.variant, ButtonVariant::Primary); + assert!(button.outline); + assert_eq!(button.size, Size::Large); + assert!(button.tooltip.is_some()); + assert!(button.compact); + assert!(!button.loading); + assert!(!button.disabled); + assert!(!button.selected); + assert_eq!(button.toggled, None); + assert_eq!(button.tab_index, 1); + assert!(button.tab_stop); + assert!(!button.dropdown_caret); + assert!(matches!(button.rounded, ButtonRounded::Medium)); + } + + #[test] + fn selected_trigger_presentation_does_not_imply_toggled_accessibility() { + let selected = Button::new("menu-trigger").selected(true); + assert!(selected.selected); + assert_eq!(selected.toggled, None); + + let selected_toggle = Button::new("explicit-toggle").selected(true).toggled(true); + assert!(selected_toggle.selected); + assert_eq!(selected_toggle.toggled, Some(true)); + } + + /// A loading button must be as inert as a disabled one. `interactive` is what + /// gates the hover and active styling, the `cursor_pointer` of link buttons and + /// the `mouse_down` handler, none of which depend on a listener being set. + #[gpui::test] + fn test_button_loading_is_not_interactive(_cx: &mut gpui::TestAppContext) { + assert!(Button::new("test").interactive()); + assert!(!Button::new("test").loading(true).interactive()); + assert!(!Button::new("test").disabled(true).interactive()); + assert!( + !Button::new("test") + .loading(true) + .disabled(true) + .interactive() + ); + + // Loading gates hovering even when an `on_hover` listener is set. + let loading = Button::new("test").loading(true).on_hover(|_, _, _| {}); + assert!(!loading.hoverable()); + } + + /// `selected` is styling only; the toggle state must be opted into, so that + /// ordinary buttons are not announced as toggle buttons. + #[gpui::test] + fn test_button_variant_methods(_cx: &mut gpui::TestAppContext) { + // Test variant check methods + assert!(ButtonVariant::Link.is_link()); + assert!(ButtonVariant::Text.is_text()); + assert!(ButtonVariant::Ghost.is_ghost()); + + // Test no_padding logic + assert!(ButtonVariant::Link.no_padding()); + assert!(ButtonVariant::Text.no_padding()); + assert!(!ButtonVariant::Ghost.no_padding()); + } + + #[gpui::test] + fn link_button_prepaints_its_child(cx: &mut gpui::TestAppContext) { + use gpui::{Context, Render}; + + struct LinkButtonHarness; + + impl Render for LinkButtonHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Button::new("link-button").link().child( + div() + .debug_selector(|| "link-button-child".into()) + .child("Visible link button"), + ) + } + } + + cx.update(crate::init); + let (_, cx) = cx.add_window_view(|_, _| LinkButtonHarness); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let bounds = cx + .debug_bounds("link-button-child") + .expect("link button child must participate in layout and prepaint"); + assert!(bounds.size.width > px(0.)); + assert!(bounds.size.height > px(0.)); + } + + #[gpui::test] + fn base_slot_prepaints_complete_button_content(cx: &mut gpui::TestAppContext) { + use gpui::{Context, Render}; + + struct CompleteButtonHarness; + + impl Render for CompleteButtonHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .child( + Button::new("complete-button") + .icon(IconName::Plus) + .label("Create") + .child( + div() + .debug_selector(|| "button-custom-content".into()) + .child("Details"), + ) + .dropdown_caret(true), + ) + .child( + Button::new("loading-button") + .icon(IconName::Plus) + .loading_icon(IconName::Loader) + .loading(true) + .label("Creating") + .dropdown_caret(true), + ) + } + } + + cx.update(crate::init); + let (_, cx) = cx.add_window_view(|_, _| CompleteButtonHarness); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let bounds = cx + .debug_bounds("button-custom-content") + .expect("application content must prepaint through the Base child seam"); + assert!(bounds.size.width > px(0.)); + assert!(bounds.size.height > px(0.)); + } + + #[gpui::test] + fn test_outline_selected_uses_outline_active_style(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + window.update(|_, cx| { + let variant = ButtonVariant::Danger; + let active_style = variant.active(true, cx); + let selected_style = variant.selected(true, cx); + + assert_eq!(selected_style.bg, active_style.bg); + assert_eq!(selected_style.border, active_style.border); + assert_eq!(selected_style.fg, cx.theme().danger); + assert_ne!(selected_style.bg, cx.theme().tokens.danger_active.into()); + }); + } + + #[gpui::test] + fn test_primary_button_uses_gradient_background_tokens(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + window.update(|_, cx| { + let config = serde_json::from_value::(serde_json::json!({ + "name": "Gradient", + "mode": "light", + "colors": { + "button.primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)", + "button.primary.hover.background": "linear-gradient(145deg, #4338CA, #0891B2)", + "button.primary.active.background": "linear-gradient(155deg, #3730A3, #0E7490)" + } + })) + .unwrap(); + crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config)); + + assert_eq!( + ButtonVariant::Primary.normal(false, cx).bg, + cx.theme().tokens.button_primary.into() + ); + assert_eq!( + ButtonVariant::Primary.hovered(false, cx).bg, + cx.theme().tokens.button_primary_hover.into() + ); + assert_eq!( + ButtonVariant::Primary.active(false, cx).bg, + cx.theme().tokens.button_primary_active.into() + ); + }); + } + + #[gpui::test] + fn test_outline_primary_keeps_original_depth(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + window.update(|_, cx| { + let config = serde_json::from_value::(serde_json::json!({ + "name": "Outline Depth", + "mode": "light", + "colors": { + "primary.background": "linear-gradient(180deg, #111827, #020617)", + "primary.hover.background": "linear-gradient(180deg, #1F2937, #111827)", + "primary.active.background": "linear-gradient(180deg, #020617, #000000)" + } + })) + .unwrap(); + crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config)); + + assert_eq!( + ButtonVariant::Primary.normal(true, cx).bg, + cx.theme().tokens.primary.background.opacity(0.1) + ); + assert_eq!( + ButtonVariant::Primary.hovered(true, cx).bg, + cx.theme().tokens.primary_hover.background.opacity(0.2) + ); + assert_eq!( + ButtonVariant::Primary.active(true, cx).bg, + cx.theme().tokens.primary_active.background.opacity(0.4) + ); + }); + } + + #[gpui::test] + fn test_outline_buttons_use_semantic_gradient_tokens(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + window.update(|_, cx| { + let config = serde_json::from_value::(serde_json::json!({ + "name": "Outline Gradient", + "mode": "light", + "colors": { + "primary.background": "linear-gradient(180deg, #111827, #020617)", + "primary.hover.background": "linear-gradient(180deg, #1F2937, #111827)", + "primary.active.background": "linear-gradient(180deg, #020617, #000000)", + "button.primary.background": "linear-gradient(180deg, #FFFFFF, #E5E7EB)", + "button.primary.hover.background": "linear-gradient(180deg, #F9FAFB, #E5E7EB)", + "button.primary.active.background": "linear-gradient(180deg, #E5E7EB, #D1D5DB)", + "danger.background": "linear-gradient(180deg, #EF4444, #DC2626)", + "danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)", + "danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)", + "button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)", + "button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)", + "button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)" + } + })) + .unwrap(); + crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config)); + + assert_eq!( + ButtonVariant::Primary.normal(true, cx).bg, + cx.theme().tokens.primary.background.opacity(0.1) + ); + assert_eq!( + ButtonVariant::Danger.normal(true, cx).bg, + cx.theme().tokens.danger.background.opacity(0.1) + ); + assert_eq!( + ButtonVariant::Danger.hovered(true, cx).bg, + cx.theme().tokens.danger_hover.background.opacity(0.2) + ); + assert_eq!( + ButtonVariant::Danger.active(true, cx).bg, + cx.theme().tokens.danger_active.background.opacity(0.4) + ); + assert_eq!( + ButtonVariant::Primary.normal(false, cx).bg, + cx.theme().tokens.button_primary.into() + ); + assert_eq!( + ButtonVariant::Danger.normal(false, cx).bg, + linear_gradient( + 180., + linear_color_stop(crate::try_parse_color("#FEF2F2").unwrap(), 0.), + linear_color_stop(crate::try_parse_color("#FEE2E2").unwrap(), 1.) + ) + ); + }); + } + + #[gpui::test] + fn test_disabled_outline_buttons_keep_semantic_backgrounds(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + window.update(|_, cx| { + let config = serde_json::from_value::(serde_json::json!({ + "name": "Disabled Outline Gradient", + "mode": "light", + "colors": { + "primary.background": "linear-gradient(180deg, #111827, #020617)", + "button.primary.background": "linear-gradient(180deg, #FFFFFF, #E5E7EB)", + "danger.background": "linear-gradient(180deg, #EF4444, #DC2626)", + "button.danger.background": "linear-gradient(180deg, #E5E7EB, #D1D5DB)" + } + })) + .unwrap(); + crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config)); + + assert_eq!( + ButtonVariant::Primary.disabled(true, cx).bg, + cx.theme() + .tokens + .primary + .background + .opacity(0.1) + .opacity(0.5) + ); + assert_eq!( + ButtonVariant::Danger.disabled(true, cx).bg, + cx.theme() + .tokens + .danger + .background + .opacity(0.1) + .opacity(0.5) + ); + assert_ne!( + ButtonVariant::Danger.disabled(true, cx).bg, + cx.theme().input_background().opacity(0.5).into() + ); + assert_ne!( + ButtonVariant::Danger.disabled(true, cx).bg, + cx.theme().tokens.button_danger.background.opacity(0.15) + ); + }); + } +} diff --git a/vendor/gpui-component/src/button/button_group.rs b/vendor/gpui-component/src/button/button_group.rs index 0fa6bb89..7075d1d0 100644 --- a/vendor/gpui-component/src/button/button_group.rs +++ b/vendor/gpui-component/src/button/button_group.rs @@ -1,13 +1,16 @@ +use gpui::Corners; +use gpui::InteractiveElement; +use gpui::ParentElement; +use gpui::{App, Axis, Edges, ElementId, IntoElement, Window}; use gpui::{ - div, prelude::FluentBuilder as _, App, Axis, Corners, Edges, ElementId, InteractiveElement, - IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement as _, StyleRefinement, - Styled, Window, + RenderOnce, StatefulInteractiveElement as _, StyleRefinement, Styled, div, + prelude::FluentBuilder as _, }; use std::{cell::Cell, rc::Rc}; use crate::{ - button::{Button, ButtonVariant, ButtonVariants}, Disableable, Sizable, Size, StyledExt, + button::{Button, ButtonVariant, ButtonVariants}, }; /// A ButtonGroup element, to wrap multiple buttons in a group. @@ -172,6 +175,10 @@ impl RenderOnce for ButtonGroup { .enumerate() .map(|(child_index, child)| { let state = Rc::clone(&state); + // The group as a whole is a toggle control, so every + // child advertises its pressed state. + let selected = child.selected; + let child = child.toggled(selected); let child = if children_len == 1 { child } else if child_index == 0 { @@ -207,7 +214,12 @@ impl RenderOnce for ButtonGroup { } else { // Middle child - .border_corners(Corners::all(false)) + .border_corners(Corners { + top_left: false, + top_right: false, + bottom_left: false, + bottom_right: false, + }) .border_edges(Edges { left: vertical, top: !vertical, @@ -252,3 +264,187 @@ impl RenderOnce for ButtonGroup { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Selectable as _; + use gpui::{ + Axis, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext, + VisualTestContext, point, px, + }; + use std::{cell::Cell, rc::Rc}; + + struct GroupHarness { + multiple: bool, + install_group_callback: bool, + child_clicks: Rc>, + group_changes: Rc>>>, + } + + impl Render for GroupHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let child_clicks = self.child_clicks.clone(); + let mut group = ButtonGroup::new("group") + .multiple(self.multiple) + .size(px(120.)) + .child( + Button::new("one") + .label("One") + .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)), + ) + .child(Button::new("two").label("Two").selected(true)); + if self.install_group_callback { + let changes = self.group_changes.clone(); + group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone())); + } + group + } + } + + fn group_harness( + cx: &mut TestAppContext, + multiple: bool, + install_group_callback: bool, + ) -> ( + &mut VisualTestContext, + Rc>, + Rc>>>, + ) { + cx.update(crate::init); + let child_clicks = Rc::new(Cell::new(0)); + let changes = Rc::new(std::cell::RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let child_clicks = child_clicks.clone(); + let group_changes = changes.clone(); + move |_, _| GroupHarness { + multiple, + install_group_callback, + child_clicks, + group_changes, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, child_clicks, changes) + } + + fn activate_key(cx: &mut VisualTestContext, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + fn click_first(cx: &mut VisualTestContext) { + cx.simulate_click(point(px(10.), px(60.)), Modifiers::default()); + } + + #[gpui::test] + fn legacy_group_callback_overrides_the_child_callback(cx: &mut TestAppContext) { + let (cx, child_clicks, changes) = group_harness(cx, false, true); + click_first(cx); + assert_eq!(child_clicks.get(), 0); + assert_eq!(changes.borrow().as_slice(), &[vec![0]]); + } + + #[gpui::test] + fn legacy_child_callback_survives_without_a_group_callback(cx: &mut TestAppContext) { + let (cx, child_clicks, changes) = group_harness(cx, false, false); + click_first(cx); + assert_eq!(child_clicks.get(), 1); + assert!(changes.borrow().is_empty()); + } + + #[gpui::test] + fn legacy_single_and_multiple_results_use_the_rendered_selection(cx: &mut TestAppContext) { + let (cx, _, single) = group_harness(cx, false, true); + click_first(cx); + assert_eq!(single.borrow().as_slice(), &[vec![0]]); + + let (cx, _, multiple) = group_harness(cx, true, true); + click_first(cx); + assert_eq!(multiple.borrow().as_slice(), &[vec![1, 0]]); + } + + #[gpui::test] + fn legacy_keyboard_click_does_not_reach_the_group_callback(cx: &mut TestAppContext) { + let (cx, _, changes) = group_harness(cx, false, true); + cx.update(|window, cx| window.focus_next(cx)); + activate_key(cx, "enter"); + assert!(changes.borrow().is_empty()); + } + + #[gpui::test] + fn legacy_disabled_state_depends_on_builder_order(cx: &mut TestAppContext) { + struct DisabledOrderHarness(Rc>); + + impl Render for DisabledOrderHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let first = self.0.clone(); + let second = self.0.clone(); + crate::v_flex() + .child( + ButtonGroup::new("disabled-before-child") + .size(px(120.)) + .disabled(true) + .child( + Button::new("first") + .label("First") + .on_click(move |_, _, _| first.set(first.get() + 1)), + ), + ) + .child( + ButtonGroup::new("disabled-after-child") + .size(px(120.)) + .child( + Button::new("second") + .label("Second") + .on_click(move |_, _, _| second.set(second.get() + 1)), + ) + .disabled(true), + ) + } + } + + cx.update(crate::init); + let clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let clicks = clicks.clone(); + move |_, _| DisabledOrderHarness(clicks) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(10.), px(60.)), Modifiers::default()); + assert_eq!(clicks.get(), 0); + cx.simulate_click(point(px(10.), px(180.)), Modifiers::default()); + assert_eq!(clicks.get(), 1); + } + + #[gpui::test] + fn test_button_group_builder(_cx: &mut gpui::TestAppContext) { + let group = ButtonGroup::new("complex-group") + .child(Button::new("btn1").label("One")) + .child(Button::new("btn2").label("Two")) + .child(Button::new("btn3").label("Three")) + .primary() + .large() + .outline() + .compact() + .multiple(true) + .layout(Axis::Vertical) + .disabled(false) + .on_click(|_, _, _| {}); + + assert_eq!(group.children.len(), 3); + assert_eq!(group.variant, Some(ButtonVariant::Primary)); + assert_eq!(group.size, Some(Size::Large)); + assert!(group.outline); + assert!(group.compact); + assert!(group.multiple); + assert_eq!(group.layout, Axis::Vertical); + assert!(!group.disabled); + assert!(group.on_click.is_some()); + } +} diff --git a/vendor/gpui-component/src/button/button_icon.rs b/vendor/gpui-component/src/button/button_icon.rs new file mode 100644 index 00000000..839ea81a --- /dev/null +++ b/vendor/gpui-component/src/button/button_icon.rs @@ -0,0 +1,168 @@ +use crate::{Icon, Sizable, Size, progress::ProgressCircle, spinner::Spinner}; +use gpui::{App, IntoElement, RenderOnce, Window, prelude::FluentBuilder}; + +/// Button icon which can be an Icon, Spinner, or Progress use for `icon` method of Button. +#[doc(hidden)] +#[derive(IntoElement)] +pub struct ButtonIcon { + icon: ButtonIconVariant, + loading_icon: Option, + loading: bool, + size: Size, +} + +impl From for ButtonIcon +where + T: Into, +{ + fn from(icon: T) -> Self { + ButtonIcon::new(icon) + } +} + +impl ButtonIcon { + /// Creates a new ButtonIcon with the given icon. + pub fn new(icon: impl Into) -> Self { + Self { + icon: icon.into(), + loading_icon: None, + loading: false, + size: Size::Medium, + } + } + + pub(crate) fn loading_icon(mut self, icon: Option) -> Self { + self.loading_icon = icon; + self + } + + pub(crate) fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } +} + +impl Sizable for ButtonIcon { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +/// Button icon which can be an Icon, Spinner, Progress, or ProgressCircle use for `icon` method of Button. +#[doc(hidden)] +#[derive(IntoElement)] +pub enum ButtonIconVariant { + Icon(Icon), + Spinner(Spinner), + Progress(ProgressCircle), +} + +impl From for ButtonIconVariant +where + T: Into, +{ + fn from(icon: T) -> Self { + Self::Icon(icon.into()) + } +} + +impl From for ButtonIconVariant { + fn from(spinner: Spinner) -> Self { + Self::Spinner(spinner) + } +} + +impl From for ButtonIconVariant { + fn from(progress: ProgressCircle) -> Self { + Self::Progress(progress) + } +} + +impl ButtonIconVariant { + /// Returns true if the ButtonIconKind is an Icon. + #[inline] + pub(crate) fn is_spinner(&self) -> bool { + matches!(self, Self::Spinner(_)) + } + + /// Returns true if the ButtonIconKind is a Progress or ProgressCircle. + #[inline] + pub(crate) fn is_progress(&self) -> bool { + matches!(self, Self::Progress(_)) + } +} + +impl Sizable for ButtonIconVariant { + fn with_size(self, size: impl Into) -> Self { + match self { + Self::Icon(icon) => Self::Icon(icon.with_size(size)), + Self::Spinner(spinner) => Self::Spinner(spinner.with_size(size)), + Self::Progress(progress) => Self::Progress(progress.with_size(size)), + } + } +} + +impl RenderOnce for ButtonIconVariant { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + match self { + Self::Icon(icon) => icon.into_any_element(), + Self::Spinner(spinner) => spinner.into_any_element(), + Self::Progress(progress) => progress.into_any_element(), + } + } +} + +impl RenderOnce for ButtonIcon { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + if self.loading { + if self.icon.is_spinner() || self.icon.is_progress() { + self.icon.with_size(self.size).into_any_element() + } else { + Spinner::new() + .when_some(self.loading_icon, |this, icon| this.icon(icon)) + .with_size(self.size) + .into_any_element() + } + } else { + self.icon.with_size(self.size).into_any_element() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::IconName; + + #[gpui::test] + fn test_button_icon_builder(_cx: &mut gpui::TestAppContext) { + let custom_icon = Icon::new(IconName::Loader); + let icon = ButtonIcon::new(IconName::Plus) + .loading(true) + .loading_icon(Some(custom_icon)) + .large(); + + assert!(icon.loading); + assert!(icon.loading_icon.is_some()); + assert_eq!(icon.size, Size::Large); + } + + #[gpui::test] + fn test_button_icon_variant_types(_cx: &mut gpui::TestAppContext) { + // Test Icon variant + let icon_variant = ButtonIconVariant::Icon(Icon::new(IconName::Plus)); + assert!(!icon_variant.is_spinner()); + assert!(!icon_variant.is_progress()); + + // Test Spinner variant + let spinner_variant = ButtonIconVariant::Spinner(Spinner::new()); + assert!(spinner_variant.is_spinner()); + assert!(!spinner_variant.is_progress()); + + // Test Progress variant + let progress_variant = ButtonIconVariant::Progress(ProgressCircle::new(75)); + assert!(!progress_variant.is_spinner()); + assert!(progress_variant.is_progress()); + } +} diff --git a/vendor/gpui-component/src/button/dropdown_button.rs b/vendor/gpui-component/src/button/dropdown_button.rs index 9713b62b..54ccf94f 100644 --- a/vendor/gpui-component/src/button/dropdown_button.rs +++ b/vendor/gpui-component/src/button/dropdown_button.rs @@ -1,16 +1,22 @@ +use gpui::{Corners, Hsla}; use gpui::{ - div, prelude::FluentBuilder, App, Context, Corner, Corners, Edges, ElementId, Hsla, - InteractiveElement as _, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, - Window, + Anchor, App, Context, Edges, ElementId, InteractiveElement as _, IntoElement, ParentElement, + RenderOnce, StyleRefinement, Styled, Window, div, prelude::FluentBuilder, }; use crate::{ - menu::{DropdownMenu, PopupMenu}, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt as _, + menu::{DropdownMenu, PopupMenu}, }; -use super::{Button, ButtonRounded, ButtonVariant, ButtonVariants}; +use super::{Button, ButtonVariant, ButtonVariants}; +/// A split button: an action button with an attached menu trigger. +/// +/// The two halves stay visually joined, except for a `ghost` button that is not +/// selected — a toolbar reads better when an idle ghost pair looks like two +/// separate buttons. +/// #[derive(IntoElement)] pub struct DropdownButton { id: ElementId, @@ -20,14 +26,12 @@ pub struct DropdownButton { Option) -> PopupMenu + 'static>>, selected: bool, disabled: bool, - // The button props - compact: bool, + // The button props, applied to both halves. Unset means the inner + // [`Button`] keeps whatever it was given. outline: bool, - loading: bool, - variant: ButtonVariant, - size: Size, - rounded: ButtonRounded, - anchor: Corner, + variant: Option, + size: Option, + anchor: Anchor, dropdown_icon: Option<(IconName, Option)>, } @@ -41,18 +45,32 @@ impl DropdownButton { menu: None, selected: false, disabled: false, - compact: false, outline: false, - loading: false, - variant: ButtonVariant::default(), - size: Size::default(), - rounded: ButtonRounded::default(), - anchor: Corner::TopRight, + variant: None, + size: None, + anchor: Anchor::TopRight, dropdown_icon: None, } } + fn effective_variant(&self) -> ButtonVariant { + self.variant + .or_else(|| self.button.as_ref().map(Button::variant)) + .unwrap_or_default() + } + + fn effective_size(&self) -> Size { + self.size + .or_else(|| self.button.as_ref().map(Button::button_size)) + .unwrap_or_default() + } + /// Set the left button of the dropdown button. + /// + /// The button keeps its own label, icon, tooltip and click handler. A + /// variant or size set on the [`DropdownButton`] applies to both halves and + /// overrides the one set here. When either outer value is unset, this + /// button's value becomes the shared value for both halves. pub fn button(mut self, button: Button) -> Self { self.button = Some(button); self @@ -70,7 +88,7 @@ impl DropdownButton { /// Set the dropdown menu of the button with anchor corner. pub fn dropdown_menu_with_anchor( mut self, - anchor: impl Into, + anchor: impl Into, menu: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> Self { self.menu = Some(Box::new(menu)); @@ -78,26 +96,6 @@ impl DropdownButton { self } - /// Set the rounded style of the button. - pub fn rounded(mut self, rounded: impl Into) -> Self { - self.rounded = rounded.into(); - self - } - - /// Replace the dropdown caret with a status icon. - pub fn dropdown_icon(mut self, icon: IconName, color: Option) -> Self { - self.dropdown_icon = Some((icon, color)); - self - } - - /// Set the button to compact style. - /// - /// See also: [`Button::compact`] - pub fn compact(mut self) -> Self { - self.compact = true; - self - } - /// Set the button to outline style. /// /// See also: [`Button::outline`] @@ -106,9 +104,9 @@ impl DropdownButton { self } - /// Set the button to loading state. - pub fn loading(mut self, loading: bool) -> Self { - self.loading = loading; + /// Replace the dropdown caret with a status icon. + pub fn dropdown_icon(mut self, icon: IconName, color: Option) -> Self { + self.dropdown_icon = Some((icon, color)); self } } @@ -128,14 +126,14 @@ impl Styled for DropdownButton { impl Sizable for DropdownButton { fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); + self.size = Some(size.into()); self } } impl ButtonVariants for DropdownButton { fn with_variant(mut self, variant: ButtonVariant) -> Self { - self.variant = variant; + self.variant = Some(variant); self } } @@ -153,81 +151,125 @@ impl Selectable for DropdownButton { impl RenderOnce for DropdownButton { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - let rounded = self.variant.is_ghost() && !self.selected; + debug_assert!( + self.button.is_some() || self.menu.is_some(), + "a DropdownButton needs a `button`, a `dropdown_menu`, or both" + ); + + let variant = self.effective_variant(); + let size = self.effective_size(); + let selected = self.selected || self.button.as_ref().is_some_and(Selectable::is_selected); + let is_ghost = variant.is_ghost(); + let attached = !(is_ghost && !selected); div() .id(self.id) .h_flex() .refine_style(&self.style) .when_some(self.button, |this, button| { + let disabled = self.disabled || button.is_disabled(); this.child( button - .rounded(self.rounded) .border_corners(Corners { top_left: true, - top_right: rounded, + top_right: !attached, bottom_left: true, - bottom_right: rounded, + bottom_right: !attached, + }) + .border_edges(Edges::all(true)) + .selected(selected) + .disabled(disabled) + .when(self.outline, |this| this.outline()) + .with_size(size) + .with_variant(variant), + ) + }) + .when_some(self.menu, |this, menu| { + let show_default_caret = self.dropdown_icon.is_none(); + this.child( + Button::new("popup") + .when_some(self.dropdown_icon, |button, (icon, color)| { + button.child(Icon::new(icon).when_some(color, |icon, color| { + icon.text_color(color) + })) + }) + .when(show_default_caret, |button| button.dropdown_caret(true)) + .border_corners(Corners { + top_left: !attached, + top_right: true, + bottom_left: !attached, + bottom_right: true, }) .border_edges(Edges { - left: true, + left: !attached, top: true, right: true, bottom: true, }) - .loading(self.loading) - .selected(self.selected) - .disabled(self.disabled || self.loading) - .when(self.compact, |this| this.compact()) + .selected(selected) + .disabled(self.disabled) .when(self.outline, |this| this.outline()) - .with_size(self.size) - .with_variant(self.variant), + .with_size(size) + .with_variant(variant) + .dropdown_menu_with_anchor(self.anchor, menu), ) - .when_some(self.menu, |this, menu| { - this.child( - { - let show_default_caret = self.dropdown_icon.is_none(); - Button::new("popup") - .when_some(self.dropdown_icon, |this, (icon, color)| { - this.child( - Icon::new(icon) - .with_size(match self.size { - Size::Size(size) => Size::Size(size * 0.75), - _ => self.size, - }) - .when_some(color, |this, color| this.text_color(color)), - ) - }) - .when(show_default_caret, |this| this.dropdown_caret(true)) - .when(!show_default_caret, |this| match self.size { - Size::Size(size) => this.size(size), - Size::XSmall => this.size_5(), - Size::Small => this.size_6(), - Size::Medium | Size::Large => this.size_8(), - }) - .rounded(self.rounded) - .border_edges(Edges { - left: rounded, - top: true, - right: true, - bottom: true, - }) - .border_corners(Corners { - top_left: rounded, - top_right: true, - bottom_left: rounded, - bottom_right: true, - }) - .selected(self.selected) - .disabled(self.disabled || self.loading) - .when(self.compact, |this| this.compact()) - .when(self.outline, |this| this.outline()) - .with_size(self.size) - .with_variant(self.variant) - .dropdown_menu_with_anchor(self.anchor, menu) - }, - ) - }) }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[gpui::test] + fn test_dropdown_button_builder(_cx: &mut gpui::TestAppContext) { + let button = Button::new("inner").label("Action"); + let dropdown = DropdownButton::new("complex-dropdown") + .button(button) + .primary() + .outline() + .large() + .disabled(false) + .selected(false) + .dropdown_menu_with_anchor(Anchor::BottomLeft, |menu, _, _| menu); + + assert!(dropdown.button.is_some()); + assert_eq!(dropdown.variant, Some(ButtonVariant::Primary)); + assert!(dropdown.outline); + assert_eq!(dropdown.size, Some(Size::Large)); + assert!(!dropdown.disabled); + assert!(!dropdown.selected); + assert!(dropdown.menu.is_some()); + assert_eq!(dropdown.anchor, Anchor::BottomLeft); + } + + /// An unset variant or size leaves the inner button's own to survive, so a + /// caller can style the halves from either level. + #[gpui::test] + fn inner_button_keeps_its_own_variant_and_size(_cx: &mut gpui::TestAppContext) { + let dropdown = DropdownButton::new("dropdown") + .button(Button::new("inner").label("Action").danger().small()) + .dropdown_menu(|menu, _, _| menu); + + assert_eq!(dropdown.variant, None); + assert_eq!(dropdown.size, None); + } + + #[gpui::test] + fn inner_ghost_becomes_the_split_variant(_cx: &mut gpui::TestAppContext) { + let dropdown = DropdownButton::new("dropdown") + .button(Button::new("inner").label("Action").ghost()) + .dropdown_menu(|menu, _, _| menu); + + assert_eq!(dropdown.effective_variant(), ButtonVariant::Ghost); + } + + #[gpui::test] + fn inner_size_becomes_the_split_size(_cx: &mut gpui::TestAppContext) { + let dropdown = DropdownButton::new("dropdown") + .button(Button::new("inner").label("Action").small()) + .dropdown_menu(|menu, _, _| menu); + + assert_eq!(dropdown.effective_size(), Size::Small); + } +} diff --git a/vendor/gpui-component/src/button/mod.rs b/vendor/gpui-component/src/button/mod.rs index 7d8dca74..c0f196ab 100644 --- a/vendor/gpui-component/src/button/mod.rs +++ b/vendor/gpui-component/src/button/mod.rs @@ -1,9 +1,11 @@ mod button; mod button_group; +mod button_icon; mod dropdown_button; mod toggle; pub use button::*; pub use button_group::*; +pub(crate) use button_icon::*; pub use dropdown_button::*; pub use toggle::*; diff --git a/vendor/gpui-component/src/button/toggle.rs b/vendor/gpui-component/src/button/toggle.rs index c6e3a78e..c415fc3d 100644 --- a/vendor/gpui-component/src/button/toggle.rs +++ b/vendor/gpui-component/src/button/toggle.rs @@ -1,13 +1,14 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - div, prelude::FluentBuilder as _, AnyElement, App, ElementId, InteractiveElement, IntoElement, + AnyElement, App, Axis, Corners, Edges, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, - Window, + Window, prelude::FluentBuilder as _, }; -use smallvec::{smallvec, SmallVec}; +use gpui_base::{Toggle as BaseToggle, ToggleGroup as BaseToggleGroup}; +use smallvec::{SmallVec, smallvec}; -use crate::{h_flex, ActiveTheme, Disableable, Icon, Sizable, Size, StyledExt}; +use crate::{ActiveTheme, Disableable, Icon, Sizable, Size, StyledExt, tooltip::ComponentTooltip}; #[derive(Default, Copy, Debug, Clone, PartialEq, Eq, Hash)] pub enum ToggleVariant { @@ -37,8 +38,11 @@ pub struct Toggle { size: Size, variant: ToggleVariant, disabled: bool, + border_corners: Corners, + border_edges: Edges, children: SmallVec<[AnyElement; 1]>, on_click: Option>, + tooltip: ComponentTooltip, } impl Toggle { @@ -51,11 +55,25 @@ impl Toggle { size: Size::default(), variant: ToggleVariant::default(), disabled: false, + border_corners: Corners { + top_left: true, + top_right: true, + bottom_left: true, + bottom_right: true, + }, + border_edges: Edges::all(true), children: smallvec![], on_click: None, + tooltip: ComponentTooltip::default(), } } + /// Set tooltip text for the toggle. + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip.text = Some((tooltip.into(), None)); + self + } + /// Add a label to the toggle. pub fn label(mut self, label: impl Into) -> Self { let label: SharedString = label.into(); @@ -83,6 +101,16 @@ impl Toggle { self.on_click = Some(Box::new(handler)); self } + + pub(crate) fn border_corners(mut self, corners: impl Into>) -> Self { + self.border_corners = corners.into(); + self + } + + pub(crate) fn border_edges(mut self, edges: impl Into>) -> Self { + self.border_edges = edges.into(); + self + } } impl ToggleVariants for Toggle { @@ -123,9 +151,21 @@ impl RenderOnce for Toggle { let checked = self.checked; let disabled = self.disabled; let hoverable = !disabled && !checked; + let rounding = cx.theme().radius; + let pressed_background = cx.theme().tokens.accent; + let pressed_foreground = cx.theme().accent_foreground; + let instance_style = self.style.clone(); - div() - .id(self.id) + BaseToggle::new(self.id) + .pressed(checked) + .disabled(disabled) + .when_some( + self.tooltip.text.as_ref().map(|(text, _)| text.clone()), + |this, label| this.accessibility_label(label), + ) + .when_some(self.on_click, |this, on_click| { + this.on_change(move |next, _, window, cx| on_click(&next, window, cx)) + }) .flex() .flex_row() .items_center() @@ -136,30 +176,43 @@ impl RenderOnce for Toggle { Size::Large => this.min_w_9().h_9().px_3().text_lg(), _ => this.min_w_8().h_8().px_2(), }) - .rounded(cx.theme().radius) + .when(self.border_corners.top_left, |this| { + this.rounded_tl(rounding) + }) + .when(self.border_corners.top_right, |this| { + this.rounded_tr(rounding) + }) + .when(self.border_corners.bottom_left, |this| { + this.rounded_bl(rounding) + }) + .when(self.border_corners.bottom_right, |this| { + this.rounded_br(rounding) + }) .when(self.variant == ToggleVariant::Outline, |this| { - this.border_1() + this.when(self.border_edges.left, |this| this.border_l_1()) + .when(self.border_edges.right, |this| this.border_r_1()) + .when(self.border_edges.top, |this| this.border_t_1()) + .when(self.border_edges.bottom, |this| this.border_b_1()) .border_color(cx.theme().border) - .bg(cx.theme().background) - .when(cx.theme().shadow, |this| this.shadow_xs()) + .bg(cx.theme().tokens.background) }) .when(hoverable, |this| { this.hover(|this| { - this.bg(cx.theme().accent) + this.bg(cx.theme().tokens.accent) .text_color(cx.theme().accent_foreground) }) }) - .when(checked, |this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) + .styles(|styles| { + styles.pressed(|style| { + style + .bg(pressed_background) + .text_color(pressed_foreground) + .refine_style(&instance_style) + }) }) .refine_style(&self.style) .children(self.children) - .when(!disabled, |this| { - this.when_some(self.on_click, |this, on_click| { - this.on_click(move |_, window, cx| on_click(&!checked, window, cx)) - }) - }) + .map(|this| self.tooltip.apply(this)) } } @@ -171,6 +224,7 @@ pub struct ToggleGroup { size: Size, variant: ToggleVariant, disabled: bool, + segmented: bool, items: Vec, on_click: Option, &mut Window, &mut App) + 'static>>, } @@ -184,6 +238,7 @@ impl ToggleGroup { size: Size::default(), variant: ToggleVariant::default(), disabled: false, + segmented: false, items: Vec::new(), on_click: None, } @@ -211,6 +266,15 @@ impl ToggleGroup { self.on_click = Some(Rc::new(on_click)); self } + + /// Render the group as a connected segmented control. + /// + /// This keeps the existing multi-toggle behavior, but removes the default + /// gap and joins adjacent item borders into a single segmented outline. + pub fn segmented(mut self) -> Self { + self.segmented = true; + self + } } impl Sizable for ToggleGroup { @@ -243,38 +307,313 @@ impl Styled for ToggleGroup { impl RenderOnce for ToggleGroup { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let disabled = self.disabled; + let items_len = self.items.len(); let checks = self .items .iter() .map(|item| item.checked) .collect::>(); - let state = Rc::new(Cell::new(None)); + let clicked_index = Rc::new(Cell::new(None)); - h_flex() - .id(self.id) - .gap_1() - .refine_style(&self.style) - .children(self.items.into_iter().enumerate().map({ - |(ix, item)| { - let state = state.clone(); - item.disabled(disabled) - .with_size(self.size) - .with_variant(self.variant) - .on_click(move |_, _, _| { - state.set(Some(ix)); - }) - } - })) - .when(!disabled, |this| { - this.when_some(self.on_click, |this, on_click| { - this.on_click(move |_, window, cx| { - if let Some(ix) = state.get() { - let mut checks = checks.clone(); - checks[ix] = !checks[ix]; - on_click(&checks, window, cx); + BaseToggleGroup::new(self.id) + .axis(Axis::Horizontal) + .child( + crate::h_flex() + .items_center() + .when(!self.segmented, |this| this.gap_2()) + .refine_style(&self.style) + .children(self.items.into_iter().enumerate().map({ + { + let clicked_index = clicked_index.clone(); + move |(ix, item)| { + let item = if !self.segmented || items_len == 1 { + item + } else if ix == 0 { + item.border_corners(Corners { + top_left: true, + top_right: false, + bottom_left: true, + bottom_right: false, + }) + .border_edges(Edges { + left: true, + top: true, + right: true, + bottom: true, + }) + } else if ix == items_len - 1 { + item.border_corners(Corners { + top_left: false, + top_right: true, + bottom_left: false, + bottom_right: true, + }) + .border_edges(Edges { + left: false, + top: true, + right: true, + bottom: true, + }) + } else { + item.border_corners(Corners { + top_left: false, + top_right: false, + bottom_left: false, + bottom_right: false, + }) + .border_edges(Edges { + left: false, + top: true, + right: true, + bottom: true, + }) + }; + + let effective_disabled = disabled || item.disabled; + let clicked_index = clicked_index.clone(); + item.disabled(effective_disabled) + .with_size(self.size) + .with_variant(self.variant) + .on_click(move |_, _, cx| { + clicked_index.set(Some(ix)); + cx.propagate(); + }) + } } + })), + ) + .when_some( + (!disabled).then_some(self.on_click).flatten(), + |this, on_click| { + this.on_click(move |_, window, cx| { + let Some(ix) = clicked_index.take() else { + return; + }; + let mut next = checks.clone(); + next[ix] = !next[ix]; + on_click(&next, window, cx); }) - }) - }) + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{IconName, h_flex}; + use gpui::{ + Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, + StatefulInteractiveElement, TestAppContext, VisualTestContext, point, px, + }; + use std::cell::{Cell, RefCell}; + + struct ToggleHarness { + disabled: bool, + changes: Rc>>, + parent_clicks: Rc>, + } + + impl Render for ToggleHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let parent_clicks = self.parent_clicks.clone(); + h_flex() + .id("toggle-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Toggle::new("toggle") + .label("Bold") + .disabled(self.disabled) + .size_full() + .on_click(move |next, _, _| changes.borrow_mut().push(*next)), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + disabled: bool, + ) -> ( + &mut VisualTestContext, + Rc>>, + Rc>, + ) { + cx.update(crate::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let changes = changes.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| ToggleHarness { + disabled, + changes, + parent_clicks, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, changes, parent_clicks) + } + + fn activate_key(cx: &mut VisualTestContext, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + #[gpui::test] + fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) { + let (cx, changes, _) = harness(cx, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(changes.borrow().as_slice(), &[true]); + cx.update(|window, cx| assert!(window.focused(cx).is_some())); + } + + #[gpui::test] + fn canonical_toggle_supports_tab_enter_and_space(cx: &mut TestAppContext) { + let (cx, changes, _) = harness(cx, false); + cx.update(|window, cx| window.focus_next(cx)); + cx.update(|window, cx| assert!(window.focused(cx).is_some())); + activate_key(cx, "enter"); + activate_key(cx, "space"); + assert_eq!(changes.borrow().as_slice(), &[true, true]); + } + + #[gpui::test] + fn canonical_disabled_toggle_is_inert_and_blocks_parent(cx: &mut TestAppContext) { + let (cx, changes, parent_clicks) = harness(cx, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert!(changes.borrow().is_empty()); + assert_eq!(parent_clicks.get(), 0); + } + + struct ToggleGroupHarness { + install_group_callback: bool, + child_clicks: Rc>, + group_changes: Rc>>>, + } + + impl Render for ToggleGroupHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let child_clicks = self.child_clicks.clone(); + let mut group = ToggleGroup::new("toggle-group") + .w(px(120.)) + .child( + Toggle::new("one") + .label("One") + .checked(true) + .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)), + ) + .child(Toggle::new("two").label("Two")); + if self.install_group_callback { + let changes = self.group_changes.clone(); + group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone())); + } + group + } + } + + fn toggle_group_harness( + cx: &mut TestAppContext, + install_group_callback: bool, + ) -> ( + &mut VisualTestContext, + Rc>, + Rc>>>, + ) { + cx.update(crate::init); + let child_clicks = Rc::new(Cell::new(0)); + let group_changes = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view({ + let child_clicks = child_clicks.clone(); + let group_changes = group_changes.clone(); + move |_, _| ToggleGroupHarness { + install_group_callback, + child_clicks, + group_changes, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, child_clicks, group_changes) + } + + #[gpui::test] + fn legacy_toggle_group_overrides_child_callback_even_without_group_callback( + cx: &mut TestAppContext, + ) { + let (cx, child_clicks, changes) = toggle_group_harness(cx, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(child_clicks.get(), 0); + assert!(changes.borrow().is_empty()); + } + + #[gpui::test] + fn legacy_toggle_group_pointer_flips_only_the_clicked_rendered_value(cx: &mut TestAppContext) { + let (cx, child_clicks, changes) = toggle_group_harness(cx, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(child_clicks.get(), 0); + assert_eq!(changes.borrow().as_slice(), &[vec![false, false]]); + } + + #[gpui::test] + fn legacy_toggle_group_keyboard_click_does_not_reach_the_group_callback( + cx: &mut TestAppContext, + ) { + let (cx, child_clicks, changes) = toggle_group_harness(cx, true); + cx.update(|window, cx| window.focus_next(cx)); + activate_key(cx, "enter"); + assert_eq!(child_clicks.get(), 0); + assert!(changes.borrow().is_empty()); + } + + #[test] + fn instance_style_remains_the_final_visual_override() { + let toggle = Toggle::new("styled").checked(true).opacity(0.37); + assert_eq!(toggle.style.opacity, Some(0.37)); + } + + #[gpui::test] + fn test_toggle_builder(_cx: &mut gpui::TestAppContext) { + let toggle = Toggle::new("complex-toggle") + .label("Enable Feature") + .icon(IconName::Check) + .checked(true) + .outline() + .large() + .disabled(false) + .on_click(|_, _, _| {}); + + assert_eq!(toggle.children.len(), 2); // label + icon + assert!(toggle.checked); + assert_eq!(toggle.variant, ToggleVariant::Outline); + assert_eq!(toggle.size, Size::Large); + assert!(!toggle.disabled); + assert!(toggle.on_click.is_some()); + } + + #[gpui::test] + fn test_toggle_group_builder(_cx: &mut gpui::TestAppContext) { + let group = ToggleGroup::new("complex-group") + .child(Toggle::new("toggle1").label("Option 1")) + .child(Toggle::new("toggle2").label("Option 2").checked(true)) + .child(Toggle::new("toggle3").label("Option 3")) + .outline() + .large() + .segmented() + .disabled(false) + .on_click(|_, _, _| {}); + + assert_eq!(group.items.len(), 3); + assert_eq!(group.variant, ToggleVariant::Outline); + assert_eq!(group.size, Size::Large); + assert!(group.segmented); + assert!(!group.disabled); + assert!(group.on_click.is_some()); } } diff --git a/vendor/gpui-component/src/chart/area_chart.rs b/vendor/gpui-component/src/chart/area_chart.rs index 78398c8c..86a0a5ca 100644 --- a/vendor/gpui-component/src/chart/area_chart.rs +++ b/vendor/gpui-component/src/chart/area_chart.rs @@ -1,18 +1,24 @@ use std::rc::Rc; -use gpui::{px, App, Background, Bounds, Hsla, Pixels, SharedString, TextAlign, Window}; +use gpui::{ + AnyElement, App, Background, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, + Window, point, px, +}; use gpui_component_macros::IntoPlot; use num_traits::{Num, ToPrimitive}; use crate::{ + ActiveTheme, plot::{ + AXIS_GAP, Grid, Plot, PlotAxis, StrokeStyle, scale::{Scale, ScaleLinear, ScalePoint, Sealed}, shape::Area, - AxisText, Grid, Plot, PlotAxis, StrokeStyle, AXIS_GAP, + tooltip::{CrossLine, Dot, Tooltip, TooltipState}, }, - ActiveTheme, PixelsExt, }; +use super::build_point_x_labels; + #[derive(IntoPlot)] pub struct AreaChart where @@ -26,7 +32,11 @@ where strokes: Vec, stroke_styles: Vec, fills: Vec, + names: Vec, tick_margin: usize, + x_axis: bool, + grid: bool, + id: Option, } impl AreaChart @@ -43,12 +53,33 @@ where stroke_styles: vec![], strokes: vec![], fills: vec![], + names: vec![], tick_margin: 1, x: None, y: vec![], + x_axis: true, + grid: true, + id: None, } } + /// Enable an interactive hover tooltip (crosshair + a dot and row per series). + /// + /// The `id` must be unique among sibling elements. Without it, the chart stays a + /// non-interactive plot. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Set the name of the most recently added series, shown in its tooltip row. + /// + /// Call after the matching [`AreaChart::y`] (e.g. `.y(..).stroke(..).name("Desktop")`). + pub fn name(mut self, name: impl Into) -> Self { + self.names.push(name.into()); + self + } + pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self { self.x = Some(Rc::new(x)); self @@ -88,29 +119,35 @@ where self.tick_margin = tick_margin; self } -} -impl Plot for AreaChart -where - X: Clone + PartialEq + Into + 'static, - Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, -{ - fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { - let Some(x_fn) = self.x.as_ref() else { - return; - }; + /// Show or hide the x-axis line and labels. + /// + /// Default is true. + pub fn x_axis(mut self, x_axis: bool) -> Self { + self.x_axis = x_axis; + self + } - if self.y.len() == 0 { - return; + pub fn grid(mut self, grid: bool) -> Self { + self.grid = grid; + self + } + + /// Build the x (point) and y (linear) scales for the given bounds. + /// + /// Shared by `paint` and `tooltip_state` so the two stay in sync. Returns `None` when there + /// is no x accessor or no series. + fn scales(&self, bounds: Bounds) -> Option<(ScalePoint, ScaleLinear)> { + let x_fn = self.x.as_ref()?; + if self.y.is_empty() { + return None; } let width = bounds.size.width.as_f32(); - let height = bounds.size.height.as_f32() - AXIS_GAP; + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + let height = bounds.size.height.as_f32() - axis_gap; - // X scale let x = ScalePoint::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]); - - // Y scale let domain = self .data .iter() @@ -119,41 +156,48 @@ where .collect::>(); let y = ScaleLinear::new(domain, vec![height, 10.]); + Some((x, y)) + } +} + +impl Plot for AreaChart +where + X: Clone + PartialEq + Into + 'static, + Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let Some(x_fn) = self.x.as_ref() else { + return; + }; + let Some((x, y)) = self.scales(bounds) else { + return; + }; + + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + let height = bounds.size.height.as_f32() - axis_gap; + // Draw X axis - let data_len = self.data.len(); - let x_label = self.data.iter().enumerate().filter_map(|(i, d)| { - if (i + 1) % self.tick_margin == 0 { - x.tick(&x_fn(d)).map(|x_tick| { - let align = match i { - 0 => { - if data_len == 1 { - TextAlign::Center - } else { - TextAlign::Left - } - } - i if i == data_len - 1 => TextAlign::Right, - _ => TextAlign::Center, - }; - AxisText::new(x_fn(d).into(), x_tick, cx.theme().muted_foreground).align(align) - }) - } else { - None - } - }); - - PlotAxis::new() - .x(height) - .x_label(x_label) - .stroke(cx.theme().border) - .paint(&bounds, window, cx); + let mut axis = PlotAxis::new().stroke(cx.theme().border); + if self.x_axis { + let labels = build_point_x_labels( + &self.data, + x_fn.as_ref(), + &x, + self.tick_margin, + cx.theme().muted_foreground, + ); + axis = axis.x(height).x_label(labels); + } + axis.paint(&bounds, window, cx); // Draw grid - Grid::new() - .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) - .stroke(cx.theme().border) - .dash_array(&[px(4.), px(2.)]) - .paint(&bounds, window); + if self.grid { + Grid::new() + .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) + .stroke(cx.theme().border) + .dash_array(&[px(4.), px(2.)]) + .paint(&bounds, window); + } // Draw area for (i, y_fn) in self.y.iter().enumerate() { @@ -185,4 +229,84 @@ where .paint(&bounds, window); } } + + fn id(&self) -> Option { + self.id.clone() + } + + fn tooltip_state( + &self, + position: Point, + bounds: Bounds, + _cx: &App, + ) -> Option { + let x_fn = self.x.as_ref()?; + let (x, y) = self.scales(bounds)?; + + // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip. + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap { + return None; + } + + let index = x.least_index(position.x.as_f32()); + let d = self.data.get(index)?; + let x_tick = x.tick(&x_fn(d))?; + + // One dot per series at the hovered x. + let dots = self + .y + .iter() + .filter_map(|y_fn| Some(point(px(x_tick), px(y.tick(&y_fn(d))?)))) + .collect(); + + Some(TooltipState::new( + index, + point(px(x_tick), position.y), + dots, + )) + } + + fn tooltip( + &self, + state: &TooltipState, + cursor: Point, + bounds: Bounds, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let x_fn = self.x.as_ref()?; + let d = self.data.get(state.index)?; + let title: SharedString = x_fn(d).into(); + + let default_color = cx.theme().chart_2; + let dot_stroke = cx.theme().background; + let color = |i: usize| *self.strokes.get(i).unwrap_or(&default_color); + + // Follow the cursor; the crosshair and dots stay snapped to the data point. + let mut tooltip = Tooltip::new(cursor, bounds.size) + .gap(px(8.)) + // Confine the crosshair to the plot area so it doesn't cross the x-axis. + .cross_line( + CrossLine::new(state.cross_line) + .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }), + ) + .dots( + state + .dots + .iter() + .enumerate() + .map(|(i, p)| Dot::new(*p).stroke(dot_stroke).fill(color(i))), + ) + .title(title); + + // One row per series: swatch + label + value. + for (i, y_fn) in self.y.iter().enumerate() { + let name = self.names.get(i).cloned().unwrap_or_default(); + let value = y_fn(d).to_f64()?; + tooltip = tooltip.row(color(i), name, format!("{}", value)); + } + + Some(tooltip.into_any_element()) + } } diff --git a/vendor/gpui-component/src/chart/bar_chart.rs b/vendor/gpui-component/src/chart/bar_chart.rs index 8f53499a..8f45ed2b 100644 --- a/vendor/gpui-component/src/chart/bar_chart.rs +++ b/vendor/gpui-component/src/chart/bar_chart.rs @@ -1,38 +1,62 @@ -use std::rc::Rc; +use std::{ops::RangeInclusive, rc::Rc}; -use gpui::{App, Bounds, Hsla, Pixels, SharedString, TextAlign, Window, px}; +use gpui::{ + AnyElement, App, Background, Bounds, Corners, ElementId, Hsla, IntoElement, LinearColorStop, + Pixels, Point, SharedString, Size, TextAlign, Window, linear_gradient, point, px, +}; use gpui_component_macros::IntoPlot; use num_traits::{Num, ToPrimitive}; use crate::{ - ActiveTheme, PixelsExt, + ActiveTheme, plot::{ - AXIS_GAP, AxisText, Grid, Plot, PlotAxis, - label::Text, + AXIS_GAP, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel, + label::{TEXT_GAP, TEXT_SIZE, Text, measure_text_width}, scale::{Scale, ScaleBand, ScaleLinear, Sealed}, - shape::Bar, + shape::{Bar, BarAlignment}, + tooltip::{CrossLine, Tooltip, TooltipState}, }, }; +use super::build_band_labels; + +/// Space reserved along the band axis for the value-axis tick labels, in pixels. +/// +/// Like [`AXIS_GAP`] this is a fixed budget rather than a measured one: the band +/// scale is also rebuilt during hit-testing, where no [`Window`] is available to +/// shape text. Values wider than this (very large numbers) will overflow it. +const VALUE_AXIS_GAP: f32 = 32.; + #[derive(IntoPlot)] -pub struct BarChart +pub struct BarChart where T: 'static, - X: PartialEq + Into + 'static, - Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, + B: PartialEq + Into + 'static, + V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, { data: Vec, - x: Option X>>, - y: Option Y>>, - fill: Option Hsla>>, + band: Option B>>, + value: Option V>>, + fill: Option, Bounds, BarAlignment) -> Background>>, + #[allow(clippy::type_complexity)] + fill_gradient: + Option, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>, tick_margin: usize, label: Option SharedString>>, + label_axis: bool, + value_axis: bool, + value_tick_count: usize, + grid: bool, + alignment: BarAlignment, + corner_radii: Corners, + id: Option, + name: Option, } -impl BarChart +impl BarChart where - X: PartialEq + Into + 'static, - Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, + B: PartialEq + Into + 'static, + V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, { pub fn new(data: I) -> Self where @@ -40,29 +64,125 @@ where { Self { data: data.into_iter().collect(), - x: None, - y: None, + band: None, + value: None, fill: None, + fill_gradient: None, tick_margin: 1, label: None, + label_axis: true, + value_axis: false, + value_tick_count: 4, + grid: true, + alignment: BarAlignment::default(), + corner_radii: Corners::all(px(0.)), + id: None, + name: None, } } - pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self { - self.x = Some(Rc::new(x)); + /// Enable an interactive hover tooltip (crosshair + category/value) for this chart. + /// + /// The `id` must be unique among sibling elements. Without it, the chart stays a + /// non-interactive plot. Works for every [`BarAlignment`] (vertical bars get a + /// vertical crosshair, horizontal bars a horizontal one). + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Map each datum to its band-axis value (the categorical/ordinal axis). + pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self { + self.band = Some(Rc::new(band)); self } - pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self { - self.y = Some(Rc::new(y)); + /// Map each datum to its numeric value along the value axis. + pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self { + self.value = Some(Rc::new(value)); self } - pub fn fill(mut self, fill: impl Fn(&T) -> H + 'static) -> Self + /// Set a per-datum verbatim fill. + /// + /// The closure receives: + /// + /// 1. the datum, + /// 2. the **bar's bounds** in pixel space, expressed relative to the + /// chart's origin (i.e. the bar's painted rectangle within the chart), + /// 3. the **chart's bounds** in pixel space with origin `(0, 0)` and size + /// equal to the full chart extent, and + /// 4. the bar's [`BarAlignment`] (so callers can branch on orientation, + /// e.g. flip a gradient angle). + /// + /// Both rectangles share the same coordinate system, so callers can + /// implement arbitrary chart-aware backgrounds — bar-local gradients, + /// chart-wide gradients, patterns, sampled colormaps, etc. — without any + /// help from the library. + /// + /// Accepts any type convertible to [`Background`]. Setting this clears any + /// previously set [`BarChart::fill_gradient`]. + pub fn fill( + mut self, + fill: impl Fn(&T, Bounds, Bounds, BarAlignment) -> Bg + 'static, + ) -> Self where - H: Into + 'static, + Bg: Into + 'static, { - self.fill = Some(Rc::new(move |t| fill(t).into())); + self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| { + fill(t, bar_bounds, chart_bounds, alignment).into() + })); + self.fill_gradient = None; + self + } + + /// Set a per-datum auto-oriented linear gradient fill. + /// + /// The closure receives the datum, the chart's full data range + /// (`chart_range`, derived from all data values), and a `chart_to_bar` + /// remap helper that maps a chart-value coordinate to a bar-local + /// gradient position (where `0.0` is the bar's base and `1.0` is its tip). + /// + /// Use bar-local positions directly for per-bar gradients (every bar + /// looks the same regardless of its value): + /// + /// ```ignore + /// .fill_gradient(|_, _, _| [ + /// linear_color_stop(c.opacity(0.3), 0.0), + /// linear_color_stop(c, 1.0), + /// ]) + /// ``` + /// + /// Or use `chart_to_bar` to position stops at chart-relative values, so + /// each bar shows the slice of a chart-wide gradient corresponding to + /// its own `[base, value]` span: + /// + /// ```ignore + /// .fill_gradient(|_, chart_range, chart_to_bar| [ + /// linear_color_stop(c.opacity(0.3), chart_to_bar(*chart_range.start())), + /// linear_color_stop(c, chart_to_bar(*chart_range.end())), + /// ]) + /// ``` + /// + /// Stop positions returned outside `[0, 1]` are clipped to the bar; the + /// library interpolates colors at the clip points so the on-bar gradient + /// still matches the chart-wide one. + /// + /// The gradient angle is derived from [`BarAlignment`] so stop-0 is at the + /// base and stop-1 at the tip. Setting this clears any previously set + /// [`BarChart::fill`]. + pub fn fill_gradient( + mut self, + fill: impl Fn(&T, RangeInclusive, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static, + ) -> Self { + self.fill_gradient = Some(Rc::new(fill)); + self.fill = None; self } @@ -78,87 +198,623 @@ where self.label = Some(Rc::new(move |t| label(t).into())); self } + + /// Show or hide the band-axis line and labels. + /// + /// Default is true. + pub fn label_axis(mut self, label_axis: bool) -> Self { + self.label_axis = label_axis; + self + } + + /// Show or hide the value-axis tick labels. + /// + /// Enabling this reserves [`VALUE_AXIS_GAP`] along the band axis (left of + /// vertical bars, below horizontal ones) for the labels. + /// + /// Default is false. + pub fn value_axis(mut self, value_axis: bool) -> Self { + self.value_axis = value_axis; + self + } + + /// Set how many even intervals the value axis is divided into, which drives + /// both the grid line spacing and the value-axis tick labels. + /// + /// This is a count, unlike [`Self::tick_margin`], which is a stride over the + /// band axis categories. + /// + /// Default is 4. + pub fn value_tick_count(mut self, value_tick_count: usize) -> Self { + self.value_tick_count = value_tick_count.max(1); + self + } + + pub fn grid(mut self, grid: bool) -> Self { + self.grid = grid; + self + } + + /// Set the bar alignment. + /// + /// Default is [`BarAlignment::Bottom`]. + pub fn alignment(mut self, alignment: BarAlignment) -> Self { + self.alignment = alignment; + self + } + + /// Set the corner radii applied to every bar rectangle. + /// + /// Use [`Corners::all`] for uniform rounding, or construct [`Corners`] manually + /// to round only specific corners (e.g. just the tip end of each bar). + pub fn corner_radii(mut self, corner_radii: impl Into>) -> Self { + self.corner_radii = corner_radii.into(); + self + } + + /// The band scale (matching `paint`): spans the height for horizontal bars, the width + /// otherwise. Shared by `tooltip_state` and `tooltip`. + fn band_scale(&self, bounds: Bounds) -> Option> { + let band_fn = self.band.as_ref()?; + let band_extent = if self.alignment.is_horizontal() { + bounds.size.height.as_f32() + } else { + bounds.size.width.as_f32() + }; + // Value-axis labels eat into the band extent at one end; `band_offset` + // shifts the bands away from that end when it is the leading one. + let gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. }; + Some( + ScaleBand::new( + self.data.iter().map(|v| band_fn(v)).collect(), + vec![0., (band_extent - gap).max(0.)], + ) + .padding_inner(0.4) + .padding_outer(0.2), + ) + } + + /// Offset added to every band-scale tick. + /// + /// [`ScaleBand`] ignores the start of its range, so vertical bars are shifted + /// by hand to clear the value-axis labels on their left. Horizontal bars put + /// those labels below the plot, past the end of the band axis, so they need no + /// shift. + fn band_offset(&self) -> f32 { + if self.value_axis && !self.alignment.is_horizontal() { + VALUE_AXIS_GAP + } else { + 0. + } + } + + /// Label gaps `(band_side, value_end_side)` reserved along the value axis for + /// horizontal bars, measured from the actual label text. Shared by `paint` and the + /// tooltip so the crosshair lines up with the bar region. + fn horizontal_gaps(&self, window: &mut Window) -> (f32, f32) { + let Some(band_fn) = self.band.as_ref() else { + return (0., 0.); + }; + let font_size = px(TEXT_SIZE); + let band_gap = if self.label_axis { + self.data + .iter() + .map(|v| { + let s: SharedString = band_fn(v).into(); + measure_text_width(&s, font_size, window) + }) + .fold(0f32, f32::max) + + TEXT_GAP * 2. + } else { + 0. + }; + let value_end_gap = if let Some(label_fn) = self.label.as_ref() { + self.data + .iter() + .map(|v| measure_text_width(&label_fn(v), font_size, window)) + .fold(0f32, f32::max) + + TEXT_GAP * 2. + } else { + TEXT_GAP * 4. + }; + (band_gap, value_end_gap) + } } -impl Plot for BarChart +impl Plot for BarChart where - X: PartialEq + Into + 'static, - Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, + B: PartialEq + Into + 'static, + V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, { fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { - let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else { + let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else { return; }; - let width = bounds.size.width.as_f32(); - let height = bounds.size.height.as_f32() - AXIS_GAP; + let total_width = bounds.size.width.as_f32(); + let total_height = bounds.size.height.as_f32(); + let axis_gap = if self.label_axis { AXIS_GAP } else { 0. }; + let alignment = self.alignment; + let is_horizontal = alignment.is_horizontal(); - // X scale - let x = ScaleBand::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]) - .padding_inner(0.4) - .padding_outer(0.2); - let band_width = x.band_width(); + // Band scale spans the full extent perpendicular to the value axis. Shared with the + // tooltip via `band_scale()` so the bars and the hover crosshair stay aligned. + let Some(band_scale) = self.band_scale(bounds) else { + return; + }; + let band_width = band_scale.band_width(); - // Y scale, ensure start from 0. - let y = ScaleLinear::new( + let value_dim = if is_horizontal { + total_width + } else { + total_height + }; + // For horizontal charts the band labels (category names) are rendered + // along the value axis and can be arbitrarily wide, so we measure the + // actual maximum label width instead of using a fixed constant. + // Similarly, value labels (numbers) at the bar ends are measured so the + // scale range is always shrunk by exactly the right amount. + let (band_gap, value_end_gap) = if is_horizontal { + self.horizontal_gaps(window) + } else { + (axis_gap, 10.) + }; + let (range, baseline) = match alignment { + BarAlignment::Bottom => { + let baseline = value_dim - axis_gap; + (vec![baseline, 10.], baseline) + } + BarAlignment::Top => { + let baseline = axis_gap; + (vec![baseline, value_dim - 10.], baseline) + } + BarAlignment::Left => { + let baseline = band_gap; + (vec![baseline, value_dim - value_end_gap], baseline) + } + BarAlignment::Right => { + let baseline = value_dim - band_gap; + (vec![baseline, value_end_gap], baseline) + } + }; + let value_scale = ScaleLinear::new( self.data .iter() - .map(|v| y_fn(v)) - .chain(Some(Y::zero())) + .map(|v| value_fn(v)) + .chain(Some(V::zero())) .collect(), - vec![height, 10.], + range, ); - // Draw X axis - let x_label = self.data.iter().enumerate().filter_map(|(i, d)| { - if (i + 1) % self.tick_margin == 0 { - x.tick(&x_fn(d)).map(|x_tick| { - AxisText::new( - x_fn(d).into(), - x_tick + band_width / 2., - cx.theme().muted_foreground, - ) - .align(TextAlign::Center) - }) - } else { - None + // Where zero sits along the value axis. Bars grow from here rather than from + // the geometric baseline, so negative values extend to the opposite side. With + // no negative data zero is the domain minimum and this is the baseline. + let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline); + let band_offset = self.band_offset(); + + // Grid lines and the zero line span their bounds edge to edge, so they are + // painted into bounds inset by the value-axis gap. Without this they run + // straight through the value-axis labels. + let value_axis_gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. }; + let plot_bounds = if is_horizontal { + Bounds { + origin: bounds.origin, + size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)), + } + } else { + Bounds { + origin: bounds.origin + point(px(value_axis_gap), px(0.)), + size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height), } + }; + + // Value domain, matching `value_scale`'s (which is the data plus zero). + // `far` maps to the maximum and `baseline` to the minimum. + let (domain_lo, domain_hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| { + let f = value_fn(v).to_f32().unwrap_or(0.); + (lo.min(f), hi.max(f)) }); - PlotAxis::new() - .x(height) - .x_label(x_label) - .stroke(cx.theme().border) - .paint(&bounds, window, cx); - - // Draw grid - Grid::new() - .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) - .stroke(cx.theme().border) - .dash_array(&[px(4.), px(2.)]) - .paint(&bounds, window); - - // Draw bars - let x_fn = x_fn.clone(); - let y_fn = y_fn.clone(); - let default_fill = cx.theme().chart_2; + // Draw band axis (with categorical labels). + let mut axis = PlotAxis::new().stroke(cx.theme().border); + if self.label_axis { + match alignment { + BarAlignment::Bottom | BarAlignment::Top => { + axis = axis.x(zero_pixel); + + // Labels are placed one at a time rather than through + // `x_label`, because a chart with negative values needs them + // on either side of the zero line: each label goes on the side + // its own bar leaves empty. + let labels = self + .data + .iter() + .enumerate() + .filter(|(i, _)| (i + 1) % self.tick_margin == 0) + .filter_map(|(_, d)| { + let band_x = band_scale.tick(&band_fn(d))?; + let value = value_fn(d).to_f32().unwrap_or(0.); + let label_y = if label_below_zero_line(value, alignment) { + zero_pixel + TEXT_GAP + } else { + zero_pixel - TEXT_GAP - TEXT_SIZE + }; + + Some( + Text::new( + band_fn(d).into(), + point(px(band_x + band_offset + band_width / 2.), px(label_y)), + cx.theme().muted_foreground, + ) + .align(TextAlign::Center), + ) + }) + .collect(); + PlotLabel::new(labels).paint(&bounds, window, cx); + } + BarAlignment::Left | BarAlignment::Right => { + let labels = build_band_labels( + &self.data, + band_fn.as_ref(), + &band_scale, + band_width, + self.tick_margin, + cx.theme().muted_foreground, + ); + let (side, align) = if matches!(alignment, BarAlignment::Left) { + (AxisLabelSide::Start, TextAlign::Right) + } else { + (AxisLabelSide::End, TextAlign::Left) + }; + axis = axis + .y(zero_pixel) + .y_label_side(side) + .y_label(labels.into_iter().map(|t| t.align(align))); + } + } + } + axis.paint(&plot_bounds, window, cx); + + // Far edge of the value axis in pixel space (opposite the baseline). + let far = match alignment { + BarAlignment::Bottom => 10., + BarAlignment::Top => value_dim - 10., + BarAlignment::Left => value_dim - value_end_gap, + BarAlignment::Right => value_end_gap, + }; + + let steps = self.value_tick_count; + let value_ticks = value_tick_positions(far, baseline, steps); + + // Draw grid, excluding the line at the baseline. + if self.grid { + let grid = Grid::new() + .stroke(cx.theme().border) + .dash_array(&[px(4.), px(2.)]); + let lines = value_ticks[..steps].to_vec(); + let grid = if is_horizontal { + grid.x(lines) + } else { + grid.y(lines) + }; + grid.paint(&plot_bounds, window); + } + + if self.value_axis { + // Ticks run from `far` (the domain maximum) to `baseline` (the minimum), + // so the labels walk the domain in the same direction. + let labels = value_ticks.iter().enumerate().map(|(i, &tick)| { + let value = domain_hi - (domain_hi - domain_lo) * i as f32 / steps as f32; + AxisText::new(format_tick(value), px(tick), cx.theme().muted_foreground) + }); + + // The labels go in the gap `band_scale` kept clear for them, right-aligned + // against the plot area for vertical bars and centred under it otherwise. + let value_axis = if is_horizontal { + PlotAxis::new() + .x_axis(false) + .x(px(total_height - VALUE_AXIS_GAP)) + .x_label(labels.map(|t| t.align(TextAlign::Center))) + } else { + PlotAxis::new() + .y_axis(false) + .y(px(VALUE_AXIS_GAP - TEXT_GAP * 2.)) + .y_label(labels.map(|t| t.align(TextAlign::Right))) + }; + value_axis.paint(&bounds, window, cx); + } + + // Draw bars. + let band_fn_cloned = band_fn.clone(); + let value_fn_cloned = value_fn.clone(); + let default_fill: Background = cx.theme().chart_2.into(); let fill = self.fill.clone(); + let fill_gradient = self.fill_gradient.clone(); let label_color = cx.theme().foreground; + + // Chart bounds in pixel space, with origin (0, 0) and size equal to + // the full chart extent. Passed to user `fill` closures so they can + // position chart-wide backgrounds (gradients, patterns, etc.). + let chart_bounds: Bounds = Bounds { + origin: Point::new(0., 0.), + size: Size::new(total_width, total_height), + }; + + // Chart data range in f32 — passed to `fill_gradient` callers and used + // by the `chart_to_bar` remap helper. + let chart_range = { + let mut lo = 0.0_f32; + let mut hi = 0.0_f32; + for v in &self.data { + if let Some(f) = value_fn(v).to_f32() { + lo = lo.min(f); + hi = hi.max(f); + } + } + lo..=hi + }; + let mut bar = Bar::new() .data(&self.data) + .alignment(alignment) .band_width(band_width) - .x(move |d| x.tick(&x_fn(d))) - .y0(move |_| height) - .y1(move |d| y.tick(&y_fn(d))) - .fill(move |d| fill.as_ref().map(|f| f(d)).unwrap_or(default_fill)); + .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset)) + .base(move |_| zero_pixel) + .value(move |d| value_scale.tick(&value_fn_cloned(d))) + .corner_radii(self.corner_radii); + + bar = match (fill, fill_gradient) { + (_, Some(fg)) => { + let value_fn_for_grad = value_fn.clone(); + bar.fill(move |d, _frame, alignment| { + let v = value_fn_for_grad(d).to_f32().unwrap_or(0.); + let base_v = 0.0_f32; + let bar_lo = base_v.min(v); + let bar_hi = base_v.max(v); + let bar_span = (bar_hi - bar_lo).max(f32::EPSILON); + let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span; + let stops = fg(d, chart_range.clone(), &chart_to_bar); + let [s0, s1] = clip_stops_to_bar(stops); + let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1); + bg + }) + } + (Some(f), _) => { + bar.fill(move |d, frame, alignment| f(d, frame, chart_bounds, alignment)) + } + _ => bar.fill(move |_, _, _| default_fill), + }; if let Some(label) = self.label.as_ref() { let label = label.clone(); - bar = bar.label(move |d, p| { - vec![Text::new(label(d), p, label_color).align(TextAlign::Center)] - }); + let text_align = match alignment { + BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center, + BarAlignment::Left => TextAlign::Left, + BarAlignment::Right => TextAlign::Right, + }; + bar = + bar.label(move |d, p| vec![Text::new(label(d), p, label_color).align(text_align)]); } bar.paint(&bounds, window, cx); } + + fn id(&self) -> Option { + self.id.clone() + } + + fn tooltip_state( + &self, + position: Point, + bounds: Bounds, + _cx: &App, + ) -> Option { + let band_fn = self.band.as_ref()?; + self.value.as_ref()?; + + // Only the band scale is needed to hit-test which bar is hovered, so no text + // measurement (and thus no `window`) is required. + let is_horizontal = self.alignment.is_horizontal(); + let band_scale = self.band_scale(bounds)?; + let band_width = band_scale.band_width(); + + let band_offset = self.band_offset(); + let cursor_band = if is_horizontal { + position.y + } else { + position.x + }; + let index = band_scale.least_index(cursor_band.as_f32() - band_offset); + let d = self.data.get(index)?; + let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.; + + // Vertical bars: vertical crosshair at the bar's x. Horizontal bars: horizontal + // crosshair at the bar's y. The box tracks the cursor either way. + let cross_line = if is_horizontal { + point(position.x, px(center)) + } else { + point(px(center), position.y) + }; + + Some(TooltipState::new(index, cross_line, vec![])) + } + + fn tooltip( + &self, + state: &TooltipState, + cursor: Point, + bounds: Bounds, + window: &mut Window, + cx: &mut App, + ) -> Option { + let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?); + let d = self.data.get(state.index)?; + let title: SharedString = band_fn(d).into(); + let value = value_fn(d).to_f64()?; + let name = self.name.clone().unwrap_or_default(); + + // Highlight the hovered bar with a translucent band the width of the bar, instead + // of a hairline. Confined to the plot area so it doesn't cover the axis labels. + let band_width = self.band_scale(bounds)?.band_width(); + let cross_line = if self.alignment.is_horizontal() { + let (band_gap, value_end_gap) = self.horizontal_gaps(window); + let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.); + let start = if matches!(self.alignment, BarAlignment::Left) { + band_gap + } else { + value_end_gap + }; + // Skip the tooltip when the cursor is over the axis labels, not a bar. + let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP; + if cursor.x.as_f32() < start + || cursor.x.as_f32() > start + length + || (self.value_axis && cursor.y.as_f32() > value_labels_top) + { + return None; + } + CrossLine::new(state.cross_line) + .horizontal() + .h_span(start, length) + .band(px(band_width)) + } else { + let axis_gap = if self.label_axis { AXIS_GAP } else { 0. }; + let length = bounds.size.height.as_f32() - axis_gap; + let start = if matches!(self.alignment, BarAlignment::Top) { + axis_gap + } else { + 0. + }; + // Skip the tooltip when the cursor is over the axis labels, not a bar. + if cursor.y.as_f32() < start + || cursor.y.as_f32() > start + length + || cursor.x.as_f32() < self.band_offset() + { + return None; + } + CrossLine::new(state.cross_line) + .span(start, length) + .band(px(band_width)) + }; + + Some( + // Follow the cursor; the highlight band stays snapped to the bar. + Tooltip::new(cursor, bounds.size) + .gap(px(8.)) + .cross_line(cross_line) + .title(title) + .row(cx.theme().chart_2, name, format!("{}", value)) + .into_any_element(), + ) + } +} + +/// Clip a two-stop gradient to bar-local `[0, 1]`, interpolating colors at the +/// clip points so the on-bar gradient matches the (possibly broader) gradient +/// the caller defined. +/// +/// When a stop position falls outside `[0, 1]` (e.g. because `chart_to_bar` +/// returned a value past the bar's edge for a chart-relative gradient), +/// gpui's renderer would clamp the position and lose the gradient effect. +/// This function instead replaces such a stop with the color sampled along +/// the line through both stops at position `0.0` or `1.0`, preserving the +/// visual slice. +fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] { + let [a, b] = stops; + let p0 = a.percentage; + let p1 = b.percentage; + let lerp = |t: f32| -> Hsla { + Hsla { + h: a.color.h + (b.color.h - a.color.h) * t, + s: a.color.s + (b.color.s - a.color.s) * t, + l: a.color.l + (b.color.l - a.color.l) * t, + a: a.color.a + (b.color.a - a.color.a) * t, + } + }; + let span = p1 - p0; + let sample = |target: f32| -> Hsla { + if span.abs() < f32::EPSILON { + a.color + } else { + lerp((target - p0) / span) + } + }; + let new_a = if (0. ..=1.).contains(&p0) { + a + } else { + LinearColorStop { + color: sample(p0.clamp(0., 1.)), + percentage: p0.clamp(0., 1.), + } + }; + let new_b = if (0. ..=1.).contains(&p1) { + b + } else { + LinearColorStop { + color: sample(p1.clamp(0., 1.)), + percentage: p1.clamp(0., 1.), + } + }; + [new_a, new_b] +} + +/// Format a tick value for display on the value axis. +fn format_tick(v: f32) -> String { + if (v - v.round()).abs() < 0.001 { + format!("{:.0}", v) + } else { + format!("{:.1}", v) + } +} + +/// Whether a vertical bar's category label belongs below the zero line. +/// +/// A bar grows away from the zero line, so its label goes on the side the bar +/// leaves empty. Which side that is flips with both the sign of the value and the +/// alignment. A zero-length bar counts as positive, which puts its label in the +/// axis gap rather than inside the plot. +fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool { + (value < 0.) == (alignment == BarAlignment::Top) +} + +/// Tick positions along the value axis, dividing it into `steps` even intervals. +/// +/// Runs from `far` (the value domain's maximum) through `baseline` (its minimum) +/// inclusive, so the result holds `steps + 1` positions and the last one is the +/// baseline. +fn value_tick_positions(far: f32, baseline: f32, steps: usize) -> Vec { + (0..=steps) + .map(|i| far + (baseline - far) * i as f32 / steps as f32) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_label_below_zero_line() { + // Bottom-aligned: positive bars grow up, leaving the space below free. + assert!(label_below_zero_line(5., BarAlignment::Bottom)); + assert!(label_below_zero_line(0., BarAlignment::Bottom)); + assert!(!label_below_zero_line(-5., BarAlignment::Bottom)); + + // Top-aligned bars grow the other way, so the sides swap. + assert!(!label_below_zero_line(5., BarAlignment::Top)); + assert!(!label_below_zero_line(0., BarAlignment::Top)); + assert!(label_below_zero_line(-5., BarAlignment::Top)); + } + + #[test] + fn test_value_tick_positions() { + // Both ends are included, so 4 intervals means 5 positions. + assert_eq!( + value_tick_positions(10., 110., 4), + vec![10., 35., 60., 85., 110.] + ); + + // Top-aligned charts have the baseline before the far edge. + assert_eq!(value_tick_positions(110., 10., 2), vec![110., 60., 10.]); + + assert_eq!(value_tick_positions(0., 50., 1), vec![0., 50.]); + } } diff --git a/vendor/gpui-component/src/chart/candlestick_chart.rs b/vendor/gpui-component/src/chart/candlestick_chart.rs index 7e362016..8a177f18 100644 --- a/vendor/gpui-component/src/chart/candlestick_chart.rs +++ b/vendor/gpui-component/src/chart/candlestick_chart.rs @@ -1,17 +1,19 @@ use std::rc::Rc; -use gpui::{App, Bounds, Hsla, PathBuilder, Pixels, SharedString, TextAlign, Window, fill, px}; +use gpui::{App, Bounds, Hsla, PathBuilder, Pixels, SharedString, Window, fill, px}; use gpui_component_macros::IntoPlot; use num_traits::{Num, ToPrimitive}; use crate::{ - ActiveTheme, PixelsExt, + ActiveTheme, plot::{ - AXIS_GAP, AxisText, Grid, Plot, PlotAxis, origin_point, + AXIS_GAP, Grid, Plot, PlotAxis, origin_point, scale::{Scale, ScaleBand, ScaleLinear, Sealed}, }, }; +use super::build_band_labels; + #[derive(IntoPlot)] pub struct CandlestickChart where @@ -27,6 +29,8 @@ where close: Option Y>>, tick_margin: usize, body_width_ratio: f32, + x_axis: bool, + grid: bool, } impl CandlestickChart @@ -47,6 +51,8 @@ where close: None, tick_margin: 1, body_width_ratio: 0.8, + x_axis: true, + grid: true, } } @@ -84,6 +90,19 @@ where self.body_width_ratio = ratio; self } + + /// Show or hide the x-axis line and labels. + /// + /// Default is true. + pub fn x_axis(mut self, x_axis: bool) -> Self { + self.x_axis = x_axis; + self + } + + pub fn grid(mut self, grid: bool) -> Self { + self.grid = grid; + self + } } impl Plot for CandlestickChart @@ -103,7 +122,8 @@ where }; let width = bounds.size.width.as_f32(); - let height = bounds.size.height.as_f32() - AXIS_GAP; + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + let height = bounds.size.height.as_f32() - axis_gap; // X scale let x = ScaleBand::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]) @@ -120,33 +140,28 @@ where let y = ScaleLinear::new(all_values, vec![height, 10.]); // Draw X axis - let x_label = self.data.iter().enumerate().filter_map(|(i, d)| { - if (i + 1) % self.tick_margin == 0 { - x.tick(&x_fn(d)).map(|x_tick| { - AxisText::new( - x_fn(d).into(), - x_tick + band_width / 2., - cx.theme().muted_foreground, - ) - .align(TextAlign::Center) - }) - } else { - None - } - }); - - PlotAxis::new() - .x(height) - .x_label(x_label) - .stroke(cx.theme().border) - .paint(&bounds, window, cx); + let mut axis = PlotAxis::new().stroke(cx.theme().border); + if self.x_axis { + let labels = build_band_labels( + &self.data, + x_fn.as_ref(), + &x, + band_width, + self.tick_margin, + cx.theme().muted_foreground, + ); + axis = axis.x(height).x_label(labels); + } + axis.paint(&bounds, window, cx); // Draw grid - Grid::new() - .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) - .stroke(cx.theme().border) - .dash_array(&[px(4.), px(2.)]) - .paint(&bounds, window); + if self.grid { + Grid::new() + .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) + .stroke(cx.theme().border) + .dash_array(&[px(4.), px(2.)]) + .paint(&bounds, window); + } // Draw candlesticks let origin = bounds.origin; @@ -183,9 +198,9 @@ where // Determine if bullish (close > open) or bearish (close < open) let is_bullish = close > open; let color: Hsla = if is_bullish { - cx.theme().bullish + cx.theme().chart_bullish } else { - cx.theme().bearish + cx.theme().chart_bearish }; // Calculate candlestick body dimensions diff --git a/vendor/gpui-component/src/chart/line_chart.rs b/vendor/gpui-component/src/chart/line_chart.rs index 15de4c4a..e4174dea 100644 --- a/vendor/gpui-component/src/chart/line_chart.rs +++ b/vendor/gpui-component/src/chart/line_chart.rs @@ -1,18 +1,24 @@ use std::rc::Rc; -use gpui::{px, App, Bounds, Hsla, Pixels, SharedString, TextAlign, Window}; +use gpui::{ + AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Window, + point, px, +}; use gpui_component_macros::IntoPlot; use num_traits::{Num, ToPrimitive}; use crate::{ + ActiveTheme, plot::{ + AXIS_GAP, Grid, Plot, PlotAxis, StrokeStyle, scale::{Scale, ScaleLinear, ScalePoint, Sealed}, shape::Line, - AxisText, Grid, Plot, PlotAxis, StrokeStyle, AXIS_GAP, + tooltip::{CrossLine, Dot, Tooltip, TooltipState}, }, - ActiveTheme, PixelsExt, }; +use super::build_point_x_labels; + #[derive(IntoPlot)] pub struct LineChart where @@ -27,6 +33,10 @@ where stroke_style: StrokeStyle, dot: bool, tick_margin: usize, + x_axis: bool, + grid: bool, + id: Option, + name: Option, } impl LineChart @@ -46,9 +56,28 @@ where x: None, y: None, tick_margin: 1, + x_axis: true, + grid: true, + id: None, + name: None, } } + /// Enable an interactive hover tooltip (with crosshair and a data dot) for this chart. + /// + /// The `id` must be unique among sibling elements. Without it, the chart stays a + /// non-interactive plot. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self { self.x = Some(Rc::new(x)); self @@ -88,24 +117,32 @@ where self.tick_margin = tick_margin; self } -} -impl Plot for LineChart -where - X: PartialEq + Into + 'static, - Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, -{ - fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { - let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else { - return; - }; + /// Show or hide the x-axis line and labels. + /// + /// Default is true. + pub fn x_axis(mut self, x_axis: bool) -> Self { + self.x_axis = x_axis; + self + } + + pub fn grid(mut self, grid: bool) -> Self { + self.grid = grid; + self + } + + /// Build the x (point) and y (linear) scales for the given bounds. + /// + /// Shared by `paint` and `tooltip_state` so the two stay in sync. Returns `None` when the + /// x/y accessors have not been set. + fn scales(&self, bounds: Bounds) -> Option<(ScalePoint, ScaleLinear)> { + let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?); let width = bounds.size.width.as_f32(); - let height = bounds.size.height.as_f32() - AXIS_GAP; + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + let height = bounds.size.height.as_f32() - axis_gap; - // X scale let x = ScalePoint::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]); - // Y scale, ensure start from 0. let y = ScaleLinear::new( self.data @@ -116,41 +153,48 @@ where vec![height, 10.], ); + Some((x, y)) + } +} + +impl Plot for LineChart +where + X: PartialEq + Into + 'static, + Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else { + return; + }; + let Some((x, y)) = self.scales(bounds) else { + return; + }; + + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + let height = bounds.size.height.as_f32() - axis_gap; + // Draw X axis - let data_len = self.data.len(); - let x_label = self.data.iter().enumerate().filter_map(|(i, d)| { - if (i + 1) % self.tick_margin == 0 { - x.tick(&x_fn(d)).map(|x_tick| { - let align = match i { - 0 => { - if data_len == 1 { - TextAlign::Center - } else { - TextAlign::Left - } - } - i if i == data_len - 1 => TextAlign::Right, - _ => TextAlign::Center, - }; - AxisText::new(x_fn(d).into(), x_tick, cx.theme().muted_foreground).align(align) - }) - } else { - None - } - }); - - PlotAxis::new() - .x(height) - .x_label(x_label) - .stroke(cx.theme().border) - .paint(&bounds, window, cx); + let mut axis = PlotAxis::new().stroke(cx.theme().border); + if self.x_axis { + let labels = build_point_x_labels( + &self.data, + x_fn.as_ref(), + &x, + self.tick_margin, + cx.theme().muted_foreground, + ); + axis = axis.x(height).x_label(labels); + } + axis.paint(&bounds, window, cx); // Draw grid - Grid::new() - .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) - .stroke(cx.theme().border) - .dash_array(&[px(4.), px(2.)]) - .paint(&bounds, window); + if self.grid { + Grid::new() + .y((0..=3).map(|i| height * i as f32 / 4.0).collect()) + .stroke(cx.theme().border) + .dash_array(&[px(4.), px(2.)]) + .paint(&bounds, window); + } // Draw line let stroke = self.stroke.unwrap_or(cx.theme().chart_2); @@ -170,4 +214,72 @@ where line.paint(&bounds, window); } + + fn id(&self) -> Option { + self.id.clone() + } + + fn tooltip_state( + &self, + position: Point, + bounds: Bounds, + _cx: &App, + ) -> Option { + let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?); + let (x, y) = self.scales(bounds)?; + + // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip. + let axis_gap = if self.x_axis { AXIS_GAP } else { 0. }; + if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap { + return None; + } + + let index = x.least_index(position.x.as_f32()); + let d = self.data.get(index)?; + let x_tick = x.tick(&x_fn(d))?; + let y_tick = y.tick(&y_fn(d))?; + + Some(TooltipState::new( + index, + point(px(x_tick), position.y), + vec![point(px(x_tick), px(y_tick))], + )) + } + + fn tooltip( + &self, + state: &TooltipState, + cursor: Point, + bounds: Bounds, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?); + let d = self.data.get(state.index)?; + let title: SharedString = x_fn(d).into(); + let value = y_fn(d).to_f64()?; + let stroke = self.stroke.unwrap_or(cx.theme().chart_2); + let name = self.name.clone().unwrap_or_default(); + + Some( + // Follow the cursor; the crosshair and dot stay snapped to the data point. + Tooltip::new(cursor, bounds.size) + .gap(px(8.)) + // Confine the crosshair to the plot area so it doesn't cross the x-axis. + .cross_line( + CrossLine::new(state.cross_line).height( + bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }, + ), + ) + .dots( + state + .dots + .iter() + .map(|p| Dot::new(*p).stroke(cx.theme().background).fill(stroke)), + ) + .title(title) + .row(stroke, name, format!("{}", value)) + .into_any_element(), + ) + } } diff --git a/vendor/gpui-component/src/chart/mod.rs b/vendor/gpui-component/src/chart/mod.rs index bcff4c21..446fb1ad 100644 --- a/vendor/gpui-component/src/chart/mod.rs +++ b/vendor/gpui-component/src/chart/mod.rs @@ -3,9 +3,87 @@ mod bar_chart; mod candlestick_chart; mod line_chart; mod pie_chart; +mod radar_chart; +mod sankey_chart; pub use area_chart::AreaChart; pub use bar_chart::BarChart; pub use candlestick_chart::CandlestickChart; pub use line_chart::LineChart; pub use pie_chart::PieChart; +pub use radar_chart::{RadarChart, RadarLabel}; +pub use sankey_chart::{SankeyChart, SankeyLabel}; + +use gpui::{Hsla, SharedString, TextAlign}; + +use crate::plot::{ + AxisText, + scale::{Scale, ScaleBand, ScalePoint}, +}; + +/// Build x-axis labels for point-based scales (`LineChart`, `AreaChart`). +/// +/// Point scales place items at evenly spaced positions. The first label is +/// left-aligned, the last is right-aligned, and the rest are centered. +pub(crate) fn build_point_x_labels( + data: &[T], + x_fn: &dyn Fn(&T) -> X, + x_scale: &ScalePoint, + tick_margin: usize, + color: Hsla, +) -> Vec +where + X: PartialEq + Into, +{ + let data_len = data.len(); + data.iter() + .enumerate() + .filter_map(|(i, d)| { + if (i + 1) % tick_margin != 0 { + return None; + } + x_scale.tick(&x_fn(d)).map(|x_tick| { + let align = match i { + 0 if data_len == 1 => TextAlign::Center, + 0 => TextAlign::Left, + i if i == data_len - 1 => TextAlign::Right, + _ => TextAlign::Center, + }; + // Call x_fn again to get an owned value for the label text. + AxisText::new(x_fn(d).into(), x_tick, color).align(align) + }) + }) + .collect() +} + +/// Build axis labels for band-based scales (`BarChart`, `CandlestickChart`). +/// +/// Band scales place items in evenly sized bands. The returned `tick` +/// coordinate is the centre of each band along the band axis; the caller +/// decides whether to feed the result to `PlotAxis::x_label` (vertical +/// charts) or `PlotAxis::y_label` (horizontal charts). +pub(crate) fn build_band_labels( + data: &[T], + x_fn: &dyn Fn(&T) -> X, + x_scale: &ScaleBand, + band_width: f32, + tick_margin: usize, + color: Hsla, +) -> Vec +where + X: PartialEq + Into, +{ + data.iter() + .enumerate() + .filter_map(|(i, d)| { + if (i + 1) % tick_margin != 0 { + return None; + } + x_scale.tick(&x_fn(d)).map(|x_tick| { + // Call x_fn again to get an owned value for the label text. + AxisText::new(x_fn(d).into(), x_tick + band_width / 2., color) + .align(TextAlign::Center) + }) + }) + .collect() +} diff --git a/vendor/gpui-component/src/chart/pie_chart.rs b/vendor/gpui-component/src/chart/pie_chart.rs index 1694f155..9710d481 100644 --- a/vendor/gpui-component/src/chart/pie_chart.rs +++ b/vendor/gpui-component/src/chart/pie_chart.rs @@ -1,17 +1,22 @@ use std::rc::Rc; -use gpui::{App, Bounds, Hsla, Pixels, Window}; +use gpui::{App, Bounds, Hsla, Pixels, SharedString, TextAlign, Window, point}; use gpui_component_macros::IntoPlot; use num_traits::Zero; use crate::{ + ActiveTheme, plot::{ - shape::{Arc, ArcData, Pie}, Plot, + label::{PlotLabel, TEXT_HEIGHT, TEXT_SIZE, Text}, + polygon, + shape::{Arc, ArcData, Pie}, }, - ActiveTheme, PixelsExt, }; +/// The default extra gap (in pixels) between `outer_radius` and the label radius. +const DEFAULT_LABEL_GAP: f32 = 15.; + #[derive(IntoPlot)] pub struct PieChart { data: Vec, @@ -22,6 +27,10 @@ pub struct PieChart { pad_angle: f32, value: Option f32>>, color: Option Hsla>>, + label: Option SharedString + 'static>>, + label_line_color: Option Hsla + 'static>>, + label_color: Option, + label_gap: f32, } impl PieChart { @@ -38,6 +47,10 @@ impl PieChart { pad_angle: 0., value: None, color: None, + label: None, + label_line_color: None, + label_color: None, + label_gap: DEFAULT_LABEL_GAP, } } @@ -106,6 +119,34 @@ impl PieChart { self.color = Some(Rc::new(move |t| color(t).into())); self } + + /// Set the label text for each slice. + /// + /// Once set, a "leader line + text" is drawn outside the ring for every + /// slice. + pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self { + self.label = Some(Rc::new(label)); + self + } + + /// Set the leader line color per slice (defaults to `cx.theme().border`). + pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self { + self.label_line_color = Some(Rc::new(color)); + self + } + + /// Set the label text color (defaults to `cx.theme().foreground`). + pub fn label_color(mut self, color: Hsla) -> Self { + self.label_color = Some(color); + self + } + + /// Set the extra gap between `outer_radius` and the label radius + /// (defaults to 15px). + pub fn label_gap(mut self, gap: f32) -> Self { + self.label_gap = gap; + self + } } impl Plot for PieChart { @@ -144,5 +185,145 @@ impl Plot for PieChart { window, ); } + + // Draw leader-line labels outside the ring (only when `label` is set). + let Some(label_fn) = self.label.as_ref() else { + return; + }; + + let label_radius = outer_radius + self.label_gap; + let center_x = bounds.size.width.as_f32() / 2.; + let center_y = bounds.size.height.as_f32() / 2.; + let label_arc = Arc::new() + .inner_radius(label_radius) + .outer_radius(label_radius); + let edge_arc = Arc::new() + .inner_radius(outer_radius) + .outer_radius(outer_radius); + + let label_color = self.label_color.unwrap_or(cx.theme().foreground); + let default_line_color = cx.theme().border; + + // First pass: collect a layout candidate per visible slice, split by + // side. `y` is the target vertical position relative to the center and + // gets adjusted later to remove overlaps. + let mut right: Vec = vec![]; + let mut left: Vec = vec![]; + for a in &arcs { + // Skip tiny slices (< 0.5°) that are too thin to label. + if a.end_angle - a.start_angle < std::f32::consts::PI / 360. { + continue; + } + + let centroid = label_arc.centroid(a); + let edge = edge_arc.centroid(a); + let is_right = centroid.x > 0.; + let line_color = self + .label_line_color + .as_ref() + .map(|f| f(a.data)) + .unwrap_or(default_line_color); + + let layout = LabelLayout { + arc_x: edge.x, + arc_y: edge.y, + label_x: centroid.x, + y: centroid.y, + text: label_fn(a.data), + line_color, + }; + if is_right { &mut right } else { &mut left }.push(layout); + } + + // Second pass: spread labels on each side so neighbors keep at least one + // text height apart, clamped within the vertical bounds. + let top = -center_y + TEXT_HEIGHT / 2.; + let bottom = center_y - TEXT_HEIGHT / 2.; + spread_labels(&mut right, top, bottom); + spread_labels(&mut left, top, bottom); + + // Third pass: paint leader lines first, then the text on top. + let mut labels = vec![]; + for (side, items) in [(1., &right), (-1., &left)] { + for item in items { + // Leader line: ring edge -> label anchor -> horizontal pull to + // ±label_radius. + let pts = [ + point(item.arc_x + center_x, item.arc_y + center_y), + point(item.label_x + center_x, item.y + center_y), + point(side * label_radius + center_x, item.y + center_y), + ]; + if let Some(p) = polygon(&pts, &bounds) { + window.paint_path(p, item.line_color); + } + + // Text sits 4px further out, aligned by side. + let origin = point( + side * (label_radius + 4.) + center_x, + item.y - TEXT_SIZE / 2. + center_y, + ); + let align = if side > 0. { + TextAlign::Left + } else { + TextAlign::Right + }; + labels.push(Text::new(item.text.clone(), origin, label_color).align(align)); + } + } + + PlotLabel::new(labels).paint(&bounds, window, cx); + } +} + +/// A resolved label position before overlap adjustment. +struct LabelLayout { + /// Anchor on the ring edge (relative to center). + arc_x: f32, + arc_y: f32, + /// Centroid x at the label radius (relative to center). + label_x: f32, + /// Target/adjusted vertical position (relative to center). + y: f32, + text: SharedString, + line_color: Hsla, +} + +/// Spread `items` vertically so that adjacent labels keep at least +/// [`TEXT_HEIGHT`] apart, clamped within `[top, bottom]`. +/// +/// Uses a two-direction relaxation: a top-down pass pushes crowded labels down, +/// then a bottom-up pass (anchored at `bottom`) pushes them back up. This +/// resolves cascading overlaps that a single-neighbor nudge cannot. +fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) { + let n = items.len(); + if n == 0 { + return; + } + + // Sort by target position so neighbors in the slice are neighbors in y. + items.sort_by(|a, b| a.y.total_cmp(&b.y)); + + // Top-down: enforce the minimum gap by pushing labels down. + for i in 1..n { + let min_y = items[i - 1].y + TEXT_HEIGHT; + if items[i].y < min_y { + items[i].y = min_y; + } + } + + // Bottom-up: clamp the bottom-most label, then pull overflowing labels up. + if items[n - 1].y > bottom { + items[n - 1].y = bottom; + } + for i in (0..n - 1).rev() { + let max_y = items[i + 1].y - TEXT_HEIGHT; + if items[i].y > max_y { + items[i].y = max_y; + } + } + + // Keep the top-most label within bounds. + if items[0].y < top { + items[0].y = top; } } diff --git a/vendor/gpui-component/src/chart/radar_chart.rs b/vendor/gpui-component/src/chart/radar_chart.rs new file mode 100644 index 00000000..cfb51603 --- /dev/null +++ b/vendor/gpui-component/src/chart/radar_chart.rs @@ -0,0 +1,700 @@ +use std::{ + f32::consts::{PI, TAU}, + rc::Rc, +}; + +use gpui::{ + AnyElement, App, AvailableSpace, Background, Bounds, ElementId, Hsla, IntoElement, Pixels, + Point, SharedString, TextAlign, Window, point, px, +}; +use gpui_component_macros::IntoPlot; +use num_traits::{Num, ToPrimitive, Zero}; + +use crate::{ + ActiveTheme, + plot::{ + Plot, + label::{PlotLabel, TEXT_SIZE, Text}, + polygon, + scale::{Scale, ScaleLinear, Sealed}, + shape::RadialLine, + tooltip::{Dot, Tooltip, TooltipState}, + }, +}; + +const HALF_PI: f32 = PI / 2.; + +/// The default extra gap (in pixels) between the outer grid ring and the labels. +const DEFAULT_LABEL_GAP: f32 = 10.; + +/// The default number of concentric grid rings. +const DEFAULT_GRID_LEVELS: usize = 4; + +/// The label of one radar dimension, returned by [`RadarChart::label`]. +pub enum RadarLabel { + /// Plain text, drawn by the plot's own text layer: honors + /// [`RadarChart::label_color`] and supplies the tooltip title. + Text(SharedString), + /// A custom element, measured at its natural size and anchored like a text + /// label. [`RadarChart::label_color`] does not apply, and it supplies no + /// tooltip title. + Element(AnyElement), +} + +impl From<&'static str> for RadarLabel { + fn from(text: &'static str) -> Self { + Self::Text(text.into()) + } +} + +impl From for RadarLabel { + fn from(text: String) -> Self { + Self::Text(text.into()) + } +} + +impl From for RadarLabel { + fn from(text: SharedString) -> Self { + Self::Text(text) + } +} + +impl From for RadarLabel { + fn from(element: AnyElement) -> Self { + Self::Element(element) + } +} + +/// A radar (spider) chart. +/// +/// Each datum is one dimension (a spoke), placed clockwise around the center +/// starting at 12 o'clock. Add one series per [`RadarChart::value`] call; each +/// series is drawn as a closed polygon connecting its values on every spoke. +#[derive(IntoPlot)] +pub struct RadarChart +where + T: 'static, + Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + data: Vec, + values: Vec Y>>, + strokes: Vec, + fills: Vec, + names: Vec, + label: Option RadarLabel + 'static>>, + /// The text of each dimension's label, resolved once per frame in `prepaint`; + /// element labels leave `None`. Read by `paint` (to draw them) and `tooltip` + /// (as the title), so the label closure runs once per dimension per frame. + label_texts: Vec>, + label_color: Option, + label_gap: f32, + max_value: Option, + outer_radius: f32, + grid: bool, + grid_levels: usize, + dot: bool, + id: Option, +} + +impl RadarChart +where + Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + pub fn new(data: I) -> Self + where + I: IntoIterator, + { + Self { + data: data.into_iter().collect(), + values: vec![], + strokes: vec![], + fills: vec![], + names: vec![], + label: None, + label_texts: vec![], + label_color: None, + label_gap: DEFAULT_LABEL_GAP, + max_value: None, + outer_radius: 0., + grid: true, + grid_levels: DEFAULT_GRID_LEVELS, + dot: false, + id: None, + } + } + + /// Enable an interactive hover tooltip (a dot and row per series at the + /// hovered dimension). + /// + /// The `id` must be unique among sibling elements. Without it, the chart + /// stays a non-interactive plot. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Set the name of the most recently added series, shown in its tooltip row. + /// + /// Call after the matching [`RadarChart::value`] + /// (e.g. `.value(..).stroke(..).name("Desktop")`). + pub fn name(mut self, name: impl Into) -> Self { + self.names.push(name.into()); + self + } + + /// Add a series to the radar chart. + /// + /// Call multiple times to overlay multiple series, each paired with the + /// matching [`RadarChart::stroke`] and [`RadarChart::fill`] calls. + pub fn value(mut self, value: impl Fn(&T) -> Y + 'static) -> Self { + self.values.push(Rc::new(value)); + self + } + + /// Set the stroke color of the most recently added series. + /// + /// Defaults to the theme chart colors, cycled per series. + pub fn stroke(mut self, stroke: impl Into) -> Self { + self.strokes.push(stroke.into()); + self + } + + /// Set the fill color of the most recently added series. + /// + /// Defaults to the series stroke color with 0.3 opacity. + pub fn fill(mut self, fill: impl Into) -> Self { + self.fills.push(fill.into()); + self + } + + /// Set the label for each dimension, shown outside the outer ring. + /// + /// Return a string for a plain text label, or `element.into_any_element()` + /// for a custom one; see [`RadarLabel`] for how the two differ. + /// + /// ```ignore + /// RadarChart::new(data).label(|d| d.month.clone()) + /// + /// RadarChart::new(data).label(|d| { + /// v_flex() + /// .items_center() + /// .child(Icon::new(IconName::Star).xsmall()) + /// .child(d.month.clone()) + /// .into_any_element() + /// }) + /// ``` + pub fn label(mut self, label: impl Fn(&T) -> L + 'static) -> Self + where + L: Into + 'static, + { + self.label = Some(Rc::new(move |d| label(d).into())); + self + } + + /// Set the text label color (defaults to `cx.theme().muted_foreground`). + /// + /// Element labels style themselves; this does not apply to them. + pub fn label_color(mut self, color: impl Into) -> Self { + self.label_color = Some(color.into()); + self + } + + /// Set the extra gap between the outer ring and the labels + /// (defaults to 10px). + pub fn label_gap(mut self, gap: f32) -> Self { + self.label_gap = gap; + self + } + + /// Set the value at the outer ring. + /// + /// Defaults to the maximum value across all series. + pub fn max_value(mut self, max_value: Y) -> Self { + self.max_value = Some(max_value); + self + } + + /// Set the outer radius of the radar chart. + /// + /// Defaults to 40% of the bounds height. + pub fn outer_radius(mut self, outer_radius: f32) -> Self { + self.outer_radius = outer_radius; + self + } + + /// Show or hide the grid rings and spokes. + /// + /// Default is true. + pub fn grid(mut self, grid: bool) -> Self { + self.grid = grid; + self + } + + /// Set the number of concentric grid rings (defaults to 4). + pub fn grid_levels(mut self, grid_levels: usize) -> Self { + self.grid_levels = grid_levels.max(1); + self + } + + /// Show dots on the vertices of each series. + pub fn dot(mut self) -> Self { + self.dot = true; + self + } + + /// The stroke color of the series at the given index, set or default. + /// + /// Defaults to the theme chart colors, cycled per series. + fn series_stroke(&self, ix: usize, cx: &App) -> Hsla { + let colors = [ + cx.theme().chart_1, + cx.theme().chart_2, + cx.theme().chart_3, + cx.theme().chart_4, + cx.theme().chart_5, + ]; + + self.strokes + .get(ix) + .copied() + .unwrap_or(colors[ix % colors.len()]) + } + + /// The resolved outer radius for the given bounds. + fn resolve_outer_radius(&self, bounds: &Bounds) -> f32 { + if self.outer_radius.is_zero() { + bounds.size.height.as_f32() * 0.4 + } else { + self.outer_radius + } + } + + /// Where the label of dimension `ix` attaches, in bounds-relative coordinates, + /// plus the outward radial direction at that dimension (a unit vector). + /// + /// The anchor sits on the label ring at the dimension's angle; callers offset + /// their own box from it along `direction`. Shared by `prepaint` and `paint` + /// so element and text labels land in the same place. + fn label_anchor( + &self, + ix: usize, + outer_radius: f32, + bounds: &Bounds, + ) -> (Point, Point) { + let label_radius = outer_radius + self.label_gap; + let angle = ix as f32 * TAU / self.data.len() as f32 - HALF_PI; + let direction = point(angle.cos(), angle.sin()); + + let anchor = point( + bounds.size.width.as_f32() / 2. + label_radius * direction.x, + bounds.size.height.as_f32() / 2. + label_radius * direction.y, + ); + + (anchor, direction) + } + + /// Build the radius scale from the center to the outer ring. + /// + /// The domain includes zero so non-negative data starts at the center. + /// Shared by `paint` and `tooltip_state` so the two stay in sync. + fn scale(&self, outer_radius: f32) -> ScaleLinear { + let domain = if let Some(max_value) = self.max_value { + vec![Y::zero(), max_value] + } else { + self.data + .iter() + .flat_map(|d| self.values.iter().map(|value_fn| value_fn(d))) + .chain(Some(Y::zero())) + .collect() + }; + + ScaleLinear::new(domain, vec![0., outer_radius]) + } + + /// Map a cursor position to the nearest spoke index, or `None` when the + /// cursor is outside the radar. + fn hovered_index(&self, position: Point, bounds: Bounds) -> Option { + let n = self.data.len(); + if n == 0 { + return None; + } + + let outer_radius = self.resolve_outer_radius(&bounds); + let dx = position.x.as_f32() - bounds.size.width.as_f32() / 2.; + let dy = position.y.as_f32() - bounds.size.height.as_f32() / 2.; + if dx.hypot(dy) > outer_radius + self.label_gap { + return None; + } + + // Screen angle -> chart angle (0 at 12 o'clock, clockwise). + let angle = (dy.atan2(dx) + HALF_PI).rem_euclid(TAU); + Some((angle * n as f32 / TAU).round() as usize % n) + } +} + +impl Plot for RadarChart +where + Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static, +{ + /// Resolve every dimension's label, keeping the text ones for `paint` and + /// laying out the element ones here (measuring is illegal in `paint`). + fn prepaint( + &mut self, + bounds: Bounds, + window: &mut Window, + cx: &mut App, + ) -> Vec { + self.label_texts.clear(); + + // Same guard as `paint`: without a series nothing is drawn at all. + let n = self.data.len(); + if n == 0 || self.values.is_empty() { + return vec![]; + } + let Some(label_fn) = self.label.clone() else { + return vec![]; + }; + + let outer_radius = self.resolve_outer_radius(&bounds); + let mut texts = Vec::with_capacity(n); + let mut elements = vec![]; + + for (ix, d) in self.data.iter().enumerate() { + match label_fn(d) { + RadarLabel::Text(text) => texts.push(Some(text)), + RadarLabel::Element(mut element) => { + texts.push(None); + + // Only `AvailableSpace::Definite` makes text wrap, so this + // measures the label at its natural, unwrapped size. + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds); + + // Push the box radially outward until its inner edge meets the + // anchor, so a tall label clears the ring instead of straddling + // it. Reduces to "centered on the anchor" across that axis when + // the dimension is square-on to it. + let origin = bounds.origin + + point( + px(anchor.x + (direction.x - 1.) * size.width.as_f32() / 2.), + px(anchor.y + (direction.y - 1.) * size.height.as_f32() / 2.), + ); + + element.prepaint_at(origin, window, cx); + elements.push(element); + } + } + } + + self.label_texts = texts; + + elements + } + + fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let n = self.data.len(); + if n == 0 || self.values.is_empty() { + return; + } + + let outer_radius = self.resolve_outer_radius(&bounds); + let angle_step = TAU / n as f32; + let center_x = bounds.size.width.as_f32() / 2.; + let center_y = bounds.size.height.as_f32() / 2.; + let scale = self.scale(outer_radius); + + // Draw grid rings and spokes + if self.grid { + let stroke = cx.theme().border; + + for level in 1..=self.grid_levels { + let radius = outer_radius * level as f32 / self.grid_levels as f32; + RadialLine::new() + .data(0..n) + .angle(move |_, i| Some(i as f32 * angle_step)) + .radius(move |_, _| Some(radius)) + .closed() + .stroke(stroke) + .paint(&bounds, window); + } + + for i in 0..n { + let angle = i as f32 * angle_step - HALF_PI; + let points = [ + point(center_x, center_y), + point( + center_x + outer_radius * angle.cos(), + center_y + outer_radius * angle.sin(), + ), + ]; + if let Some(path) = polygon(&points, &bounds) { + window.paint_path(path, stroke); + } + } + } + + // Draw series + for (i, value_fn) in self.values.iter().enumerate() { + let stroke = self.series_stroke(i, cx); + let fill = self + .fills + .get(i) + .copied() + .unwrap_or_else(|| stroke.opacity(0.3).into()); + + let scale = scale.clone(); + let value_fn = value_fn.clone(); + let mut line = RadialLine::new() + .data(&self.data) + .angle(move |_, i| Some(i as f32 * angle_step)) + .radius(move |d, _| scale.tick(&value_fn(d))) + .closed() + .fill(fill) + .stroke(stroke) + .stroke_width(2.); + if self.dot { + line = line.dot().dot_size(8.).dot_fill_color(stroke); + } + line.paint(&bounds, window); + } + + // Draw the text labels outside the outer ring; `prepaint` resolved them and + // already placed the element ones. + let label_color = self.label_color.unwrap_or(cx.theme().muted_foreground); + let labels = self + .label_texts + .iter() + .enumerate() + .filter_map(|(ix, text)| { + let text = text.clone()?; + let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds); + + // Labels on the right are left-aligned, on the left right-aligned, + // and near the vertical axis centered. `direction` is a unit vector, + // so the epsilon only absorbs float noise at 12 and 6 o'clock. + let align = if direction.x > 1e-3 { + TextAlign::Left + } else if direction.x < -1e-3 { + TextAlign::Right + } else { + TextAlign::Center + }; + + Some( + Text::new( + text, + point(px(anchor.x), px(anchor.y - TEXT_SIZE / 2.)), + label_color, + ) + .align(align), + ) + }); + + PlotLabel::new(labels.collect()).paint(&bounds, window, cx); + } + + fn id(&self) -> Option { + self.id.clone() + } + + fn tooltip_state( + &self, + position: Point, + bounds: Bounds, + _cx: &App, + ) -> Option { + if self.values.is_empty() { + return None; + } + let index = self.hovered_index(position, bounds)?; + let d = self.data.get(index)?; + + let outer_radius = self.resolve_outer_radius(&bounds); + let scale = self.scale(outer_radius); + let center_x = bounds.size.width.as_f32() / 2.; + let center_y = bounds.size.height.as_f32() / 2.; + let angle = index as f32 * TAU / self.data.len() as f32 - HALF_PI; + + // One dot per series at the hovered dimension's vertex. + let dots = self + .values + .iter() + .filter_map(|value_fn| { + let radius = scale.tick(&value_fn(d))?; + Some(point( + px(center_x + radius * angle.cos()), + px(center_y + radius * angle.sin()), + )) + }) + .collect(); + + Some(TooltipState::new(index, position, dots)) + } + + fn tooltip( + &self, + state: &TooltipState, + cursor: Point, + bounds: Bounds, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let d = self.data.get(state.index)?; + + let dot_stroke = cx.theme().background; + + // No crosshair: a radar has no cartesian axis to snap to; the dots mark + // the hovered dimension's vertices instead. + let mut tooltip = + Tooltip::new(cursor, bounds.size) + .gap(px(8.)) + .dots(state.dots.iter().enumerate().map(|(i, p)| { + Dot::new(*p) + .stroke(dot_stroke) + .fill(self.series_stroke(i, cx)) + })); + + // Filled by `prepaint`, which runs first; element labels leave no title. + if let Some(title) = self.label_texts.get(state.index).cloned().flatten() { + tooltip = tooltip.title(title); + } + + // One row per series: swatch + label + value. + for (i, value_fn) in self.values.iter().enumerate() { + let name = self.names.get(i).cloned().unwrap_or_default(); + let value = value_fn(d).to_f64()?; + tooltip = tooltip.row(self.series_stroke(i, cx), name, format!("{}", value)); + } + + Some(tooltip.into_any_element()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct Item { + subject: SharedString, + a: f64, + b: f64, + } + + #[test] + fn test_radar_chart_builder() { + let data = vec![ + Item { + subject: "Sales".into(), + a: 80., + b: 60., + }, + Item { + subject: "Marketing".into(), + a: 50., + b: 90., + }, + ]; + + let chart = RadarChart::new(data.clone()) + .label(|d| d.subject.clone()) + .value(|d| d.a) + .stroke(gpui::red()) + .fill(gpui::red()) + .name("A") + .value(|d| d.b) + .max_value(100.) + .outer_radius(120.) + .label_gap(8.) + .grid(false) + .grid_levels(5) + .dot() + .id("radar"); + + assert_eq!(chart.data.len(), 2); + assert_eq!(chart.values.len(), 2); + assert_eq!(chart.strokes.len(), 1); + assert_eq!(chart.fills.len(), 1); + assert_eq!(chart.names.len(), 1); + assert!(chart.label.is_some()); + assert_eq!(chart.max_value, Some(100.)); + assert_eq!(chart.outer_radius, 120.); + assert_eq!(chart.label_gap, 8.); + assert!(!chart.grid); + assert_eq!(chart.grid_levels, 5); + assert!(chart.dot); + assert!(chart.id.is_some()); + + let values = (chart.values[0](&data[0]), chart.values[1](&data[0])); + assert_eq!(values, (80., 60.)); + } + + /// Every string form the `label` closure may return lands on the text path, + /// which is what keeps `label_color` and the tooltip title working. + #[test] + fn test_radar_label_from_text() { + let labels = [ + RadarLabel::from("Sales"), + RadarLabel::from("Sales".to_string()), + RadarLabel::from(SharedString::from("Sales")), + ]; + + for label in labels { + assert!(matches!(label, RadarLabel::Text(text) if text == "Sales")); + } + } + + #[test] + fn test_radar_chart_grid_levels_min() { + let chart: RadarChart = RadarChart::new(vec![]).grid_levels(0); + assert_eq!(chart.grid_levels, 1); + } + + #[test] + fn test_radar_chart_hovered_index() { + let data = (0..4) + .map(|i| Item { + subject: format!("S{}", i).into(), + a: 50., + b: 50., + }) + .collect::>(); + + // Bounds 200x200 => center (100, 100), default outer radius 80, + // hover region 80 + 10 (label gap) = 90. + let chart: RadarChart = RadarChart::new(data).value(|d| d.a); + let bounds = gpui::Bounds::new(point(px(0.), px(0.)), gpui::size(px(200.), px(200.))); + + // The four spokes point at 12, 3, 6 and 9 o'clock. + assert_eq!( + chart.hovered_index(point(px(100.), px(30.)), bounds), + Some(0) + ); + assert_eq!( + chart.hovered_index(point(px(170.), px(100.)), bounds), + Some(1) + ); + assert_eq!( + chart.hovered_index(point(px(100.), px(170.)), bounds), + Some(2) + ); + assert_eq!( + chart.hovered_index(point(px(30.), px(100.)), bounds), + Some(3) + ); + + // Nearest spoke wins between two spokes. + assert_eq!( + chart.hovered_index(point(px(110.), px(40.)), bounds), + Some(0) + ); + assert_eq!( + chart.hovered_index(point(px(160.), px(90.)), bounds), + Some(1) + ); + + // Outside the radar. + assert_eq!(chart.hovered_index(point(px(100.), px(5.)), bounds), None); + assert_eq!(chart.hovered_index(point(px(5.), px(5.)), bounds), None); + } +} diff --git a/vendor/gpui-component/src/chart/sankey_chart.rs b/vendor/gpui-component/src/chart/sankey_chart.rs new file mode 100644 index 00000000..cb0bfc63 --- /dev/null +++ b/vendor/gpui-component/src/chart/sankey_chart.rs @@ -0,0 +1,578 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, Corners, Hsla, Pixels, SharedString, TextAlign, Window, fill, linear_color_stop, + linear_gradient, point, px, +}; +use gpui_component_macros::IntoPlot; + +use crate::{ + ActiveTheme, + plot::{ + Plot, + label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width}, + origin_point, + shape::{Sankey, SankeyAlign, SankeyLink, SankeyValueScale, sankey_link_path}, + }, +}; + +const DEFAULT_NODE_WIDTH: f32 = 10.; +const DEFAULT_NODE_PADDING: f32 = 16.; +const DEFAULT_LINK_OPACITY: f32 = 0.3; +const DEFAULT_MIN_LINK_WIDTH: f32 = 1.; +const DEFAULT_LABEL_GAP: f32 = 6.; +/// Cap each side's label margin (as a fraction of width) so a long label is +/// truncated to a modest column beside the flow instead of dominating it. +const MAX_LABEL_WIDTH_RATIO: f32 = 0.2; +/// Cap the reserved top+bottom label band as a fraction of height. +const MAX_LABEL_MARGIN_RATIO: f32 = 0.6; + +/// A styled line of a sankey node label. +#[derive(Clone)] +pub struct SankeyLabel { + text: SharedString, + color: Option, + font_size: Option, +} + +impl SankeyLabel { + /// Create a label line with the default color and font size. + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + color: None, + font_size: None, + } + } + + /// Set the text color. Defaults to the theme foreground. + pub fn color(mut self, color: impl Into) -> Self { + self.color = Some(color.into()); + self + } + + /// Set the font size. Defaults to 10. + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = Some(font_size); + self + } + + fn line_height(&self) -> f32 { + self.font_size.unwrap_or(TEXT_SIZE) + TEXT_GAP + } +} + +fn block_height(lines: &[SankeyLabel]) -> f32 { + lines.iter().map(|line| line.line_height()).sum() +} + +/// A Sankey diagram, layout modeled after [d3-sankey](https://github.com/d3/d3-sankey). +/// +/// Links reference nodes by their index in the node list; map string ids to +/// indices before constructing. +#[derive(IntoPlot)] +pub struct SankeyChart { + nodes: Vec, + links: Vec, + node_width: f32, + node_padding: f32, + align: SankeyAlign, + iterations: usize, + value_scale: SankeyValueScale, + node_corner_radius: Option, + node_color: Option Hsla>>, + node_label: Option SharedString>>, + value_label: Option SharedString>>, + labels: Option Vec>>, + link_opacity: f32, + min_link_width: f32, + label_gap: f32, +} + +impl SankeyChart { + /// Create a chart from nodes and links; links reference nodes by their + /// index in `nodes` (map string ids to indices before constructing). + pub fn new(nodes: I, links: L) -> Self + where + I: IntoIterator, + L: IntoIterator, + { + Self { + nodes: nodes.into_iter().collect(), + links: links.into_iter().collect(), + node_width: DEFAULT_NODE_WIDTH, + node_padding: DEFAULT_NODE_PADDING, + align: SankeyAlign::default(), + iterations: 6, + value_scale: SankeyValueScale::default(), + node_corner_radius: None, + node_color: None, + node_label: None, + value_label: None, + labels: None, + link_opacity: DEFAULT_LINK_OPACITY, + min_link_width: DEFAULT_MIN_LINK_WIDTH, + label_gap: DEFAULT_LABEL_GAP, + } + } + + /// Set the node rectangle width. Defaults to 10. + pub fn node_width(mut self, node_width: f32) -> Self { + self.node_width = node_width; + self + } + + /// Set the vertical gap between nodes in a column. Defaults to 16. + pub fn node_padding(mut self, node_padding: f32) -> Self { + self.node_padding = node_padding; + self + } + + /// Set the node alignment. Defaults to [`SankeyAlign::Justify`]. + pub fn node_align(mut self, align: SankeyAlign) -> Self { + self.align = align; + self + } + + /// Set the number of relaxation passes. Defaults to 6. + pub fn iterations(mut self, iterations: usize) -> Self { + self.iterations = iterations; + self + } + + /// Set how flow values map to node heights. + /// + /// Defaults to [`SankeyValueScale::Linear`]. Use [`SankeyValueScale::Sqrt`] + /// to keep a dominant flow from dwarfing the small ones without + /// pre-transforming the data; labels still receive the raw values. + pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self { + self.value_scale = value_scale; + self + } + + /// Set the corner radius of the node rectangles. Defaults to 0. + pub fn node_corner_radius(mut self, radius: impl Into) -> Self { + self.node_corner_radius = Some(radius.into()); + self + } + + /// Set the color of each node. + /// + /// Defaults to cycling the theme chart palette by node index. + pub fn node_color(mut self, color: impl Fn(&T) -> H + 'static) -> Self + where + H: Into + 'static, + { + self.node_color = Some(Rc::new(move |t| color(t).into())); + self + } + + /// Set the name label of each node, drawn in muted foreground. No name + /// label is drawn unless set. + pub fn node_label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self { + self.node_label = Some(Rc::new(label)); + self + } + + /// Set the value label of each node, drawn above the name label. No value + /// label is drawn unless set. + /// + /// The closure receives the datum and the node's raw computed throughput + /// (max of incoming and outgoing flow, in unscaled units). + pub fn value_label(mut self, label: impl Fn(&T, f64) -> SharedString + 'static) -> Self { + self.value_label = Some(Rc::new(label)); + self + } + + /// Set fully custom node labels, one [`SankeyLabel`] per line, top to + /// bottom. Takes precedence over `node_label`/`value_label` when set; + /// unset by default. + /// + /// The closure receives the datum and the node's raw computed throughput + /// (max of incoming and outgoing flow, in unscaled units). + pub fn labels(mut self, labels: impl Fn(&T, f64) -> Vec + 'static) -> Self { + self.labels = Some(Rc::new(labels)); + self + } + + /// Set the opacity of the link ribbons. Defaults to 0.3. + pub fn link_opacity(mut self, opacity: f32) -> Self { + self.link_opacity = opacity; + self + } + + /// Set the minimum ribbon thickness, so tiny flows stay visible. Defaults to 1. + pub fn min_link_width(mut self, width: f32) -> Self { + self.min_link_width = width; + self + } + + /// Set the gap between a node and its labels. Defaults to 6. + pub fn label_gap(mut self, gap: f32) -> Self { + self.label_gap = gap; + self + } + + fn sankey(&self) -> Sankey { + Sankey::new() + .node_width(self.node_width) + .node_padding(self.node_padding) + .node_align(self.align) + .iterations(self.iterations) + .value_scale(self.value_scale) + } + + /// Raw per-node throughput (max of raw incoming and outgoing sums), for + /// labels — the layout's `node.value` is in scaled units under a + /// non-linear value scale, so labels must not use it. + fn raw_throughput(&self) -> Vec { + let mut incoming = vec![0f64; self.nodes.len()]; + let mut outgoing = vec![0f64; self.nodes.len()]; + for link in &self.links { + if let (Some(o), Some(i)) = + (outgoing.get_mut(link.source), incoming.get_mut(link.target)) + { + *o += link.value; + *i += link.value; + } + } + incoming + .into_iter() + .zip(outgoing) + .map(|(i, o)| i.max(o)) + .collect() + } +} + +impl Plot for SankeyChart { + fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let width = bounds.size.width.as_f32(); + let height = bounds.size.height.as_f32(); + if self.nodes.is_empty() || self.links.is_empty() || width <= 0. || height <= 0. { + return; + } + + // First pass: only the topology (each node's `layer`) is needed to + // measure the label margins; label values come from `raw_throughput`. + let Ok(topology) = self.sankey().topology(self.nodes.len(), &self.links) else { + return; + }; + let layer_count = topology.layer_count(); + // Labels get the raw throughput, not the layout's (possibly scaled) value. + let raw_value = self.raw_throughput(); + + // Collect each node's label lines: the custom `labels` closure wins, + // otherwise synthesize the value/name lines with the default styles. + let node_labels: Vec> = topology + .nodes + .iter() + .map(|node| { + let datum = &self.nodes[node.index]; + let value = raw_value[node.index]; + if let Some(labels) = &self.labels { + labels(datum, value) + } else { + let mut lines = Vec::new(); + if let Some(value_label) = &self.value_label { + lines.push(SankeyLabel::new(value_label(datum, value))); + } + if let Some(node_label) = &self.node_label { + lines.push( + SankeyLabel::new(node_label(datum)).color(cx.theme().muted_foreground), + ); + } + lines + } + }) + .collect(); + let has_labels = node_labels.iter().any(|lines| !lines.is_empty()); + + // Reserve margins so the labels beside the first/last columns and + // above the middle columns are not clipped. + let mut left = 0f32; + let mut right = 0f32; + if has_labels { + for node in &topology.nodes { + if node.layer != 0 && node.layer + 1 != layer_count { + continue; + } + let mut label_width = 0f32; + for line in &node_labels[node.index] { + label_width = label_width.max(measure_text_width( + &line.text, + px(line.font_size.unwrap_or(TEXT_SIZE)), + window, + )); + } + if node.layer == 0 { + left = left.max(label_width + self.label_gap); + } else { + right = right.max(label_width + self.label_gap); + } + } + + // Cap each side independently so one long label is truncated to a + // modest column rather than eating into the flow area. + let side_cap = width * MAX_LABEL_WIDTH_RATIO; + left = left.min(side_cap); + right = right.min(side_cap); + } + // Above-node labels are only emitted for middle columns, so reserve + // the top band for the tallest such label block. Cap the vertical + // margins like the horizontal ones so a short chart doesn't collapse + // the flow. + let mut top = 0f32; + if has_labels && layer_count > 2 { + for node in &topology.nodes { + if node.layer == 0 || node.layer + 1 == layer_count { + continue; + } + let block = block_height(&node_labels[node.index]); + if block > 0. { + top = top.max(block + TEXT_GAP); + } + } + } + let mut bottom = if has_labels { TEXT_GAP } else { 0. }; + let max_vertical = height * MAX_LABEL_MARGIN_RATIO; + if top + bottom > max_vertical { + let k = max_vertical / (top + bottom); + top *= k; + bottom *= k; + } + + // Second pass: complete the placement on the final extent, reusing + // the first pass's topology. + let graph = self + .sankey() + .extent( + left, + top, + (width - right).max(left + 1.), + (height - bottom).max(top + 1.), + ) + .layout_from(topology); + + let palette = [ + cx.theme().chart_1, + cx.theme().chart_2, + cx.theme().chart_3, + cx.theme().chart_4, + cx.theme().chart_5, + ]; + let colors: Vec = self + .nodes + .iter() + .enumerate() + .map(|(index, datum)| match &self.node_color { + Some(color) => color(datum), + None => palette[index % palette.len()], + }) + .collect(); + + // Links first, under the nodes. + for link in &graph.links { + if link.value <= 0. { + continue; + } + let source = &graph.nodes[link.source]; + let target = &graph.nodes[link.target]; + let Some(path) = + sankey_link_path(source, target, link, self.min_link_width, bounds.origin) + else { + continue; + }; + window.paint_path( + path, + linear_gradient( + 90., + linear_color_stop(colors[link.source].opacity(self.link_opacity), 0.), + linear_color_stop(colors[link.target].opacity(self.link_opacity), 1.), + ), + ); + } + + let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default()); + for node in &graph.nodes { + let node_bounds = Bounds::from_corners( + origin_point(px(node.x0), px(node.y0), bounds.origin), + // Keep tiny nodes visible with a minimum 1px height. + origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), + ); + window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii)); + } + + let mut texts = Vec::new(); + for node in &graph.nodes { + let lines = &node_labels[node.index]; + if lines.is_empty() { + continue; + } + + let is_first = node.layer == 0; + let is_last = node.layer + 1 == layer_count; + // `x`/`align` place the label beside (first/last) or centered above + // (middle) the node, and `max_width` bounds it so a long label is + // truncated with an ellipsis instead of drawn outside the plot: + // first/last to their reserved margin, middle to twice the smaller + // gap to the plot edge (generous for interior nodes, only bites a + // label long enough to actually run off-plot). + let (x, align, max_width) = if is_first { + ( + node.x0 - self.label_gap, + TextAlign::Right, + left - self.label_gap, + ) + } else if is_last { + ( + node.x1 + self.label_gap, + TextAlign::Left, + right - self.label_gap, + ) + } else { + let center = (node.x0 + node.x1) / 2.; + let edge_budget = 2. * center.min(width - center); + (center, TextAlign::Center, edge_budget) + }; + + let block = block_height(lines); + let mut y = if is_first || is_last { + // Block vertically centered beside the node, clamped into + // the plot area so labels of nodes near the top or bottom + // edge are not clipped. + ((node.y0 + node.y1) / 2. - block / 2.) + .min(height - block) + .max(0.) + } else { + // Block above the node. + node.y0 - block - TEXT_GAP + }; + + for line in lines { + let font_size = px(line.font_size.unwrap_or(TEXT_SIZE)); + let text = truncate_text_to_width(&line.text, font_size, max_width, window); + texts.push( + Text::new( + text, + point(px(x), px(y)), + line.color.unwrap_or(cx.theme().foreground), + ) + .font_size(font_size) + .align(align), + ); + y += line.line_height(); + } + } + PlotLabel::new(texts).paint(&bounds, window, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sankey_chart_builder() { + let chart = SankeyChart::new(vec!["a", "b"], vec![SankeyLink::new(0, 1, 5.)]); + assert_eq!(chart.nodes.len(), 2); + assert_eq!(chart.links.len(), 1); + assert_eq!(chart.node_width, DEFAULT_NODE_WIDTH); + assert_eq!(chart.node_padding, DEFAULT_NODE_PADDING); + assert_eq!(chart.align, SankeyAlign::Justify); + assert_eq!(chart.iterations, 6); + assert_eq!(chart.node_corner_radius, None); + assert_eq!(chart.link_opacity, DEFAULT_LINK_OPACITY); + assert_eq!(chart.min_link_width, DEFAULT_MIN_LINK_WIDTH); + assert_eq!(chart.label_gap, DEFAULT_LABEL_GAP); + assert!(chart.node_color.is_none()); + assert!(chart.node_label.is_none()); + assert!(chart.value_label.is_none()); + assert!(chart.labels.is_none()); + + let chart = chart + .node_width(8.) + .node_padding(20.) + .node_align(SankeyAlign::Left) + .iterations(10) + .node_corner_radius(px(2.)) + .node_color(|_| gpui::red()) + .node_label(|d| SharedString::from(d.to_string())) + .value_label(|_, value| SharedString::from(format!("{}", value))) + .labels(|d, value| { + vec![ + SankeyLabel::new(format!("{}", value)), + SankeyLabel::new(d.to_string()), + ] + }) + .link_opacity(0.5) + .min_link_width(2.) + .label_gap(10.); + assert_eq!(chart.node_width, 8.); + assert_eq!(chart.node_padding, 20.); + assert_eq!(chart.align, SankeyAlign::Left); + assert_eq!(chart.iterations, 10); + assert_eq!(chart.node_corner_radius, Some(px(2.))); + assert_eq!(chart.link_opacity, 0.5); + assert_eq!(chart.min_link_width, 2.); + assert_eq!(chart.label_gap, 10.); + assert!(chart.node_color.is_some()); + assert!(chart.node_label.is_some()); + assert!(chart.value_label.is_some()); + assert!(chart.labels.is_some()); + } + + #[test] + fn test_sankey_label_builder() { + let label = SankeyLabel::new("a"); + assert_eq!(label.text, "a"); + assert_eq!(label.color, None); + assert_eq!(label.font_size, None); + assert_eq!(label.line_height(), TEXT_SIZE + TEXT_GAP); + + let label = SankeyLabel::new("b").color(gpui::red()).font_size(14.); + assert_eq!(label.color, Some(gpui::red())); + assert_eq!(label.font_size, Some(14.)); + assert_eq!(label.line_height(), 14. + TEXT_GAP); + + assert_eq!( + block_height(&[SankeyLabel::new("a"), SankeyLabel::new("b").font_size(14.)]), + TEXT_SIZE + TEXT_GAP + 14. + TEXT_GAP + ); + assert_eq!(block_height(&[]), 0.); + } + + #[test] + fn test_sankey_chart_raw_throughput() { + // A(out 30) -> B, B -> C(20) + D(10): B's throughput is max(in, out). + let chart = SankeyChart::new( + vec!["a", "b", "c", "d"], + vec![ + SankeyLink::new(0, 1, 30.), + SankeyLink::new(1, 2, 20.), + SankeyLink::new(1, 3, 10.), + ], + ); + let raw = chart.raw_throughput(); + assert_eq!(raw, vec![30., 30., 20., 10.]); + + // Under Sqrt the layout's node value is scaled, but raw_throughput + // (used for labels) must stay in raw units — the two must differ. + let sqrt = chart + .value_scale(SankeyValueScale::Sqrt) + .sankey() + .layout(4, &chart_links()) + .unwrap(); + // Node A: layout value is sqrt-scaled (30 -> sqrt(30)), raw is 30. + assert!((sqrt.nodes[0].value - 30f64.sqrt()).abs() < 1e-6); + assert!((raw[0] - 30.).abs() < 1e-6); + assert!(raw[0] != sqrt.nodes[0].value); + } + + fn chart_links() -> Vec { + vec![ + SankeyLink::new(0, 1, 30.), + SankeyLink::new(1, 2, 20.), + SankeyLink::new(1, 3, 10.), + ] + } +} diff --git a/vendor/gpui-component/src/checkbox.rs b/vendor/gpui-component/src/checkbox.rs index 1ac980d4..bde5d1d5 100644 --- a/vendor/gpui-component/src/checkbox.rs +++ b/vendor/gpui-component/src/checkbox.rs @@ -1,21 +1,25 @@ use std::{rc::Rc, time::Duration}; use crate::{ - icon::IconNamed, text::Text, v_flex, ActiveTheme, Disableable, FocusableExt, IconName, - Selectable, Sizable, Size, StyledExt as _, + ActiveTheme, Disableable, IconName, RoleOverride, Selectable, Sizable, Size, icon::IconNamed, + text::Text, tooltip::ComponentTooltip, v_flex, }; +use crate::{StyledExt as _, ThemeStyled as _}; use gpui::{ - div, prelude::FluentBuilder as _, px, relative, rems, svg, Animation, AnimationExt, AnyElement, - App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, - StatefulInteractiveElement, StyleRefinement, Styled, Window, + Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px, relative, rems, svg, }; +use gpui_base::CheckboxIndicator; /// A Checkbox element. #[derive(IntoElement)] pub struct Checkbox { id: ElementId, - base: Div, + base: gpui_base::Checkbox, style: StyleRefinement, + // `Text` is legacy presentation state. During render it is composed into + // the Base Checkbox child seam together with application children. label: Option, children: Vec, checked: bool, @@ -24,14 +28,18 @@ pub struct Checkbox { tab_stop: bool, tab_index: isize, on_click: Option>, + tooltip: ComponentTooltip, + role: RoleOverride, + focus_ring_enabled: bool, } impl Checkbox { /// Create a new Checkbox with the given id. pub fn new(id: impl Into) -> Self { + let id = id.into(); Self { - id: id.into(), - base: div(), + id: id.clone(), + base: gpui_base::Checkbox::new(id), style: StyleRefinement::default(), label: None, children: Vec::new(), @@ -41,9 +49,23 @@ impl Checkbox { on_click: None, tab_stop: true, tab_index: 0, + tooltip: ComponentTooltip::default(), + role: RoleOverride::default(), + focus_ring_enabled: true, } } + pub fn role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + /// Set tooltip text for the checkbox. + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip.text = Some((tooltip.into(), None)); + self + } + /// Set the label for the checkbox. pub fn label(mut self, label: impl Into) -> Self { self.label = Some(label.into()); @@ -75,18 +97,6 @@ impl Checkbox { self.tab_index = tab_index; self } - - fn handle_click( - on_click: &Option>, - checked: bool, - window: &mut Window, - cx: &mut App, - ) { - let new_checked = !checked; - if let Some(f) = on_click { - (f)(&new_checked, window, cx); - } - } } impl InteractiveElement for Checkbox { @@ -109,6 +119,17 @@ impl Disableable for Checkbox { } } +impl crate::FocusableExt for Checkbox { + fn focus_ring(mut self, enabled: bool) -> Self { + self.focus_ring_enabled = enabled; + self + } + + fn is_focus_ring_enabled(&self) -> bool { + self.focus_ring_enabled + } +} + impl Selectable for Checkbox { fn selected(self, selected: bool) -> Self { self.checked(selected) @@ -193,118 +214,264 @@ impl RenderOnce for Checkbox { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let checked = self.checked; + let base = self.base; + let children = self.children; + let accessibility_label = self.label.as_ref().map(|label| label.get_text(cx)); + let on_click = self.on_click.clone(); let focus_handle = window .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) .read(cx) .clone(); let is_focused = focus_handle.is_focused(window); - let border_color = if checked { - cx.theme().primary - } else { - cx.theme().input - }; - let color = if self.disabled { - border_color.opacity(0.5) + let unchecked_border = cx.theme().input; + let checked_color = cx.theme().primary; + let disabled_indicator_color = if checked { + checked_color.opacity(0.5) } else { - border_color + unchecked_border.opacity(0.5) }; let radius = cx.theme().radius.min(px(4.)); - - div().child( - self.base - .id(self.id.clone()) - .when(!self.disabled, |this| { - this.track_focus( - &focus_handle - .tab_stop(self.tab_stop) - .tab_index(self.tab_index), - ) - }) - .h_flex() - .gap_2() - .items_start() - .line_height(relative(1.)) - .text_color(cx.theme().foreground) - .map(|this| match self.size { - Size::XSmall => this.text_xs(), - Size::Small => this.text_sm(), - Size::Medium => this.text_base(), - Size::Large => this.text_lg(), - _ => this, + let disabled_text_color = cx.theme().muted_foreground; + let instance_style = self.style.clone(); + base.role(self.role) + .checked(checked) + .disabled(self.disabled) + .styles(|styles| { + styles.disabled(|style| { + style + .text_color(disabled_text_color) + .refine_style(&instance_style) }) - .when(self.disabled, |this| { - this.text_color(cx.theme().muted_foreground) + }) + .tab_stop(self.tab_stop) + .tab_index(self.tab_index) + .track_focus(&focus_handle) + .when_some(accessibility_label, |this, label| { + this.accessibility_label(label) + }) + .when_some(on_click, |this, on_click| { + this.on_change(move |_, _, window, cx| { + window.prevent_default(); + on_click(&!checked, window, cx); }) - .rounded(cx.theme().radius * 0.5) - .focus_ring(is_focused, px(2.), window, cx) - .refine_style(&self.style) - .child( - div() - .relative() - .map(|this| match self.size { - Size::XSmall => this.size_3(), - Size::Small => this.size_3p5(), - Size::Medium => this.size_4(), - Size::Large => this.size(rems(1.125)), - _ => this.size_4(), - }) - .flex_shrink_0() - .border_1() - .border_color(color) - .rounded(radius) - .when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs()) - .map(|this| match checked { - false => this.bg(cx.theme().background), - _ => this.bg(color), + }) + .h_flex() + .gap_2() + .items_start() + .line_height(relative(1.)) + .text_color(cx.theme().foreground) + .map(|this| match self.size { + Size::XSmall => this.text_xs(), + Size::Small => this.text_sm(), + Size::Medium => this.text_base(), + Size::Large => this.text_lg(), + _ => this, + }) + .rounded(cx.theme().radius * 0.5) + .when(is_focused && self.focus_ring_enabled, |this| { + this.focus_ring_style(window, cx) + }) + .refine_style(&self.style) + .child( + CheckboxIndicator::new() + .checked(checked) + .disabled(self.disabled) + .relative() + .map(|this| match self.size { + Size::XSmall => this.size_3(), + Size::Small => this.size_3p5(), + Size::Medium => this.size_4(), + Size::Large => this.size(rems(1.125)), + _ => this.size_4(), + }) + .flex_shrink_0() + .border_1() + .rounded(radius) + .when(!checked, |this| { + this.bg(cx.theme().input_background()) + .when(!self.disabled, |this| this.border_color(unchecked_border)) + }) + .styles(|styles| { + styles + .checked(|style| { + style + .border_color(checked_color) + .bg(cx.theme().tokens.primary) + }) + .disabled(|style| { + style + .border_color(disabled_indicator_color) + .when(checked, |style| style.bg(disabled_indicator_color)) + }) + }) + .child(checkbox_check_icon( + self.id, + self.size, + checked, + self.disabled, + window, + cx, + )), + ) + .when(self.label.is_some() || !children.is_empty(), |this| { + this.child( + v_flex() + .flex_1() + .overflow_hidden() + .line_height(relative(1.2)) + .gap_1() + .map(|this| { + if let Some(label) = self.label { + this.child( + div() + .size_full() + .text_color(cx.theme().foreground) + .when(self.disabled, |this| { + this.text_color(cx.theme().muted_foreground) + }) + .line_height(relative(1.)) + .child(label), + ) + } else { + this + } }) - .child(checkbox_check_icon( - self.id, - self.size, - checked, - self.disabled, - window, - cx, - )), + .children(children), ) - .when(self.label.is_some() || !self.children.is_empty(), |this| { - this.child( - v_flex() - .w_full() - .line_height(relative(1.2)) - .gap_1() - .map(|this| { - if let Some(label) = self.label { - this.child( - div() - .size_full() - .text_color(cx.theme().foreground) - .when(self.disabled, |this| { - this.text_color(cx.theme().muted_foreground) - }) - .line_height(relative(1.)) - .child(label), - ) - } else { - this - } - }) - .children(self.children), + }) + .on_mouse_down(MouseButton::Left, |_, window, _| { + // Preserve the legacy Checkbox behavior: pointer presses do + // not move focus, including while disabled. + window.prevent_default(); + }) + .map(|this| self.tooltip.apply(this)) + } +} + +#[cfg(test)] +mod tests { + use std::{cell::Cell, rc::Rc}; + + use gpui::{ + Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext, + VisualTestContext, point, + }; + + use super::*; + + struct CheckboxHarness { + disabled: bool, + clicks: Rc>, + parent_clicks: Rc>, + } + + impl Render for CheckboxHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + let parent_clicks = self.parent_clicks.clone(); + div() + .id("checkbox-parent") + .tab_group() + .size(px(100.)) + .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1)) + .child( + Checkbox::new("checkbox") + .disabled(self.disabled) + .size_full() + .on_click(move |checked, _, _| { + assert!(*checked); + clicks.set(clicks.get() + 1); + }), + ) + } + } + + fn harness( + cx: &mut TestAppContext, + disabled: bool, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + cx.update(crate::init); + let clicks = Rc::new(Cell::new(0)); + let parent_clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let clicks = clicks.clone(); + let parent_clicks = parent_clicks.clone(); + move |_, _| CheckboxHarness { + disabled, + clicks, + parent_clicks, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, clicks, parent_clicks) + } + + fn activate_key(cx: &mut VisualTestContext, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { keystroke }); + } + + #[gpui::test] + fn facade_pointer_activation_fires_once_without_moving_focus(cx: &mut TestAppContext) { + let (cx, clicks, _) = harness(cx, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + + assert_eq!(clicks.get(), 1); + cx.update(|window, cx| assert!(window.focused(cx).is_none())); + } + + #[gpui::test] + fn facade_supports_tab_enter_and_space(cx: &mut TestAppContext) { + let (cx, clicks, _) = harness(cx, false); + cx.update(|window, cx| window.focus_next(cx)); + cx.update(|window, cx| assert!(window.focused(cx).is_some())); + + activate_key(cx, "enter"); + activate_key(cx, "space"); + + assert_eq!(clicks.get(), 2); + } + + #[gpui::test] + fn facade_disabled_is_inert_and_pointer_activation_bubbles(cx: &mut TestAppContext) { + let (cx, clicks, parent_clicks) = harness(cx, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + + assert_eq!(clicks.get(), 0); + assert_eq!(parent_clicks.get(), 1); + cx.update(|window, cx| assert!(window.focused(cx).is_none())); + } + + #[gpui::test] + fn facade_prepaints_label_and_custom_content_through_the_base_slot(cx: &mut TestAppContext) { + struct ContentHarness; + + impl Render for ContentHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Checkbox::new("content-checkbox") + .label("Remember me") + .child( + div() + .debug_selector(|| "checkbox-custom-content".into()) + .child("Additional detail"), ) - }) - .on_mouse_down(gpui::MouseButton::Left, |_, window, _| { - // Avoid focus on mouse down. - window.prevent_default(); - }) - .when(!self.disabled, |this| { - this.on_click({ - let on_click = self.on_click.clone(); - move |_, window, cx| { - window.prevent_default(); - Self::handle_click(&on_click, checked, window, cx); - } - }) - }), - ) + } + } + + cx.update(crate::init); + let (_, cx) = cx.add_window_view(|_, _| ContentHarness); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let bounds = cx + .debug_bounds("checkbox-custom-content") + .expect("custom content must prepaint through the Base child seam"); + assert!(bounds.size.width > px(0.)); + assert!(bounds.size.height > px(0.)); } } diff --git a/vendor/gpui-component/src/clipboard.rs b/vendor/gpui-component/src/clipboard.rs index 283c1351..006079cd 100644 --- a/vendor/gpui-component/src/clipboard.rs +++ b/vendor/gpui-component/src/clipboard.rs @@ -1,13 +1,13 @@ use std::{rc::Rc, time::Duration}; use gpui::{ - prelude::FluentBuilder, App, ClipboardItem, ElementId, IntoElement, RenderOnce, SharedString, - Window, + App, ClipboardItem, ElementId, IntoElement, RenderOnce, SharedString, Window, + prelude::FluentBuilder, }; use crate::{ - button::{Button, ButtonVariants as _}, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, }; /// An element that provides clipboard copy functionality. @@ -17,6 +17,7 @@ pub struct Clipboard { value: SharedString, value_fn: Option SharedString>>, on_copied: Option>, + tooltip_text: Option, } impl Clipboard { @@ -27,9 +28,16 @@ impl Clipboard { value: SharedString::default(), value_fn: None, on_copied: None, + tooltip_text: None, } } + /// Set tooltip text for the clipboard button. + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip_text = Some(tooltip.into()); + self + } + /// Set the value for copying to the clipboard. Default is an empty string. pub fn value(mut self, value: impl Into) -> Self { self.value = value.into(); @@ -74,6 +82,7 @@ impl RenderOnce for Clipboard { }) .ghost() .xsmall() + .when_some(self.tooltip_text, |this, text| this.tooltip(text)) .when(!copied, |this| { this.on_click({ let state = state.clone(); diff --git a/vendor/gpui-component/src/collapsible.rs b/vendor/gpui-component/src/collapsible.rs index 2e346db8..ce4fa375 100644 --- a/vendor/gpui-component/src/collapsible.rs +++ b/vendor/gpui-component/src/collapsible.rs @@ -2,40 +2,27 @@ use gpui::{ AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, }; -use crate::{v_flex, StyledExt}; - -enum CollapsibleChild { - Element(AnyElement), - Content(AnyElement), -} - -impl CollapsibleChild { - fn is_content(&self) -> bool { - matches!(self, CollapsibleChild::Content(_)) - } -} +use crate::StyledExt; /// An interactive element which expands/collapses. #[derive(IntoElement)] pub struct Collapsible { + base: gpui_base::Collapsible, style: StyleRefinement, - children: Vec, - open: bool, } impl Collapsible { /// Creates a new `Collapsible` instance. pub fn new() -> Self { Self { + base: gpui_base::Collapsible::new(), style: StyleRefinement::default(), - open: false, - children: vec![], } } /// Sets whether the collapsible is open. default is false. pub fn open(mut self, open: bool) -> Self { - self.open = open; + self.base = self.base.open(open); self } @@ -43,8 +30,7 @@ impl Collapsible { /// /// If `open` is false, content will be hidden. pub fn content(mut self, content: impl IntoElement) -> Self { - self.children - .push(CollapsibleChild::Content(content.into_any_element())); + self.base = self.base.content(content); self } } @@ -57,24 +43,52 @@ impl Styled for Collapsible { impl ParentElement for Collapsible { fn extend(&mut self, elements: impl IntoIterator) { - self.children - .extend(elements.into_iter().map(|el| CollapsibleChild::Element(el))); + self.base.extend(elements); } } impl RenderOnce for Collapsible { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - v_flex() - .refine_style(&self.style) - .children(self.children.into_iter().filter_map(|child| { - if child.is_content() && !self.open { - None - } else { - match child { - CollapsibleChild::Element(el) => Some(el), - CollapsibleChild::Content(el) => Some(el), - } - } - })) + self.base.v_flex().refine_style(&self.style) + } +} + +#[cfg(test)] +mod tests { + use gpui::{Context, InteractiveElement as _, Render, TestAppContext, div, px}; + + use super::*; + + struct Harness(bool); + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Collapsible::new() + .open(self.0) + .child( + div() + .debug_selector(|| "collapsible-trigger".into()) + .size(px(10.)), + ) + .content( + div() + .debug_selector(|| "collapsible-content".into()) + .size(px(10.)), + ) + } + } + + #[gpui::test] + fn facade_preserves_vertical_layout_and_visibility(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| Harness(true)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let trigger = cx.debug_bounds("collapsible-trigger").unwrap(); + let content = cx.debug_bounds("collapsible-content").unwrap(); + assert!(trigger.origin.y < content.origin.y); + + let (_, cx) = cx.add_window_view(|_, _| Harness(false)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("collapsible-trigger").is_some()); + assert!(cx.debug_bounds("collapsible-content").is_none()); } } diff --git a/vendor/gpui-component/src/color_picker.rs b/vendor/gpui-component/src/color_picker.rs index d1a8cb4a..d846c693 100644 --- a/vendor/gpui-component/src/color_picker.rs +++ b/vendor/gpui-component/src/color_picker.rs @@ -1,34 +1,25 @@ use gpui::{ - App, AppContext, Bounds, ClickEvent, Context, Corner, Div, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, Hsla, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Stateful, - StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, anchored, - canvas, deferred, div, prelude::FluentBuilder as _, px, relative, + Anchor, App, ElementId, Entity, FocusHandle, Focusable, Hsla, InteractiveElement as _, + IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, + StyleRefinement, Styled, TextAlign, Window, div, hsla, linear_color_stop, linear_gradient, + prelude::FluentBuilder as _, }; +use rust_i18n::t; + +use gpui_base::{ColorPicker as BaseColorPicker, ColorSwatch}; +pub use gpui_base::{ColorPickerEvent, ColorPickerState}; use crate::{ - ActiveTheme as _, Colorize as _, FocusableExt as _, Icon, Selectable as _, Sizable, Size, - StyleSized, StyledExt, - actions::{Cancel, Confirm}, - button::{Button, ButtonVariants}, - divider::Divider, - h_flex, - input::{Input, InputEvent, InputState}, - tooltip::Tooltip, + ActiveTheme as _, Colorize as _, Icon, Selectable, Sizable, Size, StyleSized, h_flex, + input::Input, + popover::Popover, + separator::Separator, + slider::Slider, + tab::{Tab, TabBar}, + tooltip::{ManagedTooltipExt as _, Tooltip}, v_flex, }; -const CONTEXT: &'static str = "ColorPicker"; -pub fn init(cx: &mut App) { - cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) -} - -/// Events emitted by the [`ColorPicker`]. -#[derive(Clone)] -pub enum ColorPickerEvent { - Change(Option), -} - fn color_palettes() -> Vec> { use crate::theme::DEFAULT_COLORS; use itertools::Itertools as _; @@ -57,140 +48,6 @@ fn color_palettes() -> Vec> { ] } -/// State of the [`ColorPicker`]. -pub struct ColorPickerState { - focus_handle: FocusHandle, - value: Option, - hovered_color: Option, - state: Entity, - open: bool, - bounds: Bounds, - _subscriptions: Vec, -} - -impl ColorPickerState { - /// Create a new [`ColorPickerState`]. - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let state = cx.new(|cx| InputState::new(window, cx)); - - let _subscriptions = vec![cx.subscribe_in( - &state, - window, - |this, state, ev: &InputEvent, window, cx| match ev { - InputEvent::Change => { - let value = state.read(cx).value(); - if let Ok(color) = Hsla::parse_hex(value.as_str()) { - this.hovered_color = Some(color); - } - } - InputEvent::PressEnter { .. } => { - let val = this.state.read(cx).value(); - if let Ok(color) = Hsla::parse_hex(&val) { - this.open = false; - this.update_value(Some(color), true, window, cx); - } - } - _ => {} - }, - )]; - - Self { - focus_handle: cx.focus_handle(), - value: None, - hovered_color: None, - state, - open: false, - bounds: Bounds::default(), - _subscriptions, - } - } - - /// Set default color value. - pub fn default_value(mut self, value: impl Into) -> Self { - self.value = Some(value.into()); - self - } - - /// Set current color value. - pub fn set_value( - &mut self, - value: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - self.update_value(Some(value.into()), false, window, cx) - } - - /// Get current color value. - pub fn value(&self) -> Option { - self.value - } - - fn on_escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if !self.open { - cx.propagate(); - } - - self.open = false; - if self.hovered_color != self.value { - let color = self.value; - self.hovered_color = color; - if let Some(color) = color { - self.state.update(cx, |input, cx| { - input.set_value(color.to_hex(), window, cx); - }); - } - } - cx.notify(); - } - - fn on_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { - self.open = !self.open; - cx.notify(); - } - - fn toggle_picker(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - self.open = !self.open; - cx.notify(); - } - - fn update_value( - &mut self, - value: Option, - emit: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.value = value; - self.hovered_color = value; - self.state.update(cx, |view, cx| { - if let Some(value) = value { - view.set_value(value.to_hex(), window, cx); - } else { - view.set_value("", window, cx); - } - }); - if emit { - cx.emit(ColorPickerEvent::Change(value)); - } - cx.notify(); - } -} - -impl EventEmitter for ColorPickerState {} - -impl Render for ColorPickerState { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.state.clone() - } -} - -impl Focusable for ColorPickerState { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - /// A color picker element. #[derive(IntoElement)] pub struct ColorPicker { @@ -201,7 +58,7 @@ pub struct ColorPicker { label: Option, icon: Option, size: Size, - anchor: Corner, + anchor: Anchor, } impl ColorPicker { @@ -215,7 +72,7 @@ impl ColorPicker { size: Size::Medium, label: None, icon: None, - anchor: Corner::TopLeft, + anchor: Anchor::TopLeft, } } @@ -247,53 +104,92 @@ impl ColorPicker { /// Set the anchor corner of the color picker. /// - /// Default is `Corner::TopLeft`. - pub fn anchor(mut self, anchor: Corner) -> Self { + /// Default is `Anchor::TopLeft`. + pub fn anchor(mut self, anchor: Anchor) -> Self { self.anchor = anchor; self } - fn render_item( - &self, - color: Hsla, - clickable: bool, - window: &mut Window, - _: &mut App, - ) -> Stateful
{ - let state = self.state.clone(); - div() - .id(SharedString::from(format!("color-{}", color.to_hex()))) - .h_5() - .w_5() - .bg(color) - .border_1() - .border_color(color.darken(0.1)) - .when(clickable, |this| { - this.hover(|this| { - this.border_color(color.darken(0.3)) - .bg(color.lighten(0.1)) - .shadow_xs() - }) - .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2))) - .on_mouse_move(window.listener_for(&state, move |state, _, window, cx| { - state.hovered_color = Some(color); - state.state.update(cx, |input, cx| { - input.set_value(color.to_hex(), window, cx); - }); - cx.notify(); - })) - .on_click(window.listener_for( - &state, - move |state, _, window, cx| { - state.update_value(Some(color), true, window, cx); - state.open = false; - cx.notify(); - }, - )) - }) + fn render_item(&self, color: Hsla, cx: &mut App) -> ColorSwatch { + let selected = self.state.read(cx).value() == Some(color); + let hover_state = self.state.clone(); + let click_state = self.state.clone(); + + ColorSwatch::new( + SharedString::from(format!("color-{}", color.to_hex())), + color, + ) + .selected(selected) + .h_5() + .w_5() + .bg(color) + .border_1() + .border_color(color.darken(0.1)) + .hover(|this| this.border_color(color.darken(0.3)).bg(color.lighten(0.1))) + .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2))) + .on_hover(move |color, entered, window, cx| { + if entered { + hover_state.update(cx, |state, cx| state.preview_color(color, window, cx)); + } + }) + .on_click(move |color, _, window, cx| { + click_state.update(cx, |state, cx| state.select_color(color, window, cx)); + }) } fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { + self.state + .update(cx, |state, cx| state.sync_pending_value(window, cx)); + + let active_tab = self.state.read(cx).active_tab(); + let (slider_color, hovered_color) = { + let state = self.state.read(cx); + let slider_color = state + .displayed_color() + .unwrap_or_else(|| hsla(0., 0., 0., 1.)); + (slider_color, state.preview()) + }; + let tab_state = self.state.clone(); + + v_flex() + .p_0p5() + .gap_3() + .child( + TabBar::new("mode") + .segmented() + .selected_index(active_tab) + .on_click(move |ix: &usize, _, cx| { + tab_state.update(cx, |state, cx| state.set_active_tab(*ix, cx)); + }) + .child(Tab::new().flex_1().label(t!("ColorPicker.Palette"))) + .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))), + ) + .child(match active_tab { + 0 => self.render_palette_panel(cx).into_any_element(), + _ => self + .render_slider_tab_panel(slider_color, cx) + .into_any_element(), + }) + .when_some(hovered_color, |this, hovered_color| { + this.child(Separator::horizontal()).child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .bg(hovered_color) + .flex_shrink_0() + .border_1() + .border_color(hovered_color.darken(0.2)) + .size_5() + .rounded(cx.theme().radius), + ) + .child(Input::new(self.state.read(cx).hex_input()).small().px_2p5()), + ) + }) + } + + fn render_palette_panel(&self, cx: &mut App) -> impl IntoElement { let featured_colors = self.featured_colors.clone().unwrap_or(vec![ cx.theme().red, cx.theme().red_light, @@ -309,29 +205,16 @@ impl ColorPicker { cx.theme().magenta_light, ]); - let state = self.state.clone(); - // If the input value is empty, fill it with the current value. - let input_value = state.read(cx).state.read(cx).value(); - if input_value.is_empty() - && let Some(value) = state.read(cx).value - { - state.update(cx, |state, cx| { - state.state.update(cx, |input, cx| { - input.set_value(value.to_hex(), window, cx); - }); - }); - } - v_flex() .gap_3() .child( h_flex().gap_1().children( featured_colors .iter() - .map(|color| self.render_item(*color, true, window, cx)), + .map(|color| self.render_item(*color, cx)), ), ) - .child(Divider::horizontal()) + .child(Separator::horizontal()) .child( v_flex() .gap_1() @@ -340,36 +223,204 @@ impl ColorPicker { sub_colors .iter() .rev() - .map(|color| self.render_item(*color, true, window, cx)), + .map(|color| self.render_item(*color, cx)), ) })), ) - .when_some(state.read(cx).hovered_color, |this, hovered_color| { - this.child(Divider::horizontal()).child( - h_flex() - .gap_2() - .items_center() - .child( - div() - .bg(hovered_color) - .flex_shrink_0() - .border_1() - .border_color(hovered_color.darken(0.2)) - .size_5() - .rounded(cx.theme().radius), - ) - .child(Input::new(&state.read(cx).state).small()), - ) + } + + fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement { + let sliders = self.state.read(cx).sliders().clone(); + let steps = 96usize; + let hue_colors = (0..steps) + .map(|ix| { + let h = ix as f32 / (steps.saturating_sub(1)) as f32; + hsla(h, 1.0, 0.5, 1.0) + }) + .collect::>(); + let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0); + let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0); + let lightness_colors = (0..steps) + .map(|ix| { + let l = ix as f32 / (steps.saturating_sub(1)) as f32; + hsla(slider_color.h, 1.0, l, 1.0) }) + .collect::>(); + let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0); + let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0); + + let label_color = cx.theme().foreground.opacity(0.7); + + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(t!("ColorPicker.Hue")), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track(hue_colors, cx)) + .child( + Slider::new(sliders.hue()) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.h * 360.)), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(t!("ColorPicker.Saturation")), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track_gradient( + saturation_start, + saturation_end, + cx, + )) + .child( + Slider::new(sliders.saturation()) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.s * 100.)), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(t!("ColorPicker.Lightness")), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track(lightness_colors, cx)) + .child( + Slider::new(sliders.lightness()) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.l * 100.)), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .min_w_16() + .text_xs() + .text_color(label_color) + .child(t!("ColorPicker.Alpha")), + ) + .child( + div() + .relative() + .flex() + .items_center() + .flex_1() + .h_8() + .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx)) + .child( + Slider::new(sliders.alpha()) + .flex_1() + .bg(cx.theme().transparent), + ), + ) + .child( + div() + .w_10() + .text_xs() + .text_color(label_color) + .text_align(TextAlign::Right) + .child(format!("{:.0}", slider_color.a * 100.)), + ), + ) } - fn resolved_corner(&self, bounds: Bounds) -> Point { - bounds.corner(match self.anchor { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, - }) + fn render_slider_track(&self, colors: Vec, _: &App) -> impl IntoElement { + h_flex() + .absolute() + .left_0() + .right_0() + .h_2_5() + .overflow_hidden() + .children( + colors + .into_iter() + .map(|color| div().flex_1().h_full().bg(color)), + ) + } + + fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement { + div() + .absolute() + .left_0() + .right_0() + .h_2_5() + .overflow_hidden() + .bg(linear_gradient( + 90., + linear_color_stop(start, 0.), + linear_color_stop(end, 1.), + )) } } @@ -382,7 +433,7 @@ impl Sizable for ColorPicker { impl Focusable for ColorPicker { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.state.read(cx).focus_handle.clone() + self.state.focus_handle(cx) } } @@ -395,115 +446,111 @@ impl Styled for ColorPicker { impl RenderOnce for ColorPicker { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); - let bounds = state.bounds; - let display_title: SharedString = if let Some(value) = state.value { + let display_title: SharedString = if let Some(value) = state.value() { value.to_hex() } else { "".to_string() } .into(); - let is_focused = state.focus_handle.is_focused(window); - let focus_handle = state.focus_handle.clone().tab_stop(true); + let open = state.is_open(); + let value = state.value(); + let focus_handle = self.state.focus_handle(cx); + let open_state = self.state.clone(); + let popover_state = self.state.clone(); - div() - .id(self.id.clone()) - .key_context(CONTEXT) + BaseColorPicker::new(self.id.clone()) + .open(open) .track_focus(&focus_handle) - .on_action(window.listener_for(&self.state, ColorPickerState::on_escape)) - .on_action(window.listener_for(&self.state, ColorPickerState::on_confirm)) + .when_some(self.label.clone(), |this, label| { + this.accessibility_label(label) + }) + .on_open_change(move |open, _, cx| { + open_state.update(cx, |state, cx| state.set_open(open, cx)); + }) .child( - h_flex() - .id("color-picker-input") - .gap_2() - .items_center() - .input_text_size(self.size) - .line_height(relative(1.)) - .rounded(cx.theme().radius) - .refine_style(&self.style) - .when_some(self.icon.clone(), |this, icon| { - this.child( - Button::new("btn") - .track_focus(&focus_handle) - .ghost() - .selected(state.open) - .with_size(self.size) - .icon(icon.clone()), - ) + Popover::new("popover") + .open(open) + .w_72() + .on_open_change(move |open: &bool, _, cx| { + popover_state.update(cx, |state, cx| state.set_open(*open, cx)); }) - .when_none(&self.icon, |this| { - this.child( - div() - .id("color-picker-square") - .bg(cx.theme().background) - .border_1() - .m_1() - .border_color(cx.theme().input) - .shadow_xs() - .rounded(cx.theme().radius) - .overflow_hidden() - .size_with(self.size) - .when_some(state.value, |this, value| { - this.bg(value) - .border_color(value.darken(0.3)) - .when(state.open, |this| this.border_2()) - }) - .when(!display_title.is_empty(), |this| { - this.tooltip(move |_, cx| { - cx.new(|_| Tooltip::new(display_title.clone())).into() - }) - }), - ) - .focus_ring(is_focused, px(0.), window, cx) + .trigger(ColorPickerButton { + id: "trigger".into(), + size: self.size, + label: self.label.clone(), + value, + tooltip: if display_title.is_empty() { + None + } else { + Some(display_title.clone()) + }, + icon: self.icon.clone(), + selected: false, }) - .when_some(self.label.clone(), |this, label| this.child(label)) - .on_click(window.listener_for(&self.state, ColorPickerState::toggle_picker)) - .child( - canvas( - { - let state = self.state.clone(); - move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ), + .child(self.render_colors(window, cx)), ) - .when(state.open, |this| { + } +} + +#[derive(IntoElement)] +struct ColorPickerButton { + id: ElementId, + selected: bool, + icon: Option, + value: Option, + size: Size, + label: Option, + tooltip: Option, +} + +impl Selectable for ColorPickerButton { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Sizable for ColorPickerButton { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for ColorPickerButton { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let has_icon = self.icon.is_some(); + h_flex() + .id(self.id) + .gap_2() + .children(self.icon) + .when(!has_icon, |this| { this.child( - deferred( - anchored() - .anchor(self.anchor) - .snap_to_window_with_margin(px(8.)) - .position(self.resolved_corner(bounds)) - .child( - div() - .occlude() - .map(|this| match self.anchor { - Corner::TopLeft | Corner::TopRight => this.mt_1p5(), - Corner::BottomLeft | Corner::BottomRight => this.mb_1p5(), - }) - .w_72() - .overflow_hidden() - .rounded(cx.theme().radius) - .p_3() - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .bg(cx.theme().popover) - .text_color(cx.theme().popover_foreground) - .child(self.render_colors(window, cx)) - .on_mouse_up_out( - MouseButton::Left, - window.listener_for(&self.state, |state, _, window, cx| { - state.on_escape(&Cancel, window, cx) - }), - ), - ), - ) - .with_priority(1), + div() + .id("square") + .bg(cx.theme().tokens.background) + .border_1() + .border_color(cx.theme().input) + .rounded(cx.theme().radius) + .overflow_hidden() + .size_with(self.size) + .when_some(self.value, |this, value| { + this.bg(value) + .border_color(value.darken(0.3)) + .when(self.selected, |this| this.border_2()) + }) + .when_some(self.tooltip, |this, tooltip| { + this.managed_tooltip(move |window, cx| { + Tooltip::new(tooltip.clone()).build(window, cx) + }) + }), ) }) + .when_some(self.label, |this, label| this.child(label)) } } diff --git a/vendor/gpui-component/src/combobox.rs b/vendor/gpui-component/src/combobox.rs new file mode 100644 index 00000000..4ddef464 --- /dev/null +++ b/vendor/gpui-component/src/combobox.rs @@ -0,0 +1,1595 @@ +use gpui::{ + AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity, + EventEmitter, FocusHandle, Focusable, Hsla, InteractiveElement, IntoElement, Length, + MouseDownEvent, ParentElement, Pixels, Render, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, deferred, div, + prelude::FluentBuilder, px, rems, +}; + +use rust_i18n::t; + +pub use crate::select::Caret; + +use crate::ThemeStyled as _; +use crate::{ + ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size, + StyleSized, StyledExt, h_flex, + input::{clear_button, input_style}, + list::{List, ListState}, + searchable_list::{ + SearchableListAdapter, SearchableListChange, SearchableListDelegate, SearchableListItem, + SearchableListState, + }, + v_flex, +}; +use gpui_base::{Combobox as BaseCombobox, GlobalState}; + +// MARK: ComboboxTriggerContext + +/// Context passed to the `render_trigger` closure on [`Combobox`]. +/// +/// The fields are private and reached through the methods below, so that a new +/// one can be added without breaking the trigger renderers. +pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> { + selection: &'a [(IndexPath, D::Item)], + placeholder: Option<&'a SharedString>, + open: bool, + disabled: bool, + size: Size, +} + +impl<'a, D: SearchableListDelegate + 'static> ComboboxTriggerContext<'a, D> { + /// The items currently selected, empty when the combobox has no value. + pub fn selection(&self) -> &'a [(IndexPath, D::Item)] { + self.selection + } + + pub fn placeholder(&self) -> Option<&'a SharedString> { + self.placeholder + } + + /// Whether the dropdown list is showing. + pub fn is_open(&self) -> bool { + self.open + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn size(&self) -> Size { + self.size + } +} + +// MARK: ComboboxChange + +/// Back-compat alias — new code should use [`SearchableListChange`] directly. +pub type ComboboxChange = SearchableListChange; + +// MARK: ComboboxOptions + +struct ComboboxOptions { + style: StyleRefinement, + size: Size, + cleanable: bool, + placeholder: Option, + search_placeholder: Option, + menu_width: Length, + menu_max_h: Length, + disabled: bool, + appearance: bool, + focus_ring_enabled: bool, + trigger_icon: Option, + check_icon: Option, +} + +impl Default for ComboboxOptions { + fn default() -> Self { + Self { + style: StyleRefinement::default(), + size: Size::default(), + cleanable: false, + placeholder: None, + search_placeholder: None, + menu_width: Length::Auto, + menu_max_h: rems(20.).into(), + disabled: false, + appearance: true, + focus_ring_enabled: true, + trigger_icon: None, + check_icon: None, + } + } +} + +// MARK: ComboboxState + +/// State of the [`Combobox`] component. +pub struct ComboboxState +where + ::Value: PartialEq + Clone, +{ + pub(crate) state: SearchableListState, + + // Combobox-specific fields + multiple: bool, + searchable: bool, + trigger_icon: Option, + check_icon: Option, + render_trigger: Option< + Box, &mut Window, &mut App) -> AnyElement + 'static>, + >, + footer: Option AnyElement + 'static>>, + focus_ring_enabled: bool, +} + +/// Events emitted by [`ComboboxState`]. +pub enum ComboboxEvent +where + ::Value: PartialEq + Clone, +{ + /// Emitted on every toggle (item added or removed). + Change(Vec<::Value>), + /// Emitted when the popover closes. + Confirm(Vec<::Value>), +} + +impl ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + /// Create a new `Combobox` state. + pub fn new( + delegate: D, + selected_indices: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let weak = cx.entity().downgrade(); + let weak_confirm = weak.clone(); + let weak_cancel = weak.clone(); + let weak_empty = weak; + + let state = SearchableListState::new( + delegate, + selected_indices, + move |selected_index, _secondary, window, cx| { + cx.defer_in(window, { + let weak_confirm = weak_confirm.clone(); + move |list_state, window, cx| { + let Some(index) = selected_index else { + return; + }; + + let Some(item) = list_state.delegate().delegate.item(index).cloned() else { + return; + }; + + let ix = index; + + let Some(weak) = weak_confirm.upgrade() else { + return; + }; + + let (multiple, mut selection) = { + let s = weak.read(cx); + (s.multiple, s.state.selection.clone()) + }; + + let changes = Self::selection_changes(multiple, &selection, ix, &item); + + let before_indices: Vec = + selection.iter().map(|(ix, _)| *ix).collect(); + + // on_will_change is called directly — entity-handle access would + // re-enter the ListState lock that defer_in holds for this callback. + list_state + .delegate_mut() + .delegate + .on_will_change(&mut selection, &changes); + + let after_indices: Vec = + selection.iter().map(|(ix, _)| *ix).collect(); + let changed = before_indices != after_indices; + let should_close = changed && !multiple; + + let new_selection = weak_confirm.update(cx, |this, cx| { + this.state.selection = selection; + + if changed { + cx.emit(ComboboxEvent::Change(this.selected_values())); + cx.notify(); + } + + if should_close { + cx.emit(ComboboxEvent::Confirm(this.selected_values())); + this.set_open(false, cx); + this.focus(window, cx); + } + + this.state.selection.clone() + }); + + // Sync snapshot and fire on_confirm directly — same re-entrancy guard. + if let Ok(new_selection) = new_selection { + list_state + .delegate_mut() + .update_selection_snapshot(new_selection.clone()); + + if should_close { + list_state + .delegate_mut() + .delegate + .on_confirm(&new_selection); + } + } + } + }); + }, + // on_cancel — close and emit Confirm with current values + move |_final_selected_index, window, cx| { + cx.defer_in(window, { + let weak_cancel = weak_cancel.clone(); + move |_list_state, window, cx| { + _ = weak_cancel.update(cx, |this, cx| { + cx.emit(ComboboxEvent::Confirm(this.selected_values())); + this.set_open(false, cx); + this.focus(window, cx); + }); + } + }); + }, + // on_render_empty + move |window, cx| { + if let Some(empty) = weak_empty + .upgrade() + .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx))) + { + empty + } else { + h_flex() + .justify_center() + .py_6() + .text_color(cx.theme().muted_foreground.opacity(0.6)) + .child(Icon::new(IconName::Inbox).size(px(28.))) + .into_any_element() + } + }, + Self::on_blur, + window, + cx, + ); + + Self { + state, + multiple: false, + searchable: false, + trigger_icon: None, + check_icon: None, + render_trigger: None, + footer: None, + focus_ring_enabled: true, + } + } + + /// Enable multi-select mode. + /// + /// When `true`, clicking an item toggles it in the selection and the popover stays open. + /// When `false` (default), clicking an item replaces the selection and closes the popover. + pub fn multiple(mut self, multiple: bool) -> Self { + self.multiple = multiple; + self + } + + /// Enable or disable the search input at the top of the dropdown. + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; + self + } + + /// Return the currently selected values. + pub fn selected_values(&self) -> Vec<::Value> { + self.state.selected_values() + } + + /// Return the first selected value, or `None` when nothing is selected. + /// + /// Convenience for single-select mode (`.multiple(false)`). + pub fn selected_value(&self) -> Option<::Value> { + self.state.selected_values().into_iter().next() + } + + /// Return the currently selected `(IndexPath, Item)` pairs. + pub fn selection(&self) -> &[(IndexPath, D::Item)] { + self.state.selection() + } + + /// Replace the entire selection set by item values. + /// + /// Values are resolved through the current delegate. Values that cannot be resolved are + /// ignored. This updates the committed selection and snapshot without emitting a + /// [`ComboboxEvent`]. + pub fn set_selected_values( + &mut self, + values: &[::Value], + window: &mut Window, + cx: &mut Context, + ) { + let selected_indices = { + let list = self.state.list.read(cx); + let delegate = &list.delegate().delegate; + + values + .iter() + .filter_map(|value| delegate.position(value)) + .collect::>() + }; + + self.set_selected_indices(selected_indices, window, cx); + } + + /// Replace the entire selection set. + pub fn set_selected_indices( + &mut self, + indices: impl IntoIterator, + _window: &mut Window, + cx: &mut Context, + ) { + self.state.set_selected_indices(indices, cx); + self.state.sync_snapshot(cx); + cx.notify(); + } + + /// Add a single index to the selection, if not already present, returning whether it was added. + pub fn add_selected_index(&mut self, index: IndexPath, cx: &mut Context) -> bool { + let added = self.state.add_selected_index(index, cx); + + if added { + self.state.sync_snapshot(cx); + cx.notify(); + } + + added + } + + /// Remove a single index from the selection, returning whether it was removed. + pub fn remove_selected_index(&mut self, index: IndexPath, cx: &mut Context) -> bool { + let removed = self.state.remove_selected_index(index); + + if removed { + self.state.sync_snapshot(cx); + } + + removed + } + + /// Clear all selected values. + pub fn clear_selection(&mut self, cx: &mut Context) { + self.state.selection.clear(); + self.state.sync_snapshot(cx); + cx.emit(ComboboxEvent::Change(self.selected_values())); + cx.notify(); + } + + /// Replace the underlying delegate (item data source). + pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context) { + self.state.list.update(cx, |list, _| { + list.delegate_mut().delegate = items; + }); + } + + /// Focus the trigger. + pub fn focus(&self, window: &mut Window, cx: &mut App) { + self.state.focus_handle.focus(window, cx); + } + + /// Returns the search query. + pub fn query(&self, cx: &App) -> SharedString { + self.state.list.read(cx).query_input.read(cx).value() + } + + /// Sets the search query and updates the filtered items. + pub fn set_query(&self, query: impl Into, window: &mut Window, cx: &mut App) { + let query = query.into(); + self.state.list.update(cx, |list, cx| { + list.set_query(query.as_ref(), window, cx); + }); + } + + fn selection_changes( + multiple: bool, + selection: &[(IndexPath, D::Item)], + ix: IndexPath, + item: &D::Item, + ) -> Vec { + let is_selected = selection + .iter() + .any(|(_, selected_item)| selected_item.value() == item.value()); + + if multiple { + if is_selected { + vec![SearchableListChange::Deselect { index: ix }] + } else { + vec![SearchableListChange::Select { index: ix }] + } + } else { + let mut changes: Vec = selection + .iter() + .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix }) + .collect(); + changes.push(SearchableListChange::Select { index: ix }); + changes + } + } + + /// Process an item click: single-select replaces the selection and closes; multi-select toggles. + /// + /// Calls `delegate.on_will_change` before committing and `delegate.on_confirm` when closing. + #[allow(dead_code)] + pub(crate) fn handle_item_select( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context, + ) { + let Some(item) = self + .state + .list + .read(cx) + .delegate() + .delegate + .item(ix) + .cloned() + else { + return; + }; + + let changes = Self::selection_changes(self.multiple, &self.state.selection, ix, &item); + + let mut selection = self.state.selection.clone(); + let before_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); + + self.state.list.update(cx, |list, _cx| { + list.delegate_mut() + .delegate + .on_will_change(&mut selection, &changes); + }); + + let after_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); + let changed = before_indices != after_indices; + let should_close = changed && !self.multiple; + + self.state.selection = selection; + self.state.sync_snapshot(cx); + + if changed { + cx.emit(ComboboxEvent::Change(self.selected_values())); + cx.notify(); + } + + if should_close { + let final_selection = self.state.selection.clone(); + self.state.list.update(cx, |list, _cx| { + list.delegate_mut().delegate.on_confirm(&final_selection); + }); + + cx.emit(ComboboxEvent::Confirm(self.selected_values())); + self.set_open(false, cx); + self.focus(window, cx); + } + } + + fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { + if self.state.list.read(cx).is_focused(window, cx) + || self.state.focus_handle.is_focused(window) + { + return; + } + + self.set_open(false, cx); + cx.notify(); + } + + fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + cx.stop_propagation(); + + self.set_open(!self.state.open, cx); + + if self.state.open { + self.state.list.focus_handle(cx).focus(window, cx); + } else { + cx.emit(ComboboxEvent::Confirm(self.selected_values())); + self.focus(window, cx); + } + + cx.notify(); + } + + /// Close the menu when a press lands outside the popup. + /// + /// A press on the trigger is left to propagate: swallowing it here would keep nested + /// controls (a tag remove button, the clear button) from ever seeing the press, and + /// `toggle_menu` closes the menu on release anyway. + fn dismiss(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { + if !self.state.open || self.state.bounds.contains(&event.position) { + return; + } + + cx.stop_propagation(); + cx.emit(ComboboxEvent::Confirm(self.selected_values())); + + self.set_open(false, cx); + self.focus(window, cx); + cx.notify(); + } + + fn set_open(&mut self, open: bool, cx: &mut Context) { + self.state.open = open; + self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx)); + + cx.notify(); + } + + fn clean(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); + self.clear_selection(cx); + } + + fn default_trigger_body(&self, _window: &mut Window, cx: &mut Context) -> AnyElement { + let placeholder_text = self + .state + .placeholder + .clone() + .unwrap_or_else(|| t!("Combobox.placeholder").into()); + + if self.state.selection.is_empty() { + return div() + .text_color(cx.theme().muted_foreground) + .child(placeholder_text) + .into_any_element(); + } + + if self.multiple { + let items: Vec = self + .state + .selection + .iter() + .map(|(_, i)| i.title()) + .collect(); + + div() + .w_full() + .overflow_hidden() + .whitespace_nowrap() + .truncate() + .child(items.join(", ")) + .into_any_element() + } else { + let title = self + .state + .selection + .first() + .map(|(_, i)| i.title()) + .unwrap_or_default(); + + div() + .w_full() + .overflow_hidden() + .whitespace_nowrap() + .truncate() + .child(title) + .into_any_element() + } + } +} + +impl Render for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let searchable = self.searchable; + let is_focused = self.state.focus_handle.is_focused(window); + let show_clean = self.state.cleanable && !self.state.selection.is_empty(); + let bounds = self.state.bounds; + let allow_open = !self.state.disabled; + let outline_visible = self.state.open || (is_focused && !self.state.disabled); + let disabled = self.state.disabled; + + let (bg, fg) = input_style(disabled, cx); + + self.state.list.update(cx, |list, cx| { + list.set_searchable(searchable, cx); + list.delegate_mut().size = self.state.size; + list.delegate_mut().check_icon = self.check_icon.clone(); + }); + + let selection = &self.state.selection; + let placeholder = self.state.placeholder.as_ref(); + let open = self.state.open; + let size = self.state.size; + let has_custom_trigger = self.render_trigger.is_some(); + + let trigger_body = if let Some(render_trigger) = &self.render_trigger { + let trigger = ComboboxTriggerContext { + selection, + placeholder, + open, + disabled, + size, + }; + + render_trigger(&trigger, window, cx) + } else { + self.default_trigger_body(window, cx) + }; + + let trailing: AnyElement = if has_custom_trigger { + div().into_any_element() + } else if show_clean { + clear_button(cx) + .map(|this| { + if disabled { + this.disabled(true) + } else { + this.on_click(cx.listener(Self::clean)) + } + }) + .into_any_element() + } else if let Some(icon) = self.trigger_icon.clone() { + icon.xsmall() + .text_color(cx.theme().muted_foreground) + .into_any_element() + } else { + Caret::new(size) + .text_color(cx.theme().muted_foreground) + .into_any_element() + }; + + let toggle_handler: Option> = + if allow_open { + Some(Box::new(cx.listener(Self::toggle_menu))) + } else { + None + }; + + let footer_el = self.footer.as_ref().map(|f| f(window, cx)); + + let dismiss_handler: Box = + Box::new(cx.listener(Self::dismiss)); + + div().size_full().relative().child( + div() + .relative() + .on_prepaint({ + let state = cx.entity(); + move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds) + }) + .child(render_trigger_container( + disabled, + self.state.appearance, + self.focus_ring_enabled, + self.state.size, + &self.state.style, + bg, + fg, + outline_visible, + allow_open, + trigger_body, + trailing, + toggle_handler, + window, + cx, + )) + .when(self.state.open, |this| { + this.child( + deferred(render_popup_shell( + ("combobox-popup", cx.entity_id()), + &self.state.list, + self.state.menu_width, + self.state.search_placeholder.clone(), + self.state.size, + self.state.menu_max_h, + bounds, + footer_el, + dismiss_handler, + cx, + )) + .with_priority(gpui_base::POPUP_PRIORITY), + ) + }), + ) + } +} + +impl EventEmitter> for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ +} +impl EventEmitter for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ +} + +impl Focusable for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.state.open { + self.state.list.focus_handle(cx) + } else { + self.state.focus_handle.clone() + } + } +} + +// MARK: Combobox element + +/// A combo box with support for single and multi-select. +/// +/// Clicking an item toggles it in the selection; the dropdown stays open until the user +/// presses Escape or clicks outside. +#[derive(IntoElement)] +pub struct Combobox +where + ::Value: PartialEq + Clone, +{ + id: ElementId, + state: Entity>, + options: ComboboxOptions, + render_trigger: Option< + Box, &mut Window, &mut App) -> AnyElement + 'static>, + >, + footer: Option AnyElement + 'static>>, + empty: Option AnyElement + 'static>>, +} + +impl Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + pub fn new(state: &Entity>) -> Self { + Self { + id: ("multi-combo-box", state.entity_id()).into(), + state: state.clone(), + options: ComboboxOptions::default(), + render_trigger: None, + footer: None, + empty: None, + } + } + + /// Set the width of the dropdown menu. + pub fn menu_width(mut self, width: impl Into) -> Self { + self.options.menu_width = width.into(); + self + } + + /// Set the maximum height of the dropdown menu. + pub fn menu_max_h(mut self, max_h: impl Into) -> Self { + self.options.menu_max_h = max_h.into(); + self + } + + /// Set the placeholder text shown when no items are selected. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.options.placeholder = Some(placeholder.into()); + self + } + + /// Override the trigger chevron icon. + pub fn icon(mut self, icon: impl Into) -> Self { + self.options.trigger_icon = Some(icon.into()); + self + } + + /// Override the trailing check icon shown next to selected items. + pub fn check_icon(mut self, icon: impl Into) -> Self { + self.options.check_icon = Some(icon.into()); + self + } + + /// Set the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } + + /// Show a clear button when at least one item is selected. + pub fn cleanable(mut self, cleanable: bool) -> Self { + self.options.cleanable = cleanable; + self + } + + /// Set the disabled state. + pub fn disabled(mut self, disabled: bool) -> Self { + self.options.disabled = disabled; + self + } + + /// Set a custom closure that renders the empty-state element. + pub fn empty( + mut self, + builder: impl Fn(&mut Window, &App) -> E + 'static, + ) -> Self { + self.empty = Some(Box::new(move |window, cx| { + builder(window, cx).into_any_element() + })); + self + } + + /// Control whether the trigger shows a border and background. + pub fn appearance(mut self, appearance: bool) -> Self { + self.options.appearance = appearance; + self + } + + /// Override the entire trigger element. + pub fn render_trigger( + mut self, + f: impl Fn(&ComboboxTriggerContext, &mut Window, &mut App) -> E + 'static, + ) -> Self { + self.render_trigger = Some(Box::new(move |trigger, window, cx| { + f(trigger, window, cx).into_any_element() + })); + self + } + + /// Render an element below a separator at the bottom of the dropdown. + pub fn footer( + mut self, + f: impl Fn(&mut Window, &mut App) -> E + 'static, + ) -> Self { + self.footer = Some(Box::new(move |window, cx| f(window, cx).into_any_element())); + self + } +} + +impl Sizable for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn with_size(mut self, size: impl Into) -> Self { + self.options.size = size.into(); + self + } +} + +impl crate::FocusableExt for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn focus_ring(mut self, enabled: bool) -> Self { + self.options.focus_ring_enabled = enabled; + self + } + + fn is_focus_ring_enabled(&self) -> bool { + self.options.focus_ring_enabled + } +} + +impl Styled for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.options.style + } +} + +impl RenderOnce for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let disabled = self.options.disabled; + let focus_handle = self.state.read(cx).state.focus_handle.clone(); + let render_trigger = self.render_trigger; + let footer = self.footer; + let empty = self.empty; + let opts = self.options; + + self.state.update(cx, |this, _| { + this.state.style = opts.style; + this.state.size = opts.size; + this.state.cleanable = opts.cleanable; + this.state.placeholder = opts.placeholder; + this.state.search_placeholder = opts.search_placeholder; + this.state.menu_width = opts.menu_width; + this.state.menu_max_h = opts.menu_max_h; + this.state.disabled = opts.disabled; + this.state.appearance = opts.appearance; + this.focus_ring_enabled = opts.focus_ring_enabled; + this.trigger_icon = opts.trigger_icon; + this.check_icon = opts.check_icon; + this.render_trigger = render_trigger; + this.footer = footer; + + if let Some(empty) = empty { + this.state.empty = Some(empty); + } + }); + + let is_open = self.state.read(cx).state.open; + let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx); + let open_state = self.state.clone(); + let confirm_state = self.state.clone(); + + BaseCombobox::new(self.id) + .open(is_open) + .disabled(disabled) + .focus_handle(&focus_handle) + .content_focus_handle(&content_focus_handle) + .on_open_change(move |open, _, cx| { + open_state.update(cx, |state, cx| state.set_open(open, cx)); + }) + // This combobox commits its pending selection when the popup + // closes, so it listens for dismissal rather than Confirm. + .on_dismiss(move |_, cx| { + confirm_state.update(cx, |state, cx| { + cx.emit(ComboboxEvent::Confirm(state.selected_values())); + }); + }) + .size_full() + .child(self.state) + } +} + +// MARK: Rendering helpers + +/// Renders the styled trigger container. +#[allow(clippy::too_many_arguments)] +fn render_trigger_container( + disabled: bool, + appearance: bool, + focus_ring_enabled: bool, + size: Size, + style: &StyleRefinement, + bg: Hsla, + fg: Hsla, + outline_visible: bool, + allow_open: bool, + trigger_body: AnyElement, + trailing: AnyElement, + toggle_handler: Option>, + window: &Window, + cx: &mut App, +) -> impl IntoElement { + div() + .id("input") + .relative() + .flex() + .items_center() + .justify_between() + .border_1() + .border_color(cx.theme().transparent) + .when(appearance, |this| { + this.bg(bg) + .text_color(fg) + .when(disabled, |this| this.opacity(0.5)) + .border_color(cx.theme().input) + .rounded(cx.theme().radius) + }) + .input_size(size) + .input_text_size(size) + .refine_style(style) + .when(outline_visible && appearance, |this| { + this.border_1().border_color(cx.theme().ring) + }) + .when( + outline_visible && appearance && focus_ring_enabled, + |this| this.focus_ring_style(window, cx), + ) + .when(allow_open, |this| { + this.when_some(toggle_handler, |this, handler| this.on_click(handler)) + }) + .child( + h_flex() + .id("inner") + .w_full() + .overflow_hidden() + .items_center() + .justify_between() + .gap_1() + .child(trigger_body) + .child(trailing), + ) +} + +/// Renders the deferred anchored popup shell containing the searchable list and optional footer. +#[allow(clippy::too_many_arguments)] +fn render_popup_shell( + id: impl Into, + list: &Entity>>, + menu_width: Length, + search_placeholder: Option, + size: Size, + menu_max_h: Length, + bounds: Bounds, + footer_el: Option, + dismiss_handler: Box, + cx: &mut App, +) -> AnyElement { + let has_footer = footer_el.is_some(); + + crate::popover::dropdown_popup( + id, + bounds, + v_flex() + .occlude() + .map(|this| match menu_width { + Length::Auto => this.w(bounds.size.width + px(2.)), + Length::Definite(w) => this.w(w), + }) + .popover_style(cx) + .child( + List::new(list) + .when_some(search_placeholder, |this, placeholder| { + this.search_placeholder(placeholder) + }) + .with_size(size) + .max_h(menu_max_h) + .paddings(Edges::all(px(4.))), + ) + .when(has_footer, |this| { + this.child( + div() + .border_t_1() + .border_color(cx.theme().border) + .p_1() + .when_some(footer_el, |this, el| this.child(el)), + ) + }) + .on_mouse_down_out(dismiss_handler), + cx, + ) + .into_any_element() +} + +// MARK: Tests + +#[cfg(test)] +mod tests { + use std::{cell::Cell, rc::Rc}; + + use gpui::{ + AppContext as _, Bounds, Context, Entity, Modifiers, MouseButton, MouseDownEvent, Pixels, + Point, Subscription, TestAppContext, point, px, size, + }; + + use crate::{ + IndexPath, + combobox::{Combobox, ComboboxEvent, ComboboxState}, + searchable_list::{ + SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState, + SearchableVec, + }, + }; + + struct TestComboboxEventCollector { + event_count: Rc>, + _subscription: Subscription, + } + + impl TestComboboxEventCollector { + fn new( + state: &Entity>>, + cx: &mut Context, + ) -> Self { + let event_count = Rc::new(Cell::new(0)); + let event_count_for_subscription = event_count.clone(); + let _subscription = cx.subscribe( + state, + move |_, _, _: &ComboboxEvent>, _| { + event_count_for_subscription.set(event_count_for_subscription.get() + 1); + }, + ); + + Self { + event_count, + _subscription, + } + } + } + + #[gpui::test] + fn test_combo_box_builder(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); + + let _cb = Combobox::new(&state) + .placeholder("Select language") + .search_placeholder("Search...") + .menu_width(gpui::px(300.)) + .menu_max_h(gpui::rems(15.)) + .cleanable(true) + .disabled(false) + .appearance(true); + }); + } + + #[gpui::test] + fn test_combo_box_search_filters_items(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); + + let count_before = state + .read(cx) + .state + .list + .read(cx) + .delegate() + .delegate + .items_count(0); + assert_eq!(count_before, 3); + + state.update(cx, |s, cx| { + s.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("Rust", window, cx); + }); + }); + + let count_after = state + .read(cx) + .state + .list + .read(cx) + .delegate() + .delegate + .items_count(0); + assert_eq!(count_after, 1); + }); + } + + #[gpui::test] + fn test_combo_box_set_query_updates_text_and_filters_items(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); + + state.update(cx, |state, cx| state.set_query(" Rust ", window, cx)); + + assert_eq!(state.read(cx).query(cx).as_ref(), " Rust "); + assert_eq!( + state + .read(cx) + .state + .list + .read(cx) + .delegate() + .delegate + .items_count(0), + 1, + ); + }); + } + + #[gpui::test] + fn test_multi_combo_box_builder(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| { + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) + .multiple(true) + .searchable(true) + }); + + let _cb = Combobox::new(&state) + .placeholder("Select frameworks") + .search_placeholder("Search...") + .menu_width(gpui::px(300.)) + .cleanable(true) + .disabled(false); + + assert_eq!(state.read(cx).selected_values(), vec!["React"]); + }); + } + + #[gpui::test] + fn test_combo_box_set_selected_values_uses_current_delegate(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |state, cx| { + state.set_selected_values(&["Vue", "Missing"], window, cx); + + assert_eq!(state.selected_values(), vec!["Vue"]); + assert_eq!( + state + .selection() + .iter() + .map(|(index, _)| *index) + .collect::>(), + vec![IndexPath::new(1)], + ); + assert_eq!( + state + .state + .list + .read(cx) + .delegate() + .selection_snapshot + .as_slice(), + state.selection(), + ); + + state.set_items(SearchableVec::new(vec!["Vue", "Rust", "Go"]), window, cx); + state.set_selected_values(&["Go", "Vue"], window, cx); + + assert_eq!(state.selected_values(), vec!["Go", "Vue"]); + assert_eq!( + state + .selection() + .iter() + .map(|(index, _)| *index) + .collect::>(), + vec![IndexPath::new(2), IndexPath::new(0)], + ); + assert_eq!( + state + .state + .list + .read(cx) + .delegate() + .selection_snapshot + .as_slice(), + state.selection(), + ); + + state.set_selected_values(&[], window, cx); + + assert!(state.selection().is_empty()); + assert!( + state + .state + .list + .read(cx) + .delegate() + .selection_snapshot + .is_empty() + ); + }); + }); + } + + #[gpui::test] + fn test_combo_box_set_selected_values_does_not_emit_events(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + let state = cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)) + }); + let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx))); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_selected_values(&["React", "Vue"], window, cx); + }); + }); + + cx.update(|_, cx| { + assert_eq!(collector.read(cx).event_count.get(), 0); + }); + } + + #[gpui::test] + fn test_combo_box_initial_selection_seeds_cursor(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| { + ComboboxState::new(items, vec![IndexPath::new(1)], window, cx).multiple(true) + }); + + let state_ref = state.read(cx); + assert_eq!( + state_ref.state.list.read(cx).selected_index(), + Some(IndexPath::new(1)), + "initial selected_indices should seed ListState.selected_index, not just the snapshot", + ); + assert_eq!(state_ref.selected_values(), vec!["Vue"]); + }); + } + + #[gpui::test] + fn test_multi_combo_box_toggle(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["React"]); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx)); + assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]); + + state.update(cx, |s, cx| s.remove_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["Vue"]); + }); + } + + #[gpui::test] + fn test_multi_combo_box_search_selection_uses_value_identity(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["React"]); + + state.update(cx, |s, cx| { + s.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("Vue", window, cx); + }); + }); + + state.read_with(cx, |s, cx| { + let selection = s.state.selection.clone(); + let list = s.state.list.read(cx); + let delegate = &list.delegate().delegate; + let ix = IndexPath::new(0); + let item = delegate.item(ix).expect("filtered item exists"); + + assert_eq!(item.value(), &"Vue"); + assert!( + !delegate.is_item_checked(ix, item, &selection, cx), + "filtered row 0 should not inherit React's checked state", + ); + }); + + state.update(cx, |s, cx| { + s.handle_item_select(IndexPath::new(0), window, cx); + }); + assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]); + }); + } + + #[gpui::test] + fn test_multi_combo_box_search_deselects_by_value(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + + state.update(cx, |s, cx| { + s.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("React", window, cx); + }); + }); + + state.update(cx, |s, cx| { + s.handle_item_select(IndexPath::new(0), window, cx); + }); + assert!(state.read(cx).selected_values().is_empty()); + }); + } + + #[gpui::test] + fn test_searchable_list_default_change_uses_value_identity(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let mut delegate = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let mut selection = vec![(IndexPath::new(1), "Vue")]; + + let _ = delegate.perform_search("Vue", window, cx); + delegate.on_will_change( + &mut selection, + &[SearchableListChange::Deselect { + index: IndexPath::new(0), + }], + ); + assert!(selection.is_empty()); + + delegate.on_will_change( + &mut selection, + &[SearchableListChange::Select { + index: IndexPath::new(0), + }], + ); + assert_eq!(selection, vec![(IndexPath::new(0), "Vue")]); + }); + } + + #[gpui::test] + fn test_multi_combo_box_clear(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| { + ComboboxState::new( + items, + vec![IndexPath::new(0), IndexPath::new(1)], + window, + cx, + ) + .multiple(true) + }); + + assert_eq!(state.read(cx).selected_values().len(), 2); + state.update(cx, |s, cx| s.clear_selection(cx)); + assert!(state.read(cx).selected_values().is_empty()); + }); + } + + #[gpui::test] + fn test_single_combo_box_mode(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx)); + + // Default mode is Single. + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["Rust"]); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx)); + assert_eq!(state.read(cx).selected_values(), &["Rust", "Go"]); + }); + } + + // Delegate that vetoes all selections via on_will_change by ignoring the changes. + struct VetoDelegate(SearchableVec<&'static str>); + + impl SearchableListDelegate for VetoDelegate { + type Item = &'static str; + + fn items_count(&self, section: usize) -> usize { + self.0.items_count(section) + } + + fn item(&self, ix: IndexPath) -> Option<&&'static str> { + self.0.item(ix) + } + + fn position(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.0.position(value) + } + + fn on_will_change( + &mut self, + _selection: &mut Vec<(IndexPath, &'static str)>, + _changes: &[SearchableListChange], + ) { + // Leave selection unchanged — acts as a veto. + } + } + + #[gpui::test] + fn test_on_will_change_veto(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let delegate = VetoDelegate(SearchableVec::new(vec!["Rust", "Go", "C++"])); + let state = cx.new(|cx| ComboboxState::new(delegate, vec![], window, cx)); + + // Pre-select an item directly so we can verify veto prevents changes. + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["Rust"]); + + // Simulate a click on index 1 via handle_item_select; on_will_change vetoes it. + state.update(cx, |s, cx| { + s.handle_item_select(IndexPath::new(1), window, cx); + }); + + // Selection must remain unchanged because on_will_change left it unmodified. + assert_eq!(state.read(cx).selected_values(), &["Rust"]); + }); + } + + fn left_press(position: Point) -> MouseDownEvent { + MouseDownEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + } + } + + #[gpui::test] + fn test_combo_box_dismiss_ignores_press_on_trigger(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + let state = cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)) + }); + let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx))); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.state.bounds = Bounds { + origin: point(px(10.), px(10.)), + size: size(px(200.), px(32.)), + }; + state.set_open(true, cx); + state.dismiss(&left_press(point(px(20.), px(20.))), window, cx); + }); + }); + + cx.update(|_, cx| { + assert!( + state.read(cx).state.open, + "a press on the trigger must reach nested controls, so the menu stays open \ + and `toggle_menu` closes it on release instead", + ); + assert_eq!(collector.read(cx).event_count.get(), 0); + }); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.dismiss(&left_press(point(px(20.), px(200.))), window, cx); + }); + }); + + cx.update(|_, cx| { + assert!(!state.read(cx).state.open); + assert_eq!( + collector.read(cx).event_count.get(), + 1, + "dismissing from outside the trigger emits Confirm", + ); + }); + } + + // Suppress unused import warning for SearchableListState in test module. + #[allow(unused)] + fn _uses_state(_: &SearchableListState) + where + ::Value: PartialEq + Clone, + { + } +} diff --git a/vendor/gpui-component/src/command/command.rs b/vendor/gpui-component/src/command/command.rs new file mode 100644 index 00000000..9e34de97 --- /dev/null +++ b/vendor/gpui-component/src/command/command.rs @@ -0,0 +1,259 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, DefiniteLength, Entity, IntoElement, RenderOnce, SharedString, + StyleRefinement, Styled, Window, rems, +}; + +use crate::IndexPath; +use crate::command::{ + item::{CommandEntry, CommandGroup, CommandItem}, + state::{CommandModel, CommandState, OnCancel, OnIndex, OnQuery}, +}; + +pub(crate) type CommandSlot = dyn Fn(&CommandState, &mut Window, &mut App) -> AnyElement; + +/// Presentation of a [`Command`], pushed into its state on every render. +#[derive(Clone)] +pub(crate) struct CommandOptions { + pub(crate) style: StyleRefinement, + pub(crate) placeholder: Option, + pub(crate) empty: Option>, + pub(crate) max_h: DefiniteLength, + pub(crate) bordered: bool, + pub(crate) header: Option>, + pub(crate) footer: Option>, +} + +impl Default for CommandOptions { + fn default() -> Self { + Self { + style: StyleRefinement::default(), + placeholder: None, + empty: None, + max_h: rems(18.75).into(), + bordered: true, + header: None, + footer: None, + } + } +} + +/// A command palette: a search field over a filtered list of commands. +/// +/// Entries and rendering policy are configured on each `Command`; interaction +/// state such as the query and highlighted item lives in [`CommandState`]. +/// +/// ```ignore +/// let state = cx.new(|cx| CommandState::new(window, cx)); +/// +/// Command::new(&state) +/// .group( +/// CommandGroup::new().label("Suggestions") +/// .item(CommandItem::new().label("Calendar").icon(IconName::Calendar)), +/// ) +/// .placeholder("Type a command or search...") +/// ``` +#[derive(IntoElement)] +pub struct Command { + state: Entity, + entries: Vec, + searchable: bool, + filterable: bool, + on_query: Option>, + on_select: Option>, + on_confirm: Option>, + on_cancel: Option>, + options: CommandOptions, +} + +impl Command { + /// Render the palette held by `state`. + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + entries: Vec::new(), + searchable: true, + filterable: true, + on_query: None, + on_select: None, + on_confirm: None, + on_cancel: None, + options: CommandOptions::default(), + } + } + + /// Add an ungrouped command item. + pub fn item(mut self, item: CommandItem) -> Self { + self.entries.push(CommandEntry::Item(item)); + self + } + + /// Add multiple ungrouped command items. + pub fn items(mut self, items: impl IntoIterator) -> Self { + self.entries + .extend(items.into_iter().map(CommandEntry::Item)); + self + } + + /// Add a group of command items. + pub fn group(mut self, group: CommandGroup) -> Self { + self.entries.push(CommandEntry::Group(group)); + self + } + + /// Add a separator between the preceding and following entries. + pub fn separator(mut self) -> Self { + self.entries.push(CommandEntry::Separator); + self + } + + /// Show or hide the query field and local filtering. + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; + self + } + + /// Keep the query field but toggle the local filtering, default: `true`. + /// + /// Turn it off when an external source already answers the query, such as + /// an async search: every supplied item stays visible, the query still + /// reports through [`Self::on_query`], and a query change hands the + /// highlight back to the first item instead of a local textual match. + pub fn filterable(mut self, filterable: bool) -> Self { + self.filterable = filterable; + self + } + + /// Run a callback after a searchable query actually changes and the + /// current [`CommandState`] update releases its lease. + pub fn on_query(mut self, callback: F) -> Self + where + F: Fn(&str, &mut Window, &mut App) + 'static, + { + self.on_query = Some(Rc::new(callback)); + self + } + + /// Run a callback after the highlighted item's original index path changes and the current + /// [`CommandState`] update releases its lease. + /// + /// For [`Self::items`], `section` is 0 and `row` is the item's position in + /// the supplied iterator. Explicit groups use their group and item + /// positions and follow the implicit ungrouped section when both forms are + /// mixed. Local filtering never changes these coordinates. + pub fn on_select(mut self, callback: F) -> Self + where + F: Fn(IndexPath, &mut Window, &mut App) + 'static, + { + self.on_select = Some(Rc::new(callback)); + self + } + + /// Run a callback with the confirmed item's original index path after its Action is dispatched, + /// provided the source window remains live. The callback runs after the + /// current [`CommandState`] update releases its lease. + /// The path follows the same input-model coordinates as [`Self::on_select`]. + pub fn on_confirm(mut self, callback: F) -> Self + where + F: Fn(IndexPath, &mut Window, &mut App) + 'static, + { + self.on_confirm = Some(Rc::new(callback)); + self + } + + /// Run a callback synchronously before an empty-query Cancel action + /// propagates. A hosting Dialog should perform the dismissal after this + /// callback instead of being closed by the callback itself. + pub fn on_cancel(mut self, callback: F) -> Self + where + F: Fn(&mut Window, &mut App) + 'static, + { + self.on_cancel = Some(Rc::new(callback)); + self + } + + /// Set the placeholder of the search field. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.options.placeholder = Some(placeholder.into()); + self + } + + /// Render custom content when no command matches the query. + pub fn empty(mut self, f: F) -> Self + where + F: Fn(&CommandState, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.options.empty = Some(Rc::new(move |state, window, cx| { + f(state, window, cx).into_any_element() + })); + self + } + + /// Set the max height of the list, default: 18.75rem (300px). + pub fn max_h(mut self, height: impl Into) -> Self { + self.options.max_h = height.into(); + self + } + + /// Set whether to draw the surrounding border and rounding, default: `true`. + /// + /// Turn it off when the palette already sits inside a frame of its own, + /// such as a [`crate::Dialog`]. + pub fn bordered(mut self, bordered: bool) -> Self { + self.options.bordered = bordered; + self + } + + /// Render a custom element above the search field and command list. + pub fn header(mut self, f: F) -> Self + where + F: Fn(&CommandState, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.options.header = Some(Rc::new(move |state, window, cx| { + f(state, window, cx).into_any_element() + })); + self + } + + /// Render a custom element below the command list. + pub fn footer(mut self, f: F) -> Self + where + F: Fn(&CommandState, &mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.options.footer = Some(Rc::new(move |state, window, cx| { + f(state, window, cx).into_any_element() + })); + self + } +} + +impl Styled for Command { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.options.style + } +} + +impl RenderOnce for Command { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let options = self.options; + let model = CommandModel { + entries: self.entries, + searchable: self.searchable, + filterable: self.filterable, + on_query: self.on_query, + on_select: self.on_select, + on_confirm: self.on_confirm, + on_cancel: self.on_cancel, + }; + self.state.update(cx, |state, cx| { + state.options = options; + state.install_model(model, cx); + }); + + self.state + } +} diff --git a/vendor/gpui-component/src/command/item.rs b/vendor/gpui-component/src/command/item.rs new file mode 100644 index 00000000..f578e155 --- /dev/null +++ b/vendor/gpui-component/src/command/item.rs @@ -0,0 +1,305 @@ +use std::rc::Rc; + +use gpui::{Action, AnyElement, App, IntoElement, SharedString, Window}; + +use crate::{Disableable, Icon}; + +/// A single command in a [`crate::command::Command`] palette. +/// +pub struct CommandItem { + label: Option, + keywords: Vec, + /// Boxed: an [`Icon`] carries a whole `StyleRefinement`, which would make + /// every item — and so the palette's item vector — kilobytes wide. + pub(crate) icon: Option>, + pub(crate) action: Option>, + pub(crate) checked: bool, + disabled: bool, + pub(crate) content: Option>, +} + +impl Clone for CommandItem { + fn clone(&self) -> Self { + Self { + label: self.label.clone(), + keywords: self.keywords.clone(), + icon: self.icon.clone(), + action: self.action.as_ref().map(|action| action.boxed_clone()), + checked: self.checked, + disabled: self.disabled, + content: self.content.clone(), + } + } +} + +impl CommandItem { + /// Create an empty command item. + pub fn new() -> Self { + Self { + label: None, + keywords: Vec::new(), + icon: None, + action: None, + checked: false, + disabled: false, + content: None, + } + } + + /// Set the label to display and search. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Set the leading icon. + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(Box::new(icon.into())); + self + } + + /// Set the Action dispatched when this item is clicked or confirmed. + /// + /// The Action's active keybinding is also shown by the default row. + pub fn action(mut self, action: Box) -> Self { + self.action = Some(action); + self + } + + /// Mark this item as the chosen one, drawing a check at the right end of + /// the row. + /// + /// A resolved Action binding takes that slot, so an item with one shows no + /// check. + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + /// Add extra terms the search matches against, besides the label. + pub fn keywords(mut self, keywords: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.keywords + .extend(keywords.into_iter().map(|keyword| keyword.into())); + self + } + + /// Replace the row content (icon and label) with a lazily built child. + /// + /// The builder may run more than once for measurement and rendering, so it + /// must be side-effect-free. Custom children own their complete visual + /// presentation, including any keybinding hint. + pub fn child(mut self, builder: F) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.content = Some(Rc::new(move |window, cx| { + builder(window, cx).into_any_element() + })); + self + } + + /// Whether this item is non-interactive. + pub(crate) fn is_disabled(&self) -> bool { + self.disabled + } + + /// Whether this item matches the search query, ignoring case. + /// + /// An empty query matches everything. + pub(crate) fn matches(&self, query: &str) -> bool { + if query.is_empty() { + return true; + } + + let query = query.to_lowercase(); + + self.label + .as_ref() + .is_some_and(|label| label.to_lowercase().contains(&query)) + || self + .keywords + .iter() + .any(|keyword| keyword.to_lowercase().contains(&query)) + } + + pub(crate) fn label_text(&self) -> Option<&SharedString> { + self.label.as_ref() + } +} + +impl Default for CommandItem { + fn default() -> Self { + Self::new() + } +} + +pub(crate) type CommandItemContent = dyn Fn(&mut Window, &mut App) -> AnyElement; + +impl Disableable for CommandItem { + fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +/// A titled section of [`CommandItem`]s. +/// +/// The heading is hidden while every item in the group is filtered out. +pub struct CommandGroup { + heading: Option, + pub(crate) items: Vec, +} + +impl Clone for CommandGroup { + fn clone(&self) -> Self { + Self { + heading: self.heading.clone(), + items: self.items.clone(), + } + } +} + +impl CommandGroup { + /// Create a new group without a label. + pub fn new() -> Self { + Self { + heading: None, + items: Vec::new(), + } + } + + /// Set the label displayed above the group's items. + pub fn label(mut self, label: impl Into) -> Self { + self.heading = Some(label.into()); + self + } + + /// Add an item to the group. + pub fn item(mut self, item: CommandItem) -> Self { + self.items.push(item); + self + } + + /// Add multiple items to the group. + pub fn items(mut self, items: impl IntoIterator) -> Self { + self.items.extend(items); + self + } + + /// The heading of the group, when it has one. + pub fn heading(&self) -> Option<&SharedString> { + self.heading.as_ref() + } +} + +/// A top-level entry in a [`crate::command::Command`]. +pub enum CommandEntry { + /// A single ungrouped item. + Item(CommandItem), + /// A titled group of items. + Group(CommandGroup), + /// A divider between groups. + /// + /// A separator that ends up leading, trailing, or next to another + /// separator once the query has filtered the list is not rendered. + Separator, +} + +impl Clone for CommandEntry { + fn clone(&self) -> Self { + match self { + Self::Item(item) => Self::Item(item.clone()), + Self::Group(group) => Self::Group(group.clone()), + Self::Separator => Self::Separator, + } + } +} + +impl From for CommandEntry { + fn from(item: CommandItem) -> Self { + Self::Item(item) + } +} + +impl From for CommandEntry { + fn from(group: CommandGroup) -> Self { + Self::Group(group) + } +} + +#[cfg(test)] +mod tests { + use std::{cell::Cell, rc::Rc}; + + use gpui::{TestAppContext, actions, div}; + + use super::*; + + actions!(command_item_test, [CloneAction]); + + #[gpui::test] + fn cloned_entries_keep_actions_and_lazy_children_usable(cx: &mut TestAppContext) { + let action_count = Rc::new(Cell::new(0)); + let child_count = Rc::new(Cell::new(0)); + let action_count_for_handler = action_count.clone(); + cx.update(|cx| { + cx.on_action(move |_: &CloneAction, _| { + action_count_for_handler.set(action_count_for_handler.get() + 1); + }); + }); + + let child_count_for_builder = child_count.clone(); + let entry = CommandEntry::Group( + CommandGroup::new().label("Group").item( + CommandItem::new() + .label("cloneable") + .action(Box::new(CloneAction)) + .child(move |_, _| { + child_count_for_builder.set(child_count_for_builder.get() + 1); + div() + }), + ), + ); + let cloned = entry.clone(); + let CommandEntry::Group(group) = cloned else { + panic!("the cloned entry should remain a group"); + }; + let cloned_item = group.items.into_iter().next().unwrap(); + + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let child = cloned_item.content.as_ref().unwrap().clone(); + _ = child(window, cx); + window.dispatch_action(cloned_item.action.as_ref().unwrap().boxed_clone(), cx); + }); + + assert_eq!(child_count.get(), 1); + assert_eq!(action_count.get(), 1); + } + + #[test] + fn label_is_optional_for_custom_content() { + assert_eq!(CommandItem::new().label_text(), None); + assert_eq!( + CommandItem::new().label("Calendar").label_text(), + Some(&"Calendar".into()) + ); + } + + #[test] + fn matches_label_and_keywords() { + let item = CommandItem::new() + .label("Profile") + .keywords(["account", "user"]); + + assert!(item.matches("")); + assert!(item.matches("PRO")); + assert!(item.matches("Account")); + assert!(!item.matches("billing")); + } +} diff --git a/vendor/gpui-component/src/command/mod.rs b/vendor/gpui-component/src/command/mod.rs new file mode 100644 index 00000000..cfb295ad --- /dev/null +++ b/vendor/gpui-component/src/command/mod.rs @@ -0,0 +1,15 @@ +//! A command palette: a search field over a filtered list of commands, with +//! groups, Action keybinding hints and keyboard navigation. +//! +//! [`Command`] owns the entries and rendering policy; [`CommandState`] holds +//! interaction state such as the query, focus, selection, and scrolling. +#[allow(clippy::module_inception)] +mod command; +mod item; +mod state; + +pub use command::Command; +pub use item::{CommandEntry, CommandGroup, CommandItem}; +pub use state::CommandState; + +pub(crate) use state::init; diff --git a/vendor/gpui-component/src/command/state.rs b/vendor/gpui-component/src/command/state.rs new file mode 100644 index 00000000..494f22b8 --- /dev/null +++ b/vendor/gpui-component/src/command/state.rs @@ -0,0 +1,2450 @@ +use std::rc::Rc; + +use gpui::{ + AbsoluteLength, AnyElement, App, AppContext as _, AvailableSpace, Context, Entity, FocusHandle, + Focusable, FontFallbacks, FontFeatures, FontStyle, FontWeight, InteractiveElement, IntoElement, + KeyBinding, ListSizingBehavior, ParentElement, Pixels, Render, Role, ScrollStrategy, + SharedString, Size, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, + TextOverflow, WhiteSpace, Window, div, prelude::FluentBuilder as _, px, size, +}; +use rust_i18n::t; + +use crate::{ + ActiveTheme as _, ElementExt as _, Icon, IconName, IndexPath, StyledExt as _, + VirtualListScrollHandle, + actions::{Cancel, Confirm, SelectDown, SelectUp}, + command::{ + command::CommandOptions, + item::{CommandEntry, CommandItem}, + }, + h_flex, + input::{Input, InputEvent, InputState}, + kbd::Kbd, + scroll::Scrollbar, + v_flex, v_virtual_list, +}; + +pub(crate) const CONTEXT: &str = "Command"; + +/// The row a separator occupies: a one-pixel rule with a little air on +/// either side. Fixed, so that only the item and heading rows need measuring. +const SEPARATOR_ROW_HEIGHT: f32 = 9.; + +pub(crate) type OnQuery = dyn Fn(&str, &mut Window, &mut App); +pub(crate) type OnIndex = dyn Fn(IndexPath, &mut Window, &mut App); +pub(crate) type OnCancel = dyn Fn(&mut Window, &mut App); + +pub(crate) struct CommandModel { + pub(crate) entries: Vec, + pub(crate) searchable: bool, + pub(crate) filterable: bool, + pub(crate) on_query: Option>, + pub(crate) on_select: Option>, + pub(crate) on_confirm: Option>, + pub(crate) on_cancel: Option>, +} + +impl Default for CommandModel { + fn default() -> Self { + Self { + entries: Vec::new(), + searchable: true, + filterable: true, + on_query: None, + on_select: None, + on_confirm: None, + on_cancel: None, + } + } +} + +pub(crate) fn init(cx: &mut App) { + let context: Option<&str> = Some(CONTEXT); + cx.bind_keys([ + KeyBinding::new("escape", Cancel, context), + KeyBinding::new("enter", Confirm { secondary: false }, context), + KeyBinding::new("up", SelectUp, context), + KeyBinding::new("down", SelectDown, context), + ]); +} + +/// One rendered line of the list. +/// +/// Groups are flattened into headings and items so the list is a single +/// sequence of rows, which is what the virtual list scrolls over. +#[derive(Clone, PartialEq)] +enum CommandRow { + Heading(SharedString), + /// Holds the index into [`CommandState::matched`]. + Item(usize), + Separator, +} + +#[derive(Clone, PartialEq)] +struct TextShapeKey { + font_family: SharedString, + font_features: FontFeatures, + font_fallbacks: Option, + font_size: AbsoluteLength, + font_weight: FontWeight, + font_style: FontStyle, + white_space: WhiteSpace, + text_overflow: Option, + line_clamp: Option, +} + +#[derive(Clone, PartialEq)] +struct ListMeasurementKey { + content_width: Pixels, + rem_size: Pixels, + line_height: Pixels, + text_shape: TextShapeKey, +} + +/// An item that survived the current query, and where it landed. +#[derive(Clone)] +struct MatchedItem { + entry_ix: usize, + item_ix: usize, + index_path: IndexPath, + row_ix: usize, + disabled: bool, +} + +/// The interaction state of a [`crate::command::Command`] palette: its query, +/// focus, scrolling, and highlighted command. +pub struct CommandState { + focus_handle: FocusHandle, + query_input: Entity, + scroll_handle: VirtualListScrollHandle, + model: CommandModel, + rows: Vec, + row_sizes: Rc>>, + list_measurement_key: Option, + needs_measure: bool, + matched: Vec, + selected_index: Option, + preserve_no_selection: bool, + loading: bool, + pending_scroll: Option, + /// The placeholder last written to the query input, so that `render` only + /// writes when it changed — `set_placeholder` notifies, and an + /// unconditional notify from `render` would redraw every frame. + applied_placeholder: SharedString, + applied_query: SharedString, + pub(crate) options: CommandOptions, + _subscriptions: Vec, +} + +impl CommandState { + /// Create an empty palette. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let query_input = cx.new(|cx| InputState::new(window, cx)); + + let _subscriptions = + vec![cx.subscribe_in(&query_input, window, Self::on_query_input_event)]; + + Self { + focus_handle: cx.focus_handle(), + query_input, + scroll_handle: VirtualListScrollHandle::new(), + model: CommandModel::default(), + rows: Vec::new(), + row_sizes: Rc::new(Vec::new()), + list_measurement_key: None, + needs_measure: true, + matched: Vec::new(), + selected_index: None, + preserve_no_selection: false, + loading: false, + pending_scroll: None, + applied_placeholder: SharedString::default(), + applied_query: SharedString::default(), + options: CommandOptions::default(), + _subscriptions, + } + } + + pub(crate) fn install_model(&mut self, model: CommandModel, cx: &mut Context) { + let selected_index_path = self.selected_index(); + self.model = model; + self.update_matches(cx); + + let preserved_selection = selected_index_path.and_then(|selected_index_path| { + self.matched + .iter() + .enumerate() + .find_map(|(matched_ix, matched)| { + (!matched.disabled && matched.index_path == selected_index_path) + .then_some(matched_ix) + }) + }); + + if let Some(matched_ix) = preserved_selection { + self.selected_index = Some(matched_ix); + self.preserve_no_selection = false; + self.pending_scroll = self.matched.get(matched_ix).map(|matched| matched.row_ix); + } else if self.preserve_no_selection { + self.selected_index = None; + self.pending_scroll = None; + } else { + self.reset_selection(); + } + + self.needs_measure = true; + } + + /// The current search query. + pub fn query(&self, cx: &App) -> SharedString { + self.query_input.read(cx).value() + } + + /// Replace the search query, as if it had been typed. + /// + /// The input suppresses its own change event for a programmatic write, so + /// the re-filter and query callback happen here instead. + pub fn set_query( + &mut self, + query: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let query = query.into(); + if self.query(cx) == query { + return; + } + + self.query_input + .update(cx, |input, cx| input.set_value(query, window, cx)); + self.on_query_changed(window, cx); + } + + /// The highlighted item's path in the model installed by the latest + /// [`crate::command::Command`] render, before local filtering. + /// + /// Ungrouped items occupy section 0 and use their input position as the + /// row. Explicit groups use their group and item positions; when a model + /// mixes both forms, the implicit ungrouped section comes first. + pub fn selected_index(&self) -> Option { + self.selected_index + .and_then(|selected_index| self.matched.get(selected_index)) + .filter(|matched| !matched.disabled) + .map(|matched| matched.index_path) + } + + /// Highlight an item by its original, unfiltered model path, or clear the + /// highlight with `None`. + /// + /// A path that is currently filtered out or disabled clears the + /// highlight. A visible selection is scrolled into view. + pub fn set_selected_index( + &mut self, + index: Option, + window: &mut Window, + cx: &mut Context, + ) { + let matched_ix = index.and_then(|index| { + self.matched + .iter() + .position(|matched| matched.index_path == index && !matched.disabled) + }); + + let preserve_no_selection = matched_ix.is_none(); + if self.selected_index == matched_ix { + self.preserve_no_selection = preserve_no_selection; + return; + } + + let previous_index = self.selected_index(); + self.selected_index = matched_ix; + self.preserve_no_selection = preserve_no_selection; + self.pending_scroll = matched_ix + .and_then(|matched_ix| self.matched.get(matched_ix)) + .map(|matched| matched.row_ix); + + if let Some((on_select, index)) = self.on_select_if_changed(previous_index) { + window.defer(cx, move |window, cx| on_select(index, window, cx)); + } + + cx.notify(); + } + + /// The number of items matching the current query. + pub fn matched_count(&self) -> usize { + self.matched.len() + } + + /// Move focus to the palette's active control. + pub fn focus(&self, window: &mut Window, cx: &mut App) { + if self.model.searchable { + self.query_input.focus_handle(cx).focus(window, cx); + } else { + self.focus_handle.focus(window, cx); + } + } + + /// Show or hide the search field's spinner, and suppress the empty message + /// while it spins. + /// + /// Turn it on while an `on_query` callback is being answered. + pub fn set_loading(&mut self, loading: bool, window: &mut Window, cx: &mut Context) { + self.loading = loading; + self.query_input + .update(cx, |input, cx| input.set_loading(loading, window, cx)); + cx.notify(); + } + + /// Whether the search field is showing its spinner. + pub fn is_loading(&self) -> bool { + self.loading + } + + // MARK: Matching + + fn item_matches(&self, item: &CommandItem, query: &str) -> bool { + if !self.model.searchable || !self.model.filterable || query.is_empty() { + true + } else { + item.matches(query) + } + } + + fn item_at(&self, matched_ix: usize) -> Option<&CommandItem> { + let matched = self.matched.get(matched_ix)?; + + match self.model.entries.get(matched.entry_ix)? { + CommandEntry::Item(item) => Some(item), + CommandEntry::Group(group) => group.items.get(matched.item_ix), + CommandEntry::Separator => None, + } + } + + /// Recompute the visible rows and the matching items for the current query. + /// + fn update_matches(&mut self, cx: &App) { + let query = self.query(cx); + let query = query.trim(); + + let mut rows: Vec = Vec::new(); + let mut matched: Vec = Vec::new(); + let has_ungrouped_items = self + .model + .entries + .iter() + .any(|entry| matches!(entry, CommandEntry::Item(_))); + let mut ungrouped_item_ix = 0; + let mut group_ix = 0; + // A separator is only drawn once something follows it, which drops the + // leading, trailing and doubled separators a filtered list leaves behind. + let mut pending_separator = false; + + for (entry_ix, entry) in self.model.entries.iter().enumerate() { + match entry { + CommandEntry::Separator => pending_separator = !rows.is_empty(), + CommandEntry::Item(item) => { + let item_ix = ungrouped_item_ix; + ungrouped_item_ix += 1; + if !self.item_matches(item, query) { + continue; + } + + if pending_separator { + rows.push(CommandRow::Separator); + pending_separator = false; + } + + let index_path = IndexPath::new(item_ix).section(0); + matched.push(MatchedItem { + entry_ix, + item_ix: 0, + index_path, + row_ix: rows.len(), + disabled: item.is_disabled(), + }); + rows.push(CommandRow::Item(matched.len() - 1)); + } + CommandEntry::Group(group) => { + let section_ix = group_ix + usize::from(has_ungrouped_items); + group_ix += 1; + let visible = group + .items + .iter() + .enumerate() + .filter(|(_, item)| self.item_matches(item, query)) + .map(|(item_ix, item)| (item_ix, item.is_disabled())) + .collect::>(); + + if visible.is_empty() { + continue; + } + + if pending_separator { + rows.push(CommandRow::Separator); + pending_separator = false; + } + + if let Some(heading) = group.heading() { + rows.push(CommandRow::Heading(heading.clone())); + } + + for (item_ix, disabled) in visible { + let index_path = IndexPath::new(item_ix).section(section_ix); + matched.push(MatchedItem { + entry_ix, + item_ix, + index_path, + row_ix: rows.len(), + disabled, + }); + rows.push(CommandRow::Item(matched.len() - 1)); + } + } + } + } + + self.rows = rows; + self.matched = matched; + self.needs_measure = true; + self.selected_index = self.selected_index.and_then(|selected_index| { + (selected_index < self.matched.len()).then_some(selected_index) + }); + } + + /// Move the highlight to the first item that can be confirmed. + fn reset_selection(&mut self) { + self.selected_index = self.matched.iter().position(|matched| !matched.disabled); + self.preserve_no_selection = false; + self.pending_scroll = self + .selected_index + .and_then(|selected_index| self.matched.get(selected_index)) + .map(|matched| matched.row_ix) + .or(Some(0)); + } + + fn on_query_input_event( + &mut self, + _: &Entity, + event: &InputEvent, + window: &mut Window, + cx: &mut Context, + ) { + if !matches!(event, InputEvent::Change) { + return; + } + + self.on_query_changed(window, cx); + } + + /// Re-filter for the query that is now in the field, and report it. + fn on_query_changed(&mut self, window: &mut Window, cx: &mut Context) { + let query = self.query(cx); + if query == self.applied_query { + return; + } + + let previous_selection = self.selected_index(); + self.applied_query = query.clone(); + self.update_matches(cx); + self.reset_selection(); + let selection_callback = self.on_select_if_changed(previous_selection); + let query_callback = self + .model + .searchable + .then(|| self.model.on_query.clone()) + .flatten(); + + if selection_callback.is_some() || query_callback.is_some() { + window.defer(cx, move |window, cx| { + if let Some((on_select, index)) = selection_callback { + on_select(index, window, cx); + } + if let Some(on_query) = query_callback { + on_query(query.as_ref(), window, cx); + } + }); + } + + cx.notify(); + } + + fn set_list_measurement_key( + &mut self, + measurement_key: ListMeasurementKey, + cx: &mut Context, + ) { + if self.list_measurement_key.as_ref() == Some(&measurement_key) { + return; + } + + self.list_measurement_key = Some(measurement_key); + self.needs_measure = true; + cx.notify(); + } + + // MARK: Actions + + fn on_select_if_changed( + &self, + previous_index: Option, + ) -> Option<(Rc, IndexPath)> { + let index = self.selected_index(); + if index == previous_index { + return None; + } + + self.model.on_select.clone().zip(index) + } + + fn select(&mut self, matched_ix: usize, window: &mut Window, cx: &mut Context) { + if self.selected_index == Some(matched_ix) { + return; + } + + let previous_index = self.selected_index(); + self.selected_index = Some(matched_ix); + self.preserve_no_selection = false; + self.pending_scroll = self.matched.get(matched_ix).map(|matched| matched.row_ix); + + if let Some((on_select, index)) = self.on_select_if_changed(previous_index) { + window.defer(cx, move |window, cx| on_select(index, window, cx)); + } + + cx.notify(); + } + + /// Move the highlight by `step` items, wrapping around and skipping the + /// disabled ones. + fn select_by(&mut self, step: isize, window: &mut Window, cx: &mut Context) { + let len = self.matched.len(); + if len == 0 { + return; + } + + let mut next = self + .selected_index + .unwrap_or_else(|| if step >= 0 { len.saturating_sub(1) } else { 0 }); + let mut enabled = None; + for _ in 0..len { + next = (next as isize + step).rem_euclid(len as isize) as usize; + if !self.matched[next].disabled { + enabled = Some(next); + break; + } + } + + if let Some(next) = enabled { + self.select(next, window, cx); + } + } + + fn on_action_select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { + self.select_by(-1, window, cx); + } + + fn on_action_select_down( + &mut self, + _: &SelectDown, + window: &mut Window, + cx: &mut Context, + ) { + self.select_by(1, window, cx); + } + + fn on_action_confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + if let Some(selected_index) = self.selected_index { + self.confirm(selected_index, window, cx); + } + } + + /// Escape clears a non-empty query first, and only then leaves the palette + /// — the dialog that hosts it closes on the second press. + fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if self.model.searchable && !self.query(cx).is_empty() { + self.set_query("", window, cx); + return; + } + + // Cancel is the one synchronous callback: propagation must continue in + // this dispatch so a hosting Dialog observes it once and owns the pop. + if let Some(on_cancel) = self.model.on_cancel.clone() { + on_cancel(window, cx); + } + + cx.propagate(); + } + + fn confirm(&mut self, matched_ix: usize, window: &mut Window, cx: &mut Context) { + let Some(item) = self.item_at(matched_ix) else { + return; + }; + if item.is_disabled() { + return; + } + + let index_path = self.matched[matched_ix].index_path; + let action = item.action.as_ref().map(|action| action.boxed_clone()); + let on_confirm = self.model.on_confirm.clone(); + + if let Some(action) = action { + window.dispatch_action(action, cx); + } + if let Some(on_confirm) = on_confirm { + window.defer(cx, move |window, cx| { + on_confirm(index_path, window, cx); + }); + } + } + + // MARK: Row sizing + + /// Measure each row before passing the sizes to the virtual list. Custom + /// item elements can have independent intrinsic heights. + fn measure_row_sizes(&self, window: &mut Window, cx: &mut Context) -> Vec> { + let available = size( + self.list_measurement_key + .as_ref() + .map_or(AvailableSpace::MinContent, |key| { + AvailableSpace::Definite(key.content_width) + }), + AvailableSpace::MinContent, + ); + let mut text_style = StyleRefinement::default(); + text_style.text = self.options.style.text.clone(); + + self.rows + .iter() + .enumerate() + .map(|(row_ix, row)| match row { + CommandRow::Separator => size(px(0.), px(SEPARATOR_ROW_HEIGHT)), + CommandRow::Heading(_) | CommandRow::Item(_) => { + let row_size = div() + .refine_style(&text_style) + .child(self.render_row(row_ix, window, cx)) + .into_any_element() + .layout_as_root(available, window, cx); + size(px(0.), row_size.height) + } + }) + .collect() + } + + // MARK: Rendering + + fn sync_placeholder(&mut self, window: &mut Window, cx: &mut Context) { + let placeholder = self + .options + .placeholder + .as_ref() + .cloned() + .unwrap_or_else(|| t!("Command.placeholder").to_string().into()); + + if self.applied_placeholder == placeholder { + return; + } + + self.applied_placeholder = placeholder.clone(); + self.query_input.update(cx, |input, cx| { + input.set_placeholder(placeholder, window, cx) + }); + } + + /// The frame every item row shares, so that the measured height matches the + /// rendered one. + fn item_row(&self, selected: bool, cx: &App) -> gpui::Div { + div() + .flex() + .flex_row() + .items_center() + .w_full() + .gap_2() + .px_2() + .py_1p5() + .text_sm() + .rounded(cx.theme().radius) + .when(selected, |this| { + this.bg(cx.theme().accent) + .text_color(cx.theme().accent_foreground) + }) + } + + fn heading_row(&self, heading: SharedString, cx: &App) -> gpui::Div { + div() + .w_full() + .px_2() + .py_1p5() + .text_xs() + .font_medium() + .text_color(cx.theme().muted_foreground) + .child(heading) + } + + fn render_row(&self, row_ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { + match self.rows.get(row_ix) { + None => div().into_any_element(), + Some(CommandRow::Separator) => div() + .w_full() + .py(px(4.)) + .child(div().h(px(1.)).w_full().bg(cx.theme().border)) + .into_any_element(), + Some(CommandRow::Heading(heading)) => { + self.heading_row(heading.clone(), cx).into_any_element() + } + Some(CommandRow::Item(matched_ix)) => self.render_item(*matched_ix, window, cx), + } + } + + fn render_item( + &self, + matched_ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(item) = self.item_at(matched_ix) else { + return div().into_any_element(); + }; + + let disabled = item.is_disabled(); + let selected = self.selected_index == Some(matched_ix) && !disabled; + let muted_foreground = cx.theme().muted_foreground; + let icon_color = if selected { + cx.theme().accent_foreground + } else { + muted_foreground + }; + let binding = if item.content.is_none() { + item.action.as_ref().and_then(|action| { + Kbd::binding_for_action_in(action.as_ref(), &self.focus_handle(cx), window) + .or_else(|| Kbd::binding_for_action(action.as_ref(), None, window)) + }) + } else { + None + }; + + let content = match &item.content { + Some(render) => render(window, cx), + None => h_flex() + .flex_1() + .gap_2() + .items_center() + .when_some(item.icon.clone(), |this, icon| { + this.child(icon.size_4().text_color(icon_color)) + }) + .when_some(item.label_text().cloned(), |this, label| this.child(label)) + .into_any_element(), + }; + + self.item_row(selected, cx) + .id(self.matched[matched_ix].index_path) + .role(Role::ListBoxOption) + .aria_selected(selected) + .when(disabled, |this| this.text_color(muted_foreground)) + .when(!disabled, |this| { + this.cursor_default() + .on_hover(cx.listener(move |this, hovered: &bool, window, cx| { + if *hovered { + this.select(matched_ix, window, cx); + } + })) + .on_click(cx.listener(move |this, _, window, cx| { + this.confirm(matched_ix, window, cx); + })) + }) + .child(content) + .map(|this| match binding { + Some(binding) => this.child(binding.ml_auto()), + // The binding owns the trailing slot, so only an item without + // one can show its check there. + None => this.when(item.checked, |this| { + this.child(crate::Sizable::xsmall(Icon::new(IconName::Check).ml_auto())) + }), + }) + .into_any_element() + } + + fn render_empty(&self, window: &mut Window, cx: &mut App) -> AnyElement { + if let Some(empty) = self.options.empty.as_ref() { + return empty(self, window, cx); + } + + let message: SharedString = t!("Command.empty").to_string().into(); + + div() + .py_6() + .w_full() + .text_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message) + .into_any_element() + } +} + +impl Focusable for CommandState { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.model.searchable { + self.query_input.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl Render for CommandState { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync_placeholder(window, cx); + + if self.needs_measure { + self.needs_measure = false; + self.row_sizes = Rc::new(self.measure_row_sizes(window, cx)); + } + + if let Some(row_ix) = self.pending_scroll.take() { + self.scroll_handle + .scroll_to_item(row_ix, ScrollStrategy::Nearest); + } + + let rows_count = self.rows.len(); + let row_sizes = self.row_sizes.clone(); + let command_state = cx.entity(); + + v_flex() + .id("command") + .key_context(CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_action_select_up)) + .on_action(cx.listener(Self::on_action_select_down)) + .on_action(cx.listener(Self::on_action_confirm)) + .on_action(cx.listener(Self::on_action_cancel)) + .w_full() + .overflow_hidden() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .when(self.options.bordered, |this| { + this.rounded(cx.theme().radius_lg) + .border_1() + .border_color(cx.theme().border) + }) + .refine_style(&self.options.style) + .when_some(self.options.header.as_ref(), |this, header| { + this.child(header(self, window, cx)) + }) + .when(self.model.searchable, |this| { + this.child( + div() + .flex_none() + .px_3() + .border_b_1() + .border_color(cx.theme().border) + .child( + Input::new(&self.query_input) + .prefix( + Icon::new(IconName::Search) + .text_color(cx.theme().muted_foreground), + ) + .appearance(false) + .p_0(), + ), + ) + }) + .child( + v_flex() + .id("command-list-container") + .role(Role::ListBox) + .relative() + .flex_1() + // The rows carry their inset on the virtual list itself so + // that a mid-scroll clip edge sits flush against the + // surrounding dividers; only the empty slot needs the + // container padding. + .when(rows_count == 0, |this| this.p_1()) + .on_prepaint({ + let measure_state = command_state.clone(); + move |bounds, window, cx| { + measure_state.update(cx, |state, cx| { + // The list's `p_1` is one quarter rem on each + // side. Its rem-dependent padding and inherited + // layout-relevant text style participate in + // the row-size cache key. + let text_style = window.text_style(); + state.set_list_measurement_key( + ListMeasurementKey { + content_width: (bounds.size.width + - window.rem_size() * 0.5) + .max(px(0.)), + rem_size: window.rem_size(), + line_height: window.line_height(), + text_shape: TextShapeKey { + font_family: text_style.font_family, + font_features: text_style.font_features, + font_fallbacks: text_style.font_fallbacks, + font_size: text_style.font_size, + font_weight: text_style.font_weight, + font_style: text_style.font_style, + white_space: text_style.white_space, + text_overflow: text_style.text_overflow, + line_clamp: text_style.line_clamp, + }, + }, + cx, + ) + }) + } + }) + .max_h(self.options.max_h) + .overflow_hidden() + // While a search is in flight the list is empty because the + // answer has not arrived, which is not the same as no match. + .when(rows_count == 0 && !self.loading, |this| { + this.child(self.render_empty(window, cx)) + }) + .when(rows_count > 0, |this| { + this.child( + v_virtual_list( + command_state.clone(), + "command-list", + row_sizes, + move |this, visible_range, window, cx| { + visible_range + .map(|row_ix| this.render_row(row_ix, window, cx)) + .collect::>() + }, + ) + // Padding on the virtual list acts like CSS + // scroll-padding: the scroll ends keep their inset + // while scrolled-under rows paint and clip at the + // list edge. + .p_1() + .with_sizing_behavior(ListSizingBehavior::Infer) + .track_scroll(&self.scroll_handle), + ) + .child(Scrollbar::vertical(&self.scroll_handle)) + }), + ) + .when_some(self.options.footer.as_ref(), |this, footer| { + this.child(footer(self, window, cx)) + }) + } +} + +// MARK: Tests + +#[cfg(test)] +mod tests { + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; + + use gpui::{ + AppContext as _, AvailableSpace, Entity, InteractiveElement as _, IntoElement, KeyBinding, + Modifiers, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window, + actions, div, point, prelude::FluentBuilder as _, px, + }; + + use super::{CONTEXT, CommandModel, CommandRow, CommandState, SEPARATOR_ROW_HEIGHT}; + use crate::{ + Disableable as _, IndexPath, + actions::{Cancel, Confirm, SelectDown}, + command::{Command, CommandEntry, CommandGroup, CommandItem}, + }; + + actions!( + command_test, + [GlobalTestItem, OpenTestItem, RemovePaletteTestItem] + ); + + struct CommandActionsHarness { + state: Entity, + events: Rc>>, + } + + impl Render for CommandActionsHarness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let action_events = self.events.clone(); + let propagated_cancel_events = self.events.clone(); + let query_events = self.events.clone(); + let select_events = self.events.clone(); + let confirm_events = self.events.clone(); + let cancel_events = self.events.clone(); + + div() + .size_full() + .on_action(move |_: &OpenTestItem, _, _| { + action_events.borrow_mut().push("action".into()); + }) + .on_action(move |_: &Cancel, _, _| { + propagated_cancel_events + .borrow_mut() + .push("propagated_cancel".into()); + }) + .child( + Command::new(&self.state) + .item( + CommandItem::new() + .label("Item") + .keywords(["needle"]) + .action(Box::new(OpenTestItem)), + ) + .item(CommandItem::new().label("Item")) + .item( + CommandItem::new() + .label("Item") + .action(Box::new(GlobalTestItem)), + ) + .on_query(move |query, _, _| { + query_events.borrow_mut().push(format!("query:{query}")); + }) + .on_select(move |index, _, _| { + select_events + .borrow_mut() + .push(format!("select:{}:{}", index.section, index.row)); + }) + .on_confirm(move |index, _, _| { + confirm_events + .borrow_mut() + .push(format!("confirm:{}:{}", index.section, index.row)); + }) + .on_cancel(move |_, _| { + cancel_events.borrow_mut().push("cancel".into()); + }), + ) + } + } + + struct ReentrantCallbackHarness { + state: Entity, + events: Vec, + } + + impl Render for ReentrantCallbackHarness { + fn render(&mut self, _: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + let select_owner = cx.weak_entity(); + let query_owner = cx.weak_entity(); + let confirm_owner = cx.weak_entity(); + + Command::new(&self.state) + .item(CommandItem::new().label("alpha")) + .item(CommandItem::new().label("beta")) + .on_select(move |index, _, cx| { + _ = select_owner.update(cx, |harness, cx| { + assert_eq!(harness.state.read(cx).selected_index(), Some(index)); + harness + .events + .push(format!("select:{}:{}", index.section, index.row)); + }); + }) + .on_query(move |query, _, cx| { + _ = query_owner.update(cx, |harness, cx| { + assert_eq!(harness.state.read(cx).query(cx).as_ref(), query); + harness.events.push(format!("query:{query}")); + }); + }) + .on_confirm(move |index, _, cx| { + _ = confirm_owner.update(cx, |harness, cx| { + assert_eq!(harness.state.read(cx).selected_index(), Some(index)); + harness + .events + .push(format!("confirm:{}:{}", index.section, index.row)); + }); + }) + } + } + + #[gpui::test] + fn query_and_selection_callbacks_run_after_the_state_lease_in_defined_order( + cx: &mut TestAppContext, + ) { + cx.update(crate::init); + let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + events: Vec::new(), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + state.update(cx, |state, cx| { + state.selected_index = Some(1); + state.set_query("alpha", window, cx); + }); + }); + + assert_eq!( + harness.read_with(cx, |harness, _| harness.events.clone()), + ["select:0:0", "query:alpha"] + ); + } + + #[gpui::test] + fn actionless_confirm_callback_runs_after_the_state_lease(cx: &mut TestAppContext) { + cx.update(crate::init); + let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + events: Vec::new(), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + state.update(cx, |state, cx| state.confirm(0, window, cx)); + }); + + assert_eq!( + harness.read_with(cx, |harness, _| harness.events.clone()), + ["confirm:0:0"] + ); + } + + struct CommandItemWidthHarness { + state: Entity, + matched_ix: usize, + width: Rc>>, + } + + impl Render for CommandItemWidthHarness { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { + let width = self.width.clone(); + let item = self.state.update(cx, |state, cx| { + state.render_item(self.matched_ix, window, cx) + }); + + div() + .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width))) + .child(item) + } + } + + #[gpui::test] + fn action_that_removes_command_state_still_confirms_after_dispatch(cx: &mut TestAppContext) { + cx.update(crate::init); + let events = Rc::new(RefCell::new(Vec::new())); + let state_owner: Rc>>> = Rc::new(RefCell::new(None)); + let action_events = events.clone(); + let action_state_owner = state_owner.clone(); + cx.update(|cx| { + cx.on_action(move |_: &RemovePaletteTestItem, _| { + action_events.borrow_mut().push("action".into()); + action_state_owner.borrow_mut().take(); + }); + }); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let confirm_events = events.clone(); + let state = cx.new(|cx| { + let mut state = CommandState::new(window, cx); + state.install_model( + CommandModel { + entries: vec![CommandEntry::Item( + CommandItem::new() + .label("removed") + .action(Box::new(RemovePaletteTestItem)), + )], + searchable: false, + on_confirm: Some(Rc::new(move |index, _, _| { + confirm_events + .borrow_mut() + .push(format!("confirm:{}:{}", index.section, index.row)); + })), + ..CommandModel::default() + }, + cx, + ); + state + }); + *state_owner.borrow_mut() = Some(state.clone()); + state.update(cx, |state, cx| state.confirm(0, window, cx)); + }); + cx.run_until_parked(); + + assert!(state_owner.borrow().is_none()); + assert_eq!(events.borrow().as_slice(), ["action", "confirm:0:0"]); + } + + #[gpui::test] + fn command_actions_and_callbacks_follow_defined_order(cx: &mut TestAppContext) { + cx.update(|cx| { + crate::init(cx); + cx.bind_keys([ + KeyBinding::new("ctrl-o", OpenTestItem, Some(CONTEXT)), + KeyBinding::new("ctrl-g", GlobalTestItem, None), + ]); + }); + let events = Rc::new(RefCell::new(Vec::new())); + let (harness, cx) = cx.add_window_view(|window, cx| CommandActionsHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + events: events.clone(), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + state.update(cx, |state, cx| state.focus(window, cx)); + _ = window.draw(cx); + }); + + let action_width = Rc::new(Cell::new(None)); + let plain_width = Rc::new(Cell::new(None)); + let global_width = Rc::new(Cell::new(None)); + let (action_probe, plain_probe, global_probe) = cx.update(|_, cx| { + ( + cx.new(|_| CommandItemWidthHarness { + state: state.clone(), + matched_ix: 0, + width: action_width.clone(), + }), + cx.new(|_| CommandItemWidthHarness { + state: state.clone(), + matched_ix: 1, + width: plain_width.clone(), + }), + cx.new(|_| CommandItemWidthHarness { + state: state.clone(), + matched_ix: 2, + width: global_width.clone(), + }), + ) + }); + cx.draw( + point(px(0.), px(0.)), + AvailableSpace::min_size(), + move |_, _| action_probe.into_any_element(), + ); + cx.draw( + point(px(0.), px(0.)), + AvailableSpace::min_size(), + move |_, _| plain_probe.into_any_element(), + ); + cx.draw( + point(px(0.), px(0.)), + AvailableSpace::min_size(), + move |_, _| global_probe.into_any_element(), + ); + let action_width = action_width.get().unwrap(); + let plain_width = plain_width.get().unwrap(); + let global_width = global_width.get().unwrap(); + assert!( + action_width > plain_width, + "the scoped Action binding should add a visible Kbd ({action_width:?} vs {plain_width:?})", + ); + assert!( + global_width > plain_width, + "the app-level fallback binding should add a visible Kbd ({global_width:?} vs {plain_width:?})", + ); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_query("needle", window, cx); + state.set_query("needle", window, cx); + state.set_query("", window, cx); + }); + window.dispatch_action(Box::new(SelectDown), cx); + window.dispatch_action(Box::new(crate::actions::SelectUp), cx); + window.dispatch_action(Box::new(Confirm { secondary: false }), cx); + }); + cx.run_until_parked(); + + assert_eq!( + events.borrow().as_slice(), + [ + "query:needle", + "query:", + "select:0:1", + "select:0:0", + "action", + "confirm:0:0", + ] + ); + + cx.simulate_click(point(px(20.), px(52.)), Modifiers::default()); + cx.run_until_parked(); + cx.update(|window, cx| window.dispatch_action(Box::new(Cancel), cx)); + cx.run_until_parked(); + + assert_eq!( + events.borrow().as_slice(), + [ + "query:needle", + "query:", + "select:0:1", + "select:0:0", + "action", + "confirm:0:0", + "action", + "confirm:0:0", + "cancel", + "propagated_cancel", + ] + ); + } + + struct CommandOwnedEntriesHarness { + state: Entity, + } + + impl Render for CommandOwnedEntriesHarness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + Command::new(&self.state) + .searchable(false) + .item(CommandItem::new().label("alpha")) + .group( + CommandGroup::new() + .label("Settings") + .item(CommandItem::new().label("beta")), + ) + .separator() + .item( + CommandItem::new() + .label("custom") + .child(|_, _| div().h(px(72.)).child("Custom")), + ) + } + } + + #[gpui::test] + fn command_owns_entries_and_lazy_item_content(cx: &mut TestAppContext) { + cx.update(crate::init); + let (harness, cx) = cx.add_window_view(|window, cx| CommandOwnedEntriesHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + + let (labels, rows, row_sizes) = cx.update(|_, cx| { + let state = harness.read(cx).state.read(cx); + ( + (0..state.matched_count()) + .map(|matched_ix| { + state + .item_at(matched_ix) + .unwrap() + .label_text() + .unwrap() + .clone() + }) + .collect::>(), + state.rows.clone(), + state.row_sizes.clone(), + ) + }); + + assert_eq!(labels, ["alpha", "beta", "custom"]); + assert!(matches!( + rows.as_slice(), + [ + CommandRow::Item(_), + CommandRow::Heading(heading), + CommandRow::Item(_), + CommandRow::Separator, + CommandRow::Item(_), + ] if heading == "Settings" + )); + assert_eq!(row_sizes[4].height, px(84.)); + } + + fn command_with_entries( + state: &Entity, + entries: impl IntoIterator, + ) -> Command { + entries + .into_iter() + .fold(Command::new(state), |command, entry| match entry { + CommandEntry::Item(item) => command.item(item), + CommandEntry::Group(group) => command.group(group), + CommandEntry::Separator => command.separator(), + }) + } + + fn command_state( + window: &mut Window, + cx: &mut gpui::Context, + entries: impl IntoIterator, + ) -> CommandState { + let mut state = CommandState::new(window, cx); + state.install_model( + CommandModel { + entries: entries.into_iter().collect(), + ..CommandModel::default() + }, + cx, + ); + state + } + + fn command_state_with_options( + window: &mut Window, + cx: &mut gpui::Context, + entries: impl IntoIterator, + searchable: bool, + ) -> CommandState { + let mut state = CommandState::new(window, cx); + state.install_model( + CommandModel { + entries: entries.into_iter().collect(), + searchable, + ..CommandModel::default() + }, + cx, + ); + state + } + + fn suggestion_entries() -> Vec { + vec![ + CommandGroup::new() + .label("Suggestions") + .item(CommandItem::new().label("Calendar")) + .item(CommandItem::new().label("Search Emoji")) + .item(CommandItem::new().label("Calculator").disabled(true)) + .into(), + CommandEntry::Separator, + CommandGroup::new() + .label("Settings") + .item(CommandItem::new().label("Profile")) + .item(CommandItem::new().label("Billing")) + .into(), + ] + } + + #[gpui::test] + fn query_hides_the_groups_that_have_no_match(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let state = cx.new(|cx| command_state(window, cx, suggestion_entries())); + + state.update(cx, |state, cx| { + state.update_matches(cx); + assert_eq!(state.matched_count(), 5); + assert_eq!( + state + .rows + .iter() + .filter(|row| matches!(row, CommandRow::Heading(_))) + .count(), + 2, + ); + assert_eq!( + state + .rows + .iter() + .filter(|row| matches!(row, CommandRow::Separator)) + .count(), + 1, + ); + + // "Bil" only matches an item of the second group, so the first + // group's heading and the separator between them both go. + state.set_query("Bil", window, cx); + state.update_matches(cx); + + assert_eq!(state.matched_count(), 1); + assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1))); + assert_eq!( + state + .rows + .iter() + .filter(|row| matches!(row, CommandRow::Separator)) + .count(), + 0, + ); + assert!(matches!(state.rows.first(), Some(CommandRow::Heading(_)))); + }); + }); + } + + #[gpui::test] + fn a_query_that_matches_nothing_leaves_no_rows(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let state = cx.new(|cx| command_state(window, cx, suggestion_entries())); + + state.update(cx, |state, cx| { + state.set_query("zzz", window, cx); + state.update_matches(cx); + + assert_eq!(state.matched_count(), 0); + assert!(state.rows.is_empty()); + assert_eq!(state.selected_index(), None); + }); + }); + } + + #[gpui::test] + fn filterable_off_keeps_every_item_and_resets_the_highlight(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let state = cx.new(|cx| { + let mut state = CommandState::new(window, cx); + state.install_model( + CommandModel { + entries: suggestion_entries(), + filterable: false, + ..CommandModel::default() + }, + cx, + ); + state + }); + + state.update(cx, |state, cx| { + state.set_selected_index(Some(IndexPath::new(1).section(1)), window, cx); + + // "Bil" would locally match only "Billing"; an unfiltered + // palette keeps every row and hands the highlight back to the + // first item instead of the textual match. + state.set_query("Bil", window, cx); + + assert_eq!(state.matched_count(), 5); + assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0))); + }); + }); + } + + #[gpui::test] + fn keywords_match_when_the_label_does_not(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let state = cx.new(|cx| { + command_state( + window, + cx, + [CommandEntry::Item( + CommandItem::new().label("Profile").keywords(["account"]), + )], + ) + }); + + state.update(cx, |state, cx| { + state.set_query("account", window, cx); + state.update_matches(cx); + + assert_eq!(state.matched_count(), 1); + }); + }); + } + + #[gpui::test] + fn non_searchable_command_keeps_every_item(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let state = cx.new(|cx| { + command_state_with_options( + window, + cx, + [ + CommandEntry::Item(CommandItem::new().label("alpha")), + CommandEntry::Item(CommandItem::new().label("beta")), + ], + false, + ) + }); + state.update(cx, |state, cx| { + state.set_query("missing", window, cx); + assert_eq!(state.matched_count(), 2); + }); + }); + } + + #[gpui::test] + fn non_searchable_command_uses_frame_focus(cx: &mut TestAppContext) { + cx.update(crate::init); + let confirmed = Rc::new(RefCell::new(None)); + let confirmed_for_render = confirmed.clone(); + let (harness, cx) = cx.add_window_view(move |window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + let confirmed = confirmed_for_render.clone(); + Command::new(state) + .searchable(false) + .item(CommandItem::new().label("alpha")) + .item(CommandItem::new().label("beta")) + .on_confirm(move |index_path, _, _| { + *confirmed.borrow_mut() = Some(index_path); + }) + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.update(|window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + assert!(state.read(cx).focus_handle.is_focused(window)); + window.dispatch_action(Box::new(SelectDown), cx); + window.dispatch_action(Box::new(Confirm { secondary: false }), cx); + }); + + assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0))); + } + + #[gpui::test] + fn filtered_ungrouped_item_keeps_its_input_row(cx: &mut TestAppContext) { + cx.update(crate::init); + let confirmed = Rc::new(RefCell::new(None)); + let confirmed_for_render = confirmed.clone(); + let (harness, cx) = cx.add_window_view(move |window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + let confirmed = confirmed_for_render.clone(); + Command::new(state) + .items([ + CommandItem::new().label("alpha"), + CommandItem::new().label("beta"), + CommandItem::new().label("gamma"), + ]) + .on_confirm(move |index_path, _, _| { + *confirmed.borrow_mut() = Some(index_path); + }) + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_query("gamma", window, cx); + state.focus(window, cx); + }); + window.dispatch_action(Box::new(Confirm { secondary: false }), cx); + }); + + assert_eq!(*confirmed.borrow(), Some(IndexPath::new(2).section(0))); + } + + #[gpui::test] + fn initially_rendered_disabled_first_item_selects_and_confirms_the_first_enabled_item( + cx: &mut TestAppContext, + ) { + cx.update(crate::init); + let confirmed = Rc::new(RefCell::new(None)); + let confirmed_for_render = confirmed.clone(); + let (harness, cx) = cx.add_window_view(move |window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + let confirmed = confirmed_for_render.clone(); + Command::new(state) + .item(CommandItem::new().label("disabled").disabled(true)) + .item(CommandItem::new().label("enabled")) + .on_confirm(move |index_path, _, _| { + *confirmed.borrow_mut() = Some(index_path); + }) + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.update(|window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + window.dispatch_action(Box::new(Confirm { secondary: false }), cx); + }); + + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(IndexPath::new(1).section(0)) + ); + assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0))); + } + + #[gpui::test] + fn initially_rendered_all_disabled_items_have_no_selected_index_and_ignore_enter( + cx: &mut TestAppContext, + ) { + cx.update(crate::init); + let confirmed = Rc::new(RefCell::new(None)); + let confirmed_for_render = confirmed.clone(); + let (harness, cx) = cx.add_window_view(move |window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + let confirmed = confirmed_for_render.clone(); + Command::new(state) + .item(CommandItem::new().label("one").disabled(true)) + .item(CommandItem::new().label("two").disabled(true)) + .on_confirm(move |index_path, _, _| { + *confirmed.borrow_mut() = Some(index_path); + }) + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.update(|window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + window.dispatch_action(Box::new(Confirm { secondary: false }), cx); + }); + + assert_eq!(state.read_with(cx, |state, _| state.selected_index()), None); + assert_eq!(*confirmed.borrow(), None); + } + + #[gpui::test] + fn non_searchable_command_cancels_without_clearing_a_hidden_query(cx: &mut TestAppContext) { + cx.update(crate::init); + let cancelled = Rc::new(Cell::new(false)); + let cancelled_for_render = cancelled.clone(); + let query_calls = Rc::new(Cell::new(0)); + let query_calls_for_render = query_calls.clone(); + let (harness, cx) = cx.add_window_view(move |window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + let cancelled = cancelled_for_render.clone(); + let query_calls = query_calls_for_render.clone(); + Command::new(state) + .searchable(false) + .item(CommandItem::new().label("alpha")) + .on_query(move |_, _, _| query_calls.set(query_calls.get() + 1)) + .on_cancel(move |_, _| cancelled.set(true)) + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_query("hidden query", window, cx); + state.focus(window, cx); + }); + window.dispatch_action(Box::new(Cancel), cx); + }); + + assert!(cancelled.get()); + assert_eq!(query_calls.get(), 0); + assert_eq!( + state.read_with(cx, |state, cx| state.query(cx)), + "hidden query" + ); + } + + #[gpui::test] + fn moving_the_highlight_skips_disabled_items_and_wraps(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let state = cx.new(|cx| command_state(window, cx, suggestion_entries())); + + state.update(cx, |state, cx| { + state.update_matches(cx); + state.reset_selection(); + assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0))); + + state.select_by(1, window, cx); + assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0))); + + // "Calculator" is disabled, so it is stepped over. + state.select_by(1, window, cx); + assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(1))); + + state.select_by(-1, window, cx); + assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0))); + + // Wraps around the end, skipping the disabled item again. + state.select_by(-1, window, cx); + assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0))); + state.select_by(-1, window, cx); + assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1))); + }); + }); + } + + #[gpui::test] + fn owner_can_set_and_clear_selection_by_original_index_path(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let initially_empty = cx.new(|cx| CommandState::new(window, cx)); + initially_empty.update(cx, |state, cx| { + state.set_selected_index(None, window, cx); + state.install_model( + CommandModel { + entries: suggestion_entries().into_iter().collect(), + ..CommandModel::default() + }, + cx, + ); + assert_eq!(state.selected_index(), None); + }); + + let state = cx.new(|cx| command_state(window, cx, suggestion_entries())); + + state.update(cx, |state, cx| { + let target = IndexPath::new(1).section(1); + state.set_selected_index(Some(target), window, cx); + assert_eq!(state.selected_index(), Some(target)); + + state.set_selected_index(None, window, cx); + assert_eq!(state.selected_index(), None); + + state.install_model( + CommandModel { + entries: suggestion_entries().into_iter().collect(), + ..CommandModel::default() + }, + cx, + ); + assert_eq!(state.selected_index(), None); + + state.set_query("calendar", window, cx); + state.set_selected_index(Some(target), window, cx); + assert_eq!(state.selected_index(), None); + }); + }); + } + + #[gpui::test] + fn confirming_a_disabled_item_does_nothing(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + + cx.update(|window, cx| { + let state = cx.new(|cx| { + command_state( + window, + cx, + [ + CommandEntry::Item(CommandItem::new().label("enabled")), + CommandEntry::Item(CommandItem::new().label("disabled").disabled(true)), + ], + ) + }); + + state.update(cx, |state, cx| { + state.update_matches(cx); + + assert_eq!(state.matched_count(), 2); + // Reaching the disabled row is only possible with the mouse or + // an explicit index; confirming it must be a no-op. + state.confirm(1, window, cx); + assert_eq!(state.selected_index, Some(0)); + }); + }); + } + + #[gpui::test] + fn a_checked_item_uses_an_xsmall_trailing_check_icon(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + let unchecked_width = Rc::new(Cell::new(None)); + let checked_width = Rc::new(Cell::new(None)); + let (unchecked, checked) = cx.update(|window, cx| { + let unchecked_state = cx.new(|cx| { + command_state( + window, + cx, + [CommandEntry::Item(CommandItem::new().label("theme"))], + ) + }); + let checked_state = cx.new(|cx| { + command_state( + window, + cx, + [CommandEntry::Item( + CommandItem::new().label("theme").checked(true), + )], + ) + }); + let unchecked_width = unchecked_width.clone(); + let checked_width = checked_width.clone(); + ( + cx.new(|_| CheckIconWidthHarness { + state: unchecked_state, + width: unchecked_width, + }), + cx.new(|_| CheckIconWidthHarness { + state: checked_state, + width: checked_width, + }), + ) + }); + + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::AvailableSpace::min_size(), + move |_, _| unchecked.into_any_element(), + ); + + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::AvailableSpace::min_size(), + move |_, _| checked.into_any_element(), + ); + + assert_eq!( + checked_width.get().unwrap() - unchecked_width.get().unwrap(), + px(20.) + ); + } + + struct CheckIconWidthHarness { + state: Entity, + width: Rc>>, + } + + impl Render for CheckIconWidthHarness { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { + let width = self.width.clone(); + let item = self.state.update(cx, |state, cx| { + state.update_matches(cx); + state.render_item(0, window, cx) + }); + + div() + .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width))) + .child(item) + } + } + + struct Harness { + state: Entity, + command: Rc) -> Command>, + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div() + .size_full() + .child((self.command)(&self.state).max_h(px(200.))) + } + } + + #[gpui::test] + fn header_and_footer_render_with_current_state(cx: &mut TestAppContext) { + cx.update(crate::init); + let header_calls = Rc::new(Cell::new(0)); + let footer_calls = Rc::new(Cell::new(0)); + let header_matched_count = Rc::new(Cell::new(None)); + let footer_matched_count = Rc::new(Cell::new(None)); + + let (harness, cx) = cx.add_window_view(|window, cx| HeaderFooterHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + header_calls, + footer_calls, + header_matched_count, + footer_matched_count, + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + + let (header_calls, footer_calls, header_matched_count, footer_matched_count) = + cx.update(|_, cx| { + let harness = harness.read(cx); + ( + harness.header_calls.get(), + harness.footer_calls.get(), + harness.header_matched_count.get(), + harness.footer_matched_count.get(), + ) + }); + assert!(header_calls > 0); + assert!(footer_calls > 0); + assert_eq!(header_matched_count, Some(2)); + assert_eq!(footer_matched_count, Some(2)); + } + + #[gpui::test] + fn custom_empty_slot_renders_with_current_state(cx: &mut TestAppContext) { + cx.update(crate::init); + let empty_calls = Rc::new(Cell::new(0)); + let empty_matched_count = Rc::new(Cell::new(None)); + let calls = empty_calls.clone(); + let matched_count = empty_matched_count.clone(); + let (_harness, cx) = cx.add_window_view(move |window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + let calls = calls.clone(); + let matched_count = matched_count.clone(); + Command::new(state).empty( + move |state: &CommandState, _: &mut Window, _: &mut gpui::App| { + calls.set(calls.get() + 1); + matched_count.set(Some(state.matched_count())); + div().child("Custom empty") + }, + ) + }), + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + + assert!(empty_calls.get() > 0); + assert_eq!(empty_matched_count.get(), Some(0)); + } + + fn entries_with_late_first_enabled_item() -> Vec { + vec![ + CommandGroup::new() + .label("Disabled") + .items((0..30).map(|ix| { + CommandItem::new() + .label(format!("disabled-{ix}")) + .keywords(["match"]) + .disabled(true) + })) + .into(), + CommandEntry::Separator, + CommandGroup::new() + .label("Enabled") + .item(CommandItem::new().label("enabled").keywords(["match"])) + .into(), + ] + } + + fn assert_first_enabled_row_is_scrolled_into_view( + state: &Entity, + cx: &mut TestAppContext, + ) { + let (selected_row, offset) = state.read_with(cx, |state, _| { + ( + state.matched[state.selected_index.unwrap()].row_ix, + state.scroll_handle.base_handle().offset().y, + ) + }); + + assert!(selected_row > 30); + assert!( + offset < px(-900.), + "the list should scroll to the selected row, not row zero ({offset:?})", + ); + } + + #[gpui::test] + fn first_enabled_selection_resets_scroll_to_its_late_row(cx: &mut TestAppContext) { + cx.update(crate::init); + let (harness, cx) = cx.add_window_view(|window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(|state| { + command_with_entries(state, entries_with_late_first_enabled_item()) + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + assert_first_enabled_row_is_scrolled_into_view(&state, cx); + + cx.update(|window, cx| { + state.update(cx, |state, cx| state.set_query("match", window, cx)); + _ = window.draw(cx); + }); + assert_first_enabled_row_is_scrolled_into_view(&state, cx); + + cx.update(|window, cx| { + harness.update(cx, |_, cx| { + cx.notify(); + }); + _ = window.draw(cx); + }); + assert_first_enabled_row_is_scrolled_into_view(&state, cx); + } + + struct HeaderFooterHarness { + state: Entity, + header_calls: Rc>, + footer_calls: Rc>, + header_matched_count: Rc>>, + footer_matched_count: Rc>>, + } + + impl Render for HeaderFooterHarness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let header_calls = self.header_calls.clone(); + let header_matched_count = self.header_matched_count.clone(); + let footer_calls = self.footer_calls.clone(); + let footer_matched_count = self.footer_matched_count.clone(); + + div().size_full().child( + Command::new(&self.state) + .items([ + CommandItem::new().label("Calendar"), + CommandItem::new().label("Calculator"), + ]) + .max_h(px(200.)) + .header(move |state, _, _| { + header_calls.set(header_calls.get() + 1); + header_matched_count.set(Some(state.matched_count())); + div() + }) + .footer(move |state, _, _| { + footer_calls.set(footer_calls.get() + 1); + footer_matched_count.set(Some(state.matched_count())); + div() + }), + ) + } + } + + struct PaddedHarness { + state: Entity, + } + + impl Render for PaddedHarness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().size_full().child( + Command::new(&self.state) + .item( + CommandItem::new() + .label("fixed") + .child(|_, _| div().h(px(32.))), + ) + .max_h(px(200.)) + .p_4(), + ) + } + } + + struct WrappingHarness { + state: Entity, + width: Pixels, + no_wrap: bool, + } + + impl Render for WrappingHarness { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().size_full().child( + div().w(self.width).child( + Command::new(&self.state) + .item(CommandItem::new().label("wrapped").child(|_, _| { + div() + .w_full() + .child("A command row whose content wraps at narrow list widths") + })) + .max_h(px(200.)) + .when(self.no_wrap, |this| this.whitespace_nowrap()), + ), + ) + } + } + + #[gpui::test] + fn wrapping_rows_remeasure_for_the_list_content_width(cx: &mut TestAppContext) { + cx.update(crate::init); + + let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + width: px(360.), + no_wrap: false, + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + + let wide = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + + cx.update(|_, cx| { + harness.update(cx, |harness, cx| { + harness.width = px(120.); + cx.notify(); + }) + }); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let narrow = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + + assert!( + narrow > wide, + "the narrow list should cache a taller wrapped row ({narrow:?} vs {wide:?})", + ); + } + + #[gpui::test] + fn wrapping_rows_remeasure_when_rem_size_changes(cx: &mut TestAppContext) { + cx.update(crate::init); + + let (harness, cx) = cx.add_window_view(|window, cx| { + window.set_rem_size(px(20.)); + WrappingHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + width: px(160.), + no_wrap: false, + } + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let smaller_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + + cx.update(|window, cx| { + window.set_rem_size(px(28.)); + _ = window.draw(cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let larger_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + + assert!( + larger_rem > smaller_rem, + "a larger rem should remeasure the fixed-width wrapped row ({larger_rem:?} vs {smaller_rem:?})", + ); + } + + #[gpui::test] + fn wrapping_rows_remeasure_when_inherited_typography_changes(cx: &mut TestAppContext) { + cx.update(crate::init); + + let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + width: px(160.), + no_wrap: false, + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let wrapped_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + + cx.update(|window, cx| { + harness.update(cx, |harness, cx| { + harness.no_wrap = true; + cx.notify(); + }); + _ = window.draw(cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let no_wrap_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + assert!( + no_wrap_height < wrapped_height, + "a changed inherited typography should remeasure the fixed-width row ({no_wrap_height:?} vs {wrapped_height:?})", + ); + } + + #[gpui::test] + fn outer_command_padding_does_not_inflate_measured_row_heights(cx: &mut TestAppContext) { + cx.update(crate::init); + + let (harness, cx) = cx.add_window_view(|window, cx| PaddedHarness { + state: cx.new(|cx| CommandState::new(window, cx)), + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height); + + assert_eq!(height, px(44.)); + } + + #[gpui::test] + fn custom_rows_keep_independent_heights(cx: &mut TestAppContext) { + cx.update(crate::init); + + let (harness, cx) = cx.add_window_view(|window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(|state| { + Command::new(state) + .group( + CommandGroup::new().label("Short").item( + CommandItem::new() + .label("short") + .child(|_, _| div().h(px(32.))), + ), + ) + .separator() + .group( + CommandGroup::new().label("Tall").item( + CommandItem::new() + .label("tall") + .child(|_, _| div().h(px(72.))), + ), + ) + }), + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + let row_sizes = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes.clone()); + + assert_eq!(row_sizes.len(), 5); + assert!(row_sizes[0].height > px(0.)); + assert_eq!(row_sizes[1].height, px(44.)); + assert_eq!(row_sizes[2].height, px(SEPARATOR_ROW_HEIGHT)); + assert!(row_sizes[3].height > px(0.)); + assert_eq!(row_sizes[4].height, px(84.)); + } + + #[gpui::test] + fn reinstalling_a_model_preserves_selection_by_index_path_and_remeasures_rows( + cx: &mut TestAppContext, + ) { + cx.update(crate::init); + let reversed = Rc::new(Cell::new(false)); + let reversed_for_render = reversed.clone(); + let (harness, cx) = cx.add_window_view(|window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(move |state| { + if reversed_for_render.get() { + Command::new(state) + .item( + CommandItem::new() + .label("beta") + .child(|_, _| div().h(px(72.))), + ) + .item( + CommandItem::new() + .label("alpha") + .child(|_, _| div().h(px(32.))), + ) + } else { + Command::new(state) + .item( + CommandItem::new() + .label("alpha") + .child(|_, _| div().h(px(32.))), + ) + .item( + CommandItem::new() + .label("beta") + .child(|_, _| div().h(px(72.))), + ) + } + }), + }); + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + cx.update(|window, cx| { + state.update(cx, |state, cx| state.select_by(1, window, cx)); + }); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(IndexPath::new(1).section(0)), + ); + + reversed.set(true); + cx.update(|window, cx| { + harness.update(cx, |_, cx| cx.notify()); + _ = window.draw(cx); + }); + + let (selected_matched_index, selected_index, row_sizes) = + state.read_with(cx, |state, _| { + ( + state.selected_index, + state.selected_index(), + state.row_sizes.clone(), + ) + }); + assert_eq!(selected_matched_index, Some(1)); + assert_eq!(selected_index, Some(IndexPath::new(1).section(0))); + assert_eq!(row_sizes[0].height, px(84.)); + assert_eq!(row_sizes[1].height, px(44.)); + } + + #[gpui::test] + fn a_state_redraw_reuses_the_installed_custom_row_measurement(cx: &mut TestAppContext) { + cx.update(crate::init); + let renders = Rc::new(Cell::new(0)); + let count = renders.clone(); + let cx = cx.add_empty_window(); + let state = cx.update(|window, cx| { + cx.new(|cx| { + command_state( + window, + cx, + [CommandEntry::Item( + CommandItem::new().label("custom").child(move |_, _| { + count.set(count.get() + 1); + div().child("Custom") + }), + )], + ) + }) + }); + + let first_state = state.clone(); + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::AvailableSpace::min_size(), + move |_, _| first_state.into_any_element(), + ); + let settled_state = state.clone(); + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::AvailableSpace::min_size(), + move |_, _| settled_state.into_any_element(), + ); + let after_first_draw = renders.get(); + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::AvailableSpace::min_size(), + move |_, _| state.into_any_element(), + ); + + assert_eq!(renders.get() - after_first_draw, 2); + } + + #[gpui::test] + fn moving_past_the_visible_rows_scrolls_the_list(cx: &mut TestAppContext) { + cx.update(crate::init); + + let (harness, cx) = cx.add_window_view(|window, cx| Harness { + state: cx.new(|cx| CommandState::new(window, cx)), + command: Rc::new(|state| { + Command::new(state) + .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}")))) + }), + }); + + cx.run_until_parked(); + cx.update(|window, cx| _ = window.draw(cx)); + + let state = cx.update(|_, cx| harness.read(cx).state.clone()); + assert_eq!( + state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y), + px(0.), + ); + + // The list is capped well below 50 rows, so walking to the last one has + // to bring the viewport with it. + cx.update(|window, cx| { + state.update(cx, |state, cx| { + for _ in 0..49 { + state.select_by(1, window, cx); + } + }) + }); + cx.update(|window, cx| _ = window.draw(cx)); + + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(IndexPath::new(49).section(0)) + ); + assert!( + state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y) < px(0.), + "selecting the last row should have scrolled the list", + ); + } +} diff --git a/vendor/gpui-component/src/component_traits.rs b/vendor/gpui-component/src/component_traits.rs new file mode 100644 index 00000000..05bc7cbd --- /dev/null +++ b/vendor/gpui-component/src/component_traits.rs @@ -0,0 +1,2 @@ +pub use gpui_base::component_traits::Collapsible; +pub use gpui_base::{Disableable, Selectable}; diff --git a/vendor/gpui-component/src/description_list.rs b/vendor/gpui-component/src/description_list.rs index 5ddce5d2..f642ad7f 100644 --- a/vendor/gpui-component/src/description_list.rs +++ b/vendor/gpui-component/src/description_list.rs @@ -1,9 +1,9 @@ use gpui::{ - div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, DefiniteLength, IntoElement, - ParentElement, RenderOnce, SharedString, Styled, Window, + AnyElement, App, Axis, DefiniteLength, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::FluentBuilder as _, px, relative, }; -use crate::{h_flex, text::Text, v_flex, ActiveTheme as _, AxisExt, Sizable, Size}; +use crate::{ActiveTheme as _, AxisExt, Sizable, Size, h_flex, text::Text, v_flex}; /// A description list. #[derive(IntoElement)] @@ -23,7 +23,7 @@ pub enum DescriptionItem { value: DescriptionText, span: usize, }, - Divider, + Separator, } /// Text for the label or value in the [`DescriptionList`]. @@ -205,9 +205,9 @@ impl DescriptionList { self } - /// Add a divider to the list. - pub fn divider(mut self) -> Self { - self.items.push(DescriptionItem::Divider); + /// Add a separator to the list. + pub fn separator(mut self) -> Self { + self.items.push(DescriptionItem::Separator); self } @@ -283,7 +283,7 @@ impl RenderOnce for DescriptionList { .gap(gap) .overflow_hidden() .when(self.bordered, |this| { - this.rounded(padding_x) + this.rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) }) @@ -298,7 +298,7 @@ impl RenderOnce for DescriptionList { let is_first_col = item_ix == 0; match item { - DescriptionItem::Item { label, value, .. } => { + DescriptionItem::Item { label, value, span } => { let el = if self.layout.is_vertical() { v_flex() } else { @@ -306,6 +306,7 @@ impl RenderOnce for DescriptionList { }; el.flex_1() + .flex_basis(relative((span as f32) / (self.columns as f32))) .overflow_x_hidden() .child( div() @@ -329,7 +330,7 @@ impl RenderOnce for DescriptionList { this.border_b_1() }) .border_color(cx.theme().border) - .bg(cx.theme().description_list_label) + .bg(cx.theme().tokens.description_list_label) }) .map(|this| match label_width { Some(label_width) => { @@ -349,7 +350,7 @@ impl RenderOnce for DescriptionList { ) } _ => div().h_2().w_full().when(self.bordered, |this| { - this.bg(cx.theme().description_list_label) + this.bg(cx.theme().tokens.description_list_label) }), } }) diff --git a/vendor/gpui-component/src/dialog.rs b/vendor/gpui-component/src/dialog.rs deleted file mode 100644 index ff990baf..00000000 --- a/vendor/gpui-component/src/dialog.rs +++ /dev/null @@ -1,551 +0,0 @@ -use std::{rc::Rc, time::Duration}; - -use gpui::{ - Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, Edges, - FocusHandle, Hsla, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, - Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, - point, prelude::FluentBuilder, px, relative, -}; -use rust_i18n::t; - -use crate::{ - ActiveTheme as _, IconName, Root, Sizable as _, StyledExt, WindowExt as _, - actions::{Cancel, Confirm}, - animation::cubic_bezier, - button::{Button, ButtonVariant, ButtonVariants as _}, - h_flex, - scroll::ScrollableElement as _, - v_flex, -}; - -const CONTEXT: &str = "Dialog"; -pub(crate) fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("escape", Cancel, Some(CONTEXT)), - KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), - ]); -} - -type RenderButtonFn = Box AnyElement>; -type FooterFn = - Box Vec>; - -/// Dialog button props. -pub struct DialogButtonProps { - ok_text: Option, - ok_variant: ButtonVariant, - cancel_text: Option, - cancel_variant: ButtonVariant, -} - -impl Default for DialogButtonProps { - fn default() -> Self { - Self { - ok_text: None, - ok_variant: ButtonVariant::Primary, - cancel_text: None, - cancel_variant: ButtonVariant::default(), - } - } -} - -impl DialogButtonProps { - /// Sets the text of the OK button. Default is `OK`. - pub fn ok_text(mut self, ok_text: impl Into) -> Self { - self.ok_text = Some(ok_text.into()); - self - } - - /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`. - pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self { - self.ok_variant = ok_variant; - self - } - - /// Sets the text of the Cancel button. Default is `Cancel`. - pub fn cancel_text(mut self, cancel_text: impl Into) -> Self { - self.cancel_text = Some(cancel_text.into()); - self - } - - /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`. - pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self { - self.cancel_variant = cancel_variant; - self - } -} - -/// A modal to display content in a dialog box. -#[derive(IntoElement)] -pub struct Dialog { - style: StyleRefinement, - title: Option, - footer: Option, - content: Div, - width: Pixels, - max_width: Option, - margin_top: Option, - - on_close: Rc, - on_ok: Option bool + 'static>>, - on_cancel: Rc bool + 'static>, - button_props: DialogButtonProps, - close_button: bool, - overlay: bool, - overlay_closable: bool, - keyboard: bool, - - /// This will be change when open the dialog, the focus handle is create when open the dialog. - pub(crate) focus_handle: FocusHandle, - pub(crate) layer_ix: usize, - pub(crate) overlay_visible: bool, -} - -pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla { - if !overlay { - return hsla(0., 0., 0., 0.); - } - - cx.theme().overlay -} - -impl Dialog { - /// Create a new dialog. - pub fn new(_: &mut Window, cx: &mut App) -> Self { - Self { - focus_handle: cx.focus_handle(), - style: StyleRefinement::default(), - title: None, - footer: None, - content: v_flex(), - margin_top: None, - width: px(480.), - max_width: None, - overlay: true, - keyboard: true, - layer_ix: 0, - overlay_visible: false, - on_close: Rc::new(|_, _, _| {}), - on_ok: None, - on_cancel: Rc::new(|_, _, _| true), - button_props: DialogButtonProps::default(), - close_button: true, - overlay_closable: true, - } - } - - /// Sets the title of the dialog. - pub fn title(mut self, title: impl IntoElement) -> Self { - self.title = Some(title.into_any_element()); - self - } - - /// Set the footer of the dialog. - /// - /// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`. - /// - /// - First `RenderButtonFn` is the render function for the OK button. - /// - Second `RenderButtonFn` is the render function for the CANCEL button. - /// - /// When you set the footer, the footer will be placed default footer buttons. - pub fn footer(mut self, footer: F) -> Self - where - E: IntoElement, - F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec + 'static, - { - self.footer = Some(Box::new(move |ok, cancel, window, cx| { - footer(ok, cancel, window, cx) - .into_iter() - .map(|e| e.into_any_element()) - .collect() - })); - self - } - - /// Set to use confirm dialog, with OK and Cancel buttons. - /// - /// See also [`Self::alert`] - pub fn confirm(self) -> Self { - self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)]) - .overlay_closable(false) - .close_button(false) - } - - /// Set to as a alter dialog, with OK button. - /// - /// See also [`Self::confirm`] - pub fn alert(self) -> Self { - self.footer(|ok, _, window, cx| vec![ok(window, cx)]) - .overlay_closable(false) - .close_button(false) - } - - /// Set the button props of the dialog. - pub fn button_props(mut self, button_props: DialogButtonProps) -> Self { - self.button_props = button_props; - self - } - - /// Sets the callback for when the dialog is closed. - /// - /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback. - pub fn on_close( - mut self, - on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_close = Rc::new(on_close); - self - } - - /// Sets the callback for when the dialog is has been confirmed. - /// - /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. - pub fn on_ok( - mut self, - on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, - ) -> Self { - self.on_ok = Some(Rc::new(on_ok)); - self - } - - /// Sets the callback for when the dialog is has been canceled. - /// - /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. - pub fn on_cancel( - mut self, - on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, - ) -> Self { - self.on_cancel = Rc::new(on_cancel); - self - } - - /// Sets the false to hide close icon, default: true - pub fn close_button(mut self, close_button: bool) -> Self { - self.close_button = close_button; - self - } - - /// Set the top offset of the dialog, defaults to None, will use the 1/10 of the viewport height. - pub fn margin_top(mut self, margin_top: Pixels) -> Self { - self.margin_top = Some(margin_top); - self - } - - /// Sets the width of the dialog, defaults to 480px. - /// - /// See also [`Self::width`] - pub fn w(mut self, width: Pixels) -> Self { - self.width = width; - self - } - - /// Sets the width of the dialog, defaults to 480px. - pub fn width(mut self, width: Pixels) -> Self { - self.width = width; - self - } - - /// Set the maximum width of the dialog, defaults to `None`. - pub fn max_w(mut self, max_width: Pixels) -> Self { - self.max_width = Some(max_width); - self - } - - /// Set the overlay of the dialog, defaults to `true`. - pub fn overlay(mut self, overlay: bool) -> Self { - self.overlay = overlay; - self - } - - /// Set the overlay closable of the dialog, defaults to `true`. - /// - /// When the overlay is clicked, the dialog will be closed. - pub fn overlay_closable(mut self, overlay_closable: bool) -> Self { - self.overlay_closable = overlay_closable; - self - } - - /// Set whether to support keyboard esc to close the dialog, defaults to `true`. - pub fn keyboard(mut self, keyboard: bool) -> Self { - self.keyboard = keyboard; - self - } - - pub(crate) fn has_overlay(&self) -> bool { - self.overlay - } -} - -impl ParentElement for Dialog { - fn extend(&mut self, elements: impl IntoIterator) { - self.content.extend(elements); - } -} - -impl Styled for Dialog { - fn style(&mut self) -> &mut gpui::StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for Dialog { - fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { - let layer_ix = self.layer_ix; - let on_close = self.on_close.clone(); - let on_ok = self.on_ok.clone(); - let on_cancel = self.on_cancel.clone(); - - let render_ok: RenderButtonFn = Box::new({ - let on_ok = on_ok.clone(); - let on_close = on_close.clone(); - let ok_text = self - .button_props - .ok_text - .unwrap_or_else(|| t!("Dialog.ok").into()); - let ok_variant = self.button_props.ok_variant; - move |_, _| { - Button::new("ok") - .label(ok_text) - .with_variant(ok_variant) - .on_click({ - let on_ok = on_ok.clone(); - let on_close = on_close.clone(); - - move |_, window, cx| { - if let Some(on_ok) = &on_ok { - if !on_ok(&ClickEvent::default(), window, cx) { - return; - } - } - - on_close(&ClickEvent::default(), window, cx); - window.close_dialog(cx); - } - }) - .into_any_element() - } - }); - let render_cancel: RenderButtonFn = Box::new({ - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - let cancel_text = self - .button_props - .cancel_text - .unwrap_or_else(|| t!("Dialog.cancel").into()); - let cancel_variant = self.button_props.cancel_variant; - move |_, _| { - Button::new("cancel") - .label(cancel_text) - .with_variant(cancel_variant) - .on_click({ - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - move |_, window, cx| { - if !on_cancel(&ClickEvent::default(), window, cx) { - return; - } - - on_close(&ClickEvent::default(), window, cx); - window.close_dialog(cx); - } - }) - .into_any_element() - } - }); - - let window_paddings = crate::window_border::window_paddings(window); - let view_size = window.viewport_size() - - gpui::size( - window_paddings.left + window_paddings.right, - window_paddings.top + window_paddings.bottom, - ); - let bounds = Bounds { - origin: Point::default(), - size: view_size, - }; - let offset_top = px(layer_ix as f32 * 16.); - let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; - let x = bounds.center().x - self.width / 2.; - - let base_size = window.text_style().font_size; - let rem_size = window.rem_size(); - - let mut paddings = Edges::all(px(24.)); - if let Some(pl) = self.style.padding.left { - paddings.left = pl.to_pixels(base_size, rem_size); - } - if let Some(pr) = self.style.padding.right { - paddings.right = pr.to_pixels(base_size, rem_size); - } - if let Some(pt) = self.style.padding.top { - paddings.top = pt.to_pixels(base_size, rem_size); - } - if let Some(pb) = self.style.padding.bottom { - paddings.bottom = pb.to_pixels(base_size, rem_size); - } - - let animation = Animation::new(Duration::from_secs_f64(0.25)) - .with_easing(cubic_bezier(0.32, 0.72, 0., 1.)); - - anchored() - .position(point(window_paddings.left, window_paddings.top)) - .snap_to_window() - .child( - div() - .id("dialog") - .occlude() - .w(view_size.width) - .h(view_size.height) - .when(self.overlay_visible, |this| { - this.bg(overlay_color(self.overlay, cx)) - }) - .when(self.overlay, |this| { - // Only the last dialog owns the `mouse down - close dialog` event. - if (self.layer_ix + 1) != Root::read(window, cx).active_dialogs.len() { - return this; - } - - this.on_any_mouse_down({ - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - move |event, window, cx| { - cx.stop_propagation(); - - if self.overlay_closable && event.button == MouseButton::Left { - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_dialog(cx); - } - } - }) - }) - .child( - v_flex() - .id(layer_ix) - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius_lg) - .min_h_24() - .pt(paddings.top) - .pb(paddings.bottom) - .gap(paddings.top.min(px(16.))) - .refine_style(&self.style) - .px_0() - .key_context(CONTEXT) - .track_focus(&self.focus_handle) - .tab_group() - .when(self.keyboard, |this| { - this.on_action({ - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - move |_: &Cancel, window, cx| { - // FIXME: - // - // Here some Dialog have no focus_handle, so it will not work will Escape key. - // But by now, we `cx.close_dialog()` going to close the last active model, so the Escape is unexpected to work. - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_dialog(cx); - } - }) - .on_action({ - let on_ok = on_ok.clone(); - let on_close = on_close.clone(); - let has_footer = self.footer.is_some(); - move |_: &Confirm, window, cx| { - if let Some(on_ok) = &on_ok { - if on_ok(&ClickEvent::default(), window, cx) { - on_close(&ClickEvent::default(), window, cx); - window.close_dialog(cx); - } - } else if has_footer { - window.close_dialog(cx); - } - } - }) - }) - // There style is high priority, can't be overridden. - .absolute() - .occlude() - .relative() - .left(x) - .top(y) - .w(self.width) - .when_some(self.max_width, |this, w| this.max_w(w)) - .when_some(self.title, |this, title| { - this.child( - div() - .pl(paddings.left) - .pr(paddings.right) - .line_height(relative(1.)) - .font_semibold() - .child(title), - ) - }) - .children(self.close_button.then(|| { - let top = (paddings.top - px(10.)).max(px(8.)); - let right = (paddings.right - px(10.)).max(px(8.)); - - Button::new("close") - .absolute() - .top(top) - .right(right) - .small() - .ghost() - .icon(IconName::Close) - .on_click({ - let on_cancel = self.on_cancel.clone(); - let on_close = self.on_close.clone(); - move |_, window, cx| { - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_dialog(cx); - } - }) - })) - .child( - div().w_full().flex_1().overflow_hidden().child( - v_flex() - .id("contents") - .pl(paddings.left) - .pr(paddings.right) - .overflow_y_scrollbar() - .child(self.content), - ), - ) - .when_some(self.footer, |this, footer| { - this.child( - h_flex() - .gap_2() - .pl(paddings.left) - .pr(paddings.right) - .line_height(relative(1.)) - .justify_end() - .children(footer(render_ok, render_cancel, window, cx)), - ) - }) - .with_animation("slide-down", animation.clone(), move |this, delta| { - let y_offset = px(0.) + delta * px(30.); - // This is equivalent to `shadow_xl` with an extra opacity. - let shadow = vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1 * delta), - offset: point(px(0.), px(20.)), - blur_radius: px(25.), - spread_radius: px(-5.), - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1 * delta), - offset: point(px(0.), px(8.)), - blur_radius: px(10.), - spread_radius: px(-6.), - }, - ]; - this.top(y + y_offset).shadow(shadow) - }), - ) - .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)), - ) - } -} diff --git a/vendor/gpui-component/src/dialog/alert_dialog.rs b/vendor/gpui-component/src/dialog/alert_dialog.rs new file mode 100644 index 00000000..806861d6 --- /dev/null +++ b/vendor/gpui-component/src/dialog/alert_dialog.rs @@ -0,0 +1,366 @@ +use gpui::{ + AnyElement, App, ClickEvent, IntoElement, ParentElement, Pixels, RenderOnce, StyleRefinement, + Styled, Window, prelude::FluentBuilder as _, +}; + +use crate::{ + StyledExt as _, WindowExt as _, + dialog::{ + Dialog, DialogButtonProps, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + }, + h_flex, v_flex, +}; + +/// AlertDialog is a modal dialog that interrupts the user with important content +/// and expects a response. +/// +/// It is built on top of the Dialog component with opinionated defaults: +/// - Footer buttons are center-aligned (vs right-aligned in Dialog) +/// - Icon is optional (disabled by default, enable with `.show_icon(true)`) +/// - Simplified API for common alert scenarios +/// - Uses declarative DialogHeader, DialogTitle, DialogDescription, and DialogFooter components +/// - Supports both imperative and declarative API styles +/// +/// # Examples +/// +/// ## Imperative API (using WindowExt) +/// +/// ```ignore +/// use gpui_component::{AlertDialog, alert::AlertVariant}; +/// +/// // Using WindowExt trait +/// window.open_alert_dialog(cx, |alert, _, _| { +/// alert +/// .title("Unsaved Changes") +/// .description("You have unsaved changes. Are you sure you want to leave?") +/// .show_cancel(true) +/// }); +/// ``` +/// +/// ## Declarative API (using trigger and content) +/// +/// ```ignore +/// use gpui_component::{AlertDialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter}; +/// +/// AlertDialog::new(cx) +/// .trigger(Button::new("delete").label("Delete")) +/// .content(|content, _, cx| { +/// content +/// .child( +/// DialogHeader::new() +/// .items_center() +/// .child(DialogTitle::new().child("Delete File")) +/// .child(DialogDescription::new().child("Are you sure?")) +/// ) +/// .child( +/// DialogFooter::new() +/// .justify_center() +/// .child(Button::new("cancel").label("Cancel")) +/// .child(Button::new("confirm").label("Delete")) +/// ) +/// }) +/// ``` +#[derive(IntoElement)] +pub struct AlertDialog { + base: Dialog, + trigger: Option, + icon: Option, + title: Option, + description: Option, + button_props: DialogButtonProps, + children: Vec, +} + +impl AlertDialog { + /// Create a new AlertDialog. + /// + /// By default, the dialog is not overlay closable with a OK button. + /// + pub fn new(cx: &mut App) -> Self { + Self { + base: Dialog::new(cx) + .with_base_alert_dialog(gpui_base::AlertDialog::new(cx)) + .close_button(false), + trigger: None, + icon: None, + title: None, + description: None, + button_props: DialogButtonProps::default(), + children: Vec::new(), + } + } + + /// Set to use confirm dialog, with OK and Cancel buttons. + /// + /// The default of [`AlertDialog`] has OK button. + pub fn confirm(mut self) -> Self { + self.button_props.show_cancel = true; + self + } + + /// Sets the trigger element for the alert dialog. + /// + /// When a trigger is set, the dialog will render as a trigger element that opens the dialog when clicked. + /// + /// **Note**: When using `.trigger()`, you should also use `.content()` to define the dialog content + /// declaratively instead of using `.title()`, `.description()`, etc. + /// + /// The `title`, `description`, `icon`, and `button_props` will be ignored when used together with `.trigger()`. + pub fn trigger(mut self, trigger: impl IntoElement) -> Self { + self.trigger = Some(trigger.into_any_element()); + self + } + + /// Sets the content builder for declarative API. + /// + /// When using this method, you define the dialog content using declarative components like + /// `DialogHeader`, `DialogTitle`, `DialogDescription`, and `DialogFooter`. + /// + /// This method is typically used together with `.trigger()` for a fully declarative API. + /// + /// # Examples + /// + /// ```ignore + /// AlertDialog::new(cx) + /// .trigger(Button::new("delete").label("Delete")) + /// .content(|content, _, cx| { + /// content + /// .child(DialogHeader::new().child(DialogTitle::new().child("Confirm"))) + /// .child(DialogFooter::new().child(Button::new("ok").label("OK"))) + /// }) + /// ``` + pub fn content(mut self, builder: F) -> Self + where + F: Fn(crate::dialog::DialogContent, &mut Window, &mut App) -> crate::dialog::DialogContent + + 'static, + { + self.base = self.base.content(builder); + self + } + + /// Sets the footer builder for declarative API. + /// + /// This is used to define the footer content using declarative components like `DialogFooter`. + /// + /// If not set, a default footer with OK and optional Cancel button will be used. + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.base = self.base.footer(footer); + self + } + + #[track_caller] + fn debug_assert_no_trigger(&self) { + debug_assert!( + self.trigger.is_none() && self.base.content_builder.is_none(), + "Cannot set this property when trigger is used. Use content() to define dialog content instead." + ); + } + + /// Sets the icon of the alert dialog, default is None. + #[track_caller] + pub fn icon(mut self, icon: impl IntoElement) -> Self { + self.debug_assert_no_trigger(); + self.icon = Some(icon.into_any_element()); + self + } + + /// Sets the title of the alert dialog. + #[track_caller] + pub fn title(mut self, title: impl IntoElement) -> Self { + self.debug_assert_no_trigger(); + self.title = Some(title.into_any_element()); + self + } + + /// Sets the description of the alert dialog. + #[track_caller] + pub fn description(mut self, description: impl IntoElement) -> Self { + self.debug_assert_no_trigger(); + self.description = Some(description.into_any_element()); + self + } + + /// Set the button props of the alert dialog. + /// + /// Use this to configure button text, variants, and visibility. + /// + /// # Examples + /// + /// ```ignore + /// alert.button_props( + /// DialogButtonProps::default() + /// .ok_text("Delete") + /// .ok_variant(ButtonVariant::Danger) + /// .cancel_text("Keep") + /// .show_cancel(true) + /// ) + /// ``` + #[track_caller] + pub fn button_props(mut self, button_props: DialogButtonProps) -> Self { + self.debug_assert_no_trigger(); + self.button_props = button_props; + self + } + + /// Sets the width of the alert dialog, defaults to 420px. + pub fn width(mut self, width: impl Into) -> Self { + self.base = self.base.width(width); + self + } + + /// Show cancel button. Default is false. + pub fn show_cancel(mut self, show_cancel: bool) -> Self { + self.button_props = self.button_props.show_cancel(show_cancel); + self + } + + /// Alert dialogs never close from a backdrop press. + #[deprecated(note = "AlertDialog backdrop dismissal is disabled by design")] + pub fn overlay_closable(self, _: bool) -> Self { + self + } + + /// Set the close button of the alert dialog, defaults to `false`. + pub fn close_button(mut self, close_button: bool) -> Self { + self.base = self.base.close_button(close_button); + self + } + + /// Set whether to support keyboard esc to close the dialog, defaults to `true`. + pub fn keyboard(mut self, keyboard: bool) -> Self { + self.base = self.base.keyboard(keyboard); + self + } + + /// Sets the callback for when the alert dialog is closed. + /// + /// Called after [`Self::on_action`] or [`Self::on_cancel`] callback. + pub fn on_close( + mut self, + on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.base = self.base.on_close(on_close); + self + } + + /// Sets the callback for when the OK/action button is clicked. + /// + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. + pub fn on_ok( + mut self, + on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.button_props = self.button_props.on_ok(on_ok); + self + } + + /// Sets the callback for when the alert dialog has been canceled. + /// + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. + pub fn on_cancel( + mut self, + on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.button_props = self.button_props.on_cancel(on_cancel); + self + } + + /// Build the styled dialog surface around the Base alert-dialog host. + pub(crate) fn build_surface(self, window: &mut Window, cx: &mut App) -> Dialog { + let button_props = self.button_props.clone(); + let has_title = self.icon.is_some() || self.title.is_some(); + let has_header = has_title || self.description.is_some(); + let has_footer = self.base.footer.is_some(); + + self.base + .button_props(button_props.clone()) + .when(has_header, |this| { + this.header( + DialogHeader::new().child( + h_flex() + .gap_2() + .items_start() + .when_some(self.icon, |row, icon| row.child(icon)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .when_some(self.title, |this, title| { + this.child(DialogTitle::new().child(title)) + }) + .when_some(self.description, |this, desc| { + this.child(DialogDescription::new().child(desc)) + }), + ), + ), + ) + }) + .children(self.children) + .when(!has_footer, |this| { + // Default footer for AlertDialog if user doesn't provide one, with OK and optional Cancel button + this.footer( + DialogFooter::new() + .when(button_props.show_cancel, |this| { + this.child(button_props.render_cancel(window, cx)) + }) + .child(button_props.render_ok(window, cx)), + ) + }) + } +} + +impl Styled for AlertDialog { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.base.style + } +} + +impl ParentElement for AlertDialog { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl AlertDialog { + fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement { + let content_builder = self.base.content_builder.clone(); + let style = self.base.style.clone(); + let props = self.base.props.clone(); + let mut button_props = self.button_props.clone(); + button_props.on_close = self.base.button_props.on_close.clone(); + + gpui_base::AlertDialogTrigger::new(trigger) + .on_open(move |window, cx| { + let content_builder = content_builder.clone(); + let style = style.clone(); + let props = props.clone(); + let button_props = button_props.clone(); + window.open_dialog(cx, move |dialog, _, cx| { + dialog + .with_base_alert_dialog(gpui_base::AlertDialog::new(cx)) + .refine_style(&style) + .button_props(button_props.clone()) + .with_props(props.clone()) + .when_some(content_builder.clone(), |this, content_builder| { + this.content(move |content, window, cx| { + content_builder(content, window, cx) + }) + }) + }); + }) + .into_any_element() + } +} + +impl RenderOnce for AlertDialog { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + if let Some(trigger) = self.trigger.take() { + // If a trigger is provided, render the trigger element that opens the dialog + self.render_trigger(trigger, window, cx) + } else { + // Otherwise, render the dialog content directly + self.build_surface(window, cx).into_any_element() + } + } +} diff --git a/vendor/gpui-component/src/dialog/content.rs b/vendor/gpui-component/src/dialog/content.rs new file mode 100644 index 00000000..f96da0c3 --- /dev/null +++ b/vendor/gpui-component/src/dialog/content.rs @@ -0,0 +1,44 @@ +use gpui::{ + AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, +}; + +use crate::{ActiveTheme as _, StyledExt as _, v_flex}; + +/// Content container for a dialog. +#[derive(IntoElement)] +pub struct DialogContent { + style: StyleRefinement, + children: Vec, +} + +impl DialogContent { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: Vec::new(), + } + } +} + +impl ParentElement for DialogContent { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for DialogContent { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for DialogContent { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + v_flex() + .w_full() + .flex_1() + .rounded(cx.theme().radius_lg) + .refine_style(&self.style) + .children(self.children) + } +} diff --git a/vendor/gpui-component/src/dialog/description.rs b/vendor/gpui-component/src/dialog/description.rs new file mode 100644 index 00000000..55edab76 --- /dev/null +++ b/vendor/gpui-component/src/dialog/description.rs @@ -0,0 +1,54 @@ +use gpui::{ + AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, +}; +use gpui_base::DialogDescription as BaseDialogDescription; + +use crate::{ActiveTheme as _, StyledExt as _}; + +/// Description element for a dialog header. +/// +/// Typically used inside a DialogHeader component to provide additional context. +/// +/// # Examples +/// +/// ```ignore +/// DialogDescription::new("This action cannot be undone.") +/// ``` +#[derive(IntoElement)] +pub struct DialogDescription { + base: BaseDialogDescription, + style: StyleRefinement, + children: Vec, +} + +impl DialogDescription { + pub fn new() -> Self { + Self { + base: BaseDialogDescription::new(), + style: StyleRefinement::default(), + children: vec![], + } + } +} + +impl ParentElement for DialogDescription { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for DialogDescription { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for DialogDescription { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + self.base + .text_sm() + .text_color(cx.theme().muted_foreground) + .refine_style(&self.style) + .children(self.children) + } +} diff --git a/vendor/gpui-component/src/dialog/dialog.rs b/vendor/gpui-component/src/dialog/dialog.rs new file mode 100644 index 00000000..ada5e8de --- /dev/null +++ b/vendor/gpui-component/src/dialog/dialog.rs @@ -0,0 +1,691 @@ +use std::{rc::Rc, sync::LazyLock, time::Duration}; + +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Edges, FocusHandle, Hsla, + InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, + StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point, + prelude::FluentBuilder, px, +}; +use gpui_base::{ElementExt as _, TextSelectionScopeId}; +use rust_i18n::t; + +use crate::{ + ActiveTheme as _, IconName, Root, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _, + animation::cubic_bezier, + button::{Button, ButtonVariant, ButtonVariants as _}, + dialog::{DialogContent, DialogTitle}, + scroll::ScrollableElement as _, + v_flex, +}; + +pub static ANIMATION_DURATION: LazyLock = LazyLock::new(|| Duration::from_secs_f64(0.25)); +pub use gpui_base::actions::{Cancel, Confirm}; + +/// Dialog button props. +#[derive(Clone)] +pub struct DialogButtonProps { + pub(crate) ok_text: Option, + pub(crate) ok_variant: ButtonVariant, + pub(crate) cancel_text: Option, + pub(crate) cancel_variant: ButtonVariant, + pub(crate) show_cancel: bool, + pub(crate) on_ok: Rc bool + 'static>, + pub(crate) on_cancel: Rc bool + 'static>, + pub(crate) on_close: Rc, +} + +impl Default for DialogButtonProps { + fn default() -> Self { + Self { + ok_text: None, + ok_variant: ButtonVariant::Primary, + cancel_text: None, + cancel_variant: ButtonVariant::default(), + show_cancel: false, + on_ok: Rc::new(|_, _, _| true), + on_cancel: Rc::new(|_, _, _| true), + on_close: Rc::new(|_, _, _| {}), + } + } +} + +impl DialogButtonProps { + /// Sets the text of the OK button. Default is `OK`. + pub fn ok_text(mut self, ok_text: impl Into) -> Self { + self.ok_text = Some(ok_text.into()); + self + } + + /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`. + pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self { + self.ok_variant = ok_variant; + self + } + + /// Sets the text of the Cancel button. Default is `Cancel`. + pub fn cancel_text(mut self, cancel_text: impl Into) -> Self { + self.cancel_text = Some(cancel_text.into()); + self + } + + /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`. + pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self { + self.cancel_variant = cancel_variant; + self + } + + /// Sets whether to show the Cancel button. Default is `false`. + pub fn show_cancel(mut self, show_cancel: bool) -> Self { + self.show_cancel = show_cancel; + self + } + + /// Sets the callback for when the dialog is has been confirmed. + /// + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. + pub fn on_ok( + mut self, + on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_ok = Rc::new(on_ok); + self + } + + /// Sets the callback for when the dialog is has been canceled. + /// + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. + pub fn on_cancel( + mut self, + on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_cancel = Rc::new(on_cancel); + self + } + + pub(crate) fn render_ok(&self, _: &mut Window, _: &mut App) -> AnyElement { + let ok_text = self + .ok_text + .clone() + .unwrap_or_else(|| t!("Dialog.ok").into()); + let ok_variant = self.ok_variant; + + Button::new("ok") + .label(ok_text) + .with_variant(ok_variant) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Confirm { secondary: false }), cx) + }) + .into_any_element() + } + + pub(crate) fn render_cancel(&self, _: &mut Window, _: &mut App) -> AnyElement { + let cancel_text = self + .cancel_text + .clone() + .unwrap_or_else(|| t!("Dialog.cancel").into()); + let cancel_variant = self.cancel_variant; + + Button::new("cancel") + .label(cancel_text) + .with_variant(cancel_variant) + .on_click(|_, window, cx| window.dispatch_action(Box::new(Cancel), cx)) + .into_any_element() + } +} + +type ContentBuilderFn = Rc DialogContent + 'static>; + +#[derive(Clone)] +pub(crate) struct DialogProps { + width: Pixels, + max_width: Option, + margin_top: Option, + close_button: bool, + + overlay: bool, + overlay_closable: bool, + pub(crate) overlay_visible: bool, + keyboard: bool, +} + +impl Default for DialogProps { + fn default() -> Self { + Self { + margin_top: None, + width: px(448.), + max_width: None, + overlay: true, + keyboard: true, + overlay_visible: false, + close_button: true, + overlay_closable: true, + } + } +} + +enum BaseDialogRoot { + Dialog(gpui_base::Dialog), + AlertDialog(gpui_base::AlertDialog), +} + +macro_rules! map_base_root { + ($self:expr, $method:ident($($arg:expr),* $(,)?)) => { + match $self { + BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)), + BaseDialogRoot::AlertDialog(root) => { + BaseDialogRoot::AlertDialog(root.$method($($arg),*)) + } + } + }; +} + +impl BaseDialogRoot { + fn layer(self, index: usize, topmost: bool) -> Self { + map_base_root!(self, layer(index, topmost)) + } + fn focus_handle(self, focus: FocusHandle) -> Self { + map_base_root!(self, focus_handle(focus)) + } + fn close_on_escape(self, value: bool) -> Self { + map_base_root!(self, close_on_escape(value)) + } + fn close_on_backdrop_press(self, value: bool) -> Self { + match self { + Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)), + Self::AlertDialog(root) => Self::AlertDialog(root), + } + } + fn dismiss_below_y(self, value: Pixels) -> Self { + map_base_root!(self, dismiss_below_y(value)) + } + fn backdrop(self, element: impl IntoElement) -> Self { + map_base_root!(self, backdrop(element)) + } + fn popup(self, element: impl IntoElement) -> Self { + map_base_root!(self, popup(element)) + } + fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self { + map_base_root!(self, on_ok(handler)) + } + fn on_cancel( + self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + map_base_root!(self, on_cancel(handler)) + } + fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { + map_base_root!(self, on_close(handler)) + } + fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + map_base_root!(self, request_close(handler)) + } +} + +impl IntoElement for BaseDialogRoot { + type Element = ::Element; + fn into_element(self) -> Self::Element { + match self { + Self::Dialog(root) => root.into_element(), + Self::AlertDialog(root) => root.into_element(), + } + } +} + +/// A modal to display content in a dialog box. +#[derive(IntoElement)] +pub struct Dialog { + base: Option, + pub(crate) style: StyleRefinement, + children: Vec, + trigger: Option, + title: Option, + pub(crate) header: Option, + pub(crate) footer: Option, + pub(crate) content_builder: Option, + pub(crate) props: DialogProps, + + pub(super) button_props: DialogButtonProps, + + /// This will be change when open the dialog, the focus handle is create when open the dialog. + pub(crate) focus_handle: FocusHandle, + pub(crate) layer_ix: usize, + pub(crate) selection_scope: TextSelectionScopeId, +} + +pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla { + if !overlay { + return hsla(0., 0., 0., 0.); + } + + cx.theme().overlay +} + +impl Dialog { + /// Create a new dialog. + pub fn new(cx: &mut App) -> Self { + Self { + base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))), + focus_handle: cx.focus_handle(), + style: StyleRefinement::default(), + trigger: None, + title: None, + header: None, + footer: None, + content_builder: None, + props: DialogProps::default(), + children: Vec::new(), + layer_ix: 0, + selection_scope: TextSelectionScopeId::default(), + button_props: DialogButtonProps::default(), + } + } + + /// Sets the trigger element for the dialog. + /// + /// When a trigger is set, the dialog will render as a trigger button that opens the dialog when clicked. + pub fn trigger(mut self, trigger: impl IntoElement) -> Self { + self.trigger = Some(trigger.into_any_element()); + self + } + + /// Sets the content of the dialog. + pub fn content(mut self, builder: F) -> Self + where + F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static, + { + self.content_builder = Some(Rc::new(builder)); + self + } + + /// Sets the title of the dialog. + pub fn title(mut self, title: impl IntoElement) -> Self { + self.title = Some(title.into_any_element()); + self + } + + /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons. + /// + /// When you set the footer, the `button_props` will be ignored, you need to render the action buttons by yourself. + pub(crate) fn header(mut self, header: impl IntoElement) -> Self { + self.header = Some(header.into_any_element()); + self + } + + /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons. + /// + /// When you set the footer, the `button_props` will be ignored, you need to render the action buttons by yourself. + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } + + /// Set the button props of the dialog. + pub fn button_props(mut self, button_props: DialogButtonProps) -> Self { + self.button_props = button_props; + self + } + pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self { + self.base = Some(BaseDialogRoot::AlertDialog(base)); + self.props.overlay_closable = false; + self + } + + /// Sets the callback for when the dialog is closed. + /// + /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback. + pub fn on_close( + mut self, + on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.button_props.on_close = Rc::new(on_close); + self + } + + /// Sets the callback for when the dialog is has been confirmed. + /// + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. + pub fn on_ok( + mut self, + on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.button_props = self.button_props.on_ok(on_ok); + self + } + + /// Sets the callback for when the dialog is has been canceled. + /// + /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed. + pub fn on_cancel( + mut self, + on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.button_props = self.button_props.on_cancel(on_cancel); + self + } + + /// Sets the false to hide close icon, default: true + pub fn close_button(mut self, close_button: bool) -> Self { + self.props.close_button = close_button; + self + } + + /// Set the top offset of the dialog, defaults to None, will use the 1/10 of the viewport height. + pub fn margin_top(mut self, margin_top: impl Into) -> Self { + self.props.margin_top = Some(margin_top.into()); + self + } + + /// Sets the width of the dialog, defaults to 448px. + /// + /// See also [`Self::width`] + pub fn w(mut self, width: impl Into) -> Self { + self.props.width = width.into(); + self + } + + /// Sets the width of the dialog, defaults to 448px. + pub fn width(mut self, width: impl Into) -> Self { + self.props.width = width.into(); + self + } + + /// Set the maximum width of the dialog, defaults to `None`. + pub fn max_w(mut self, max_width: impl Into) -> Self { + self.props.max_width = Some(max_width.into()); + self + } + + /// Set the overlay of the dialog, defaults to `true`. + pub fn overlay(mut self, overlay: bool) -> Self { + self.props.overlay = overlay; + self + } + + /// Set the overlay closable of the dialog, defaults to `true`. + /// + /// When the overlay is clicked, the dialog will be closed. + pub fn overlay_closable(mut self, overlay_closable: bool) -> Self { + self.props.overlay_closable = overlay_closable; + self + } + + /// Set whether to support keyboard esc to close the dialog, defaults to `true`. + pub fn keyboard(mut self, keyboard: bool) -> Self { + self.props.keyboard = keyboard; + self + } + + pub(crate) fn has_overlay(&self) -> bool { + self.props.overlay + } + + pub(crate) fn with_props(mut self, props: DialogProps) -> Self { + self.props = props; + self + } + + fn defer_close_dialog(window: &mut Window, cx: &mut App) { + Root::update(window, cx, |root, window, cx| { + root.defer_close_dialog(window, cx); + }); + } +} + +impl ParentElement for Dialog { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for Dialog { + fn style(&mut self) -> &mut gpui::StyleRefinement { + &mut self.style + } +} + +impl Dialog { + fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement { + let content_builder = self.content_builder.clone(); + let style = self.style.clone(); + let props = self.props.clone(); + let button_props = self.button_props.clone(); + + gpui_base::DialogTrigger::new(trigger) + .on_open(move |window, cx| { + let content_builder = content_builder.clone(); + let style = style.clone(); + let props = props.clone(); + let button_props = button_props.clone(); + window.open_dialog(cx, move |dialog, _, _| { + dialog + .refine_style(&style) + .button_props(button_props.clone()) + .with_props(props.clone()) + .content({ + let content_builder = content_builder.clone(); + move |content, window, cx| { + if let Some(builder) = content_builder.clone() { + builder(content, window, cx) + } else { + content + } + } + }) + }); + }) + .into_any_element() + } +} + +impl RenderOnce for Dialog { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + if let Some(trigger) = self.trigger.take() { + return self.render_trigger(trigger, window, cx); + } + + let layer_ix = self.layer_ix; + let selection_scope = self.selection_scope; + let on_close = self.button_props.on_close.clone(); + let on_ok = self.button_props.on_ok.clone(); + let on_cancel = self.button_props.on_cancel.clone(); + + let window_paddings = crate::window_border::window_paddings(window); + let view_size = window.viewport_size() + - gpui::size( + window_paddings.left + window_paddings.right, + window_paddings.top + window_paddings.bottom, + ); + let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + px(layer_ix as f32 * 16.); + let x = view_size.width / 2. - self.props.width / 2.; + + let base_size = window.text_style().font_size; + let rem_size = window.rem_size(); + + let mut paddings = Edges::all(px(16.)); + if let Some(pl) = self.style.padding.left { + paddings.left = pl.to_pixels(base_size, rem_size); + } + if let Some(pr) = self.style.padding.right { + paddings.right = pr.to_pixels(base_size, rem_size); + } + if let Some(pt) = self.style.padding.top { + paddings.top = pt.to_pixels(base_size, rem_size); + } + if let Some(pb) = self.style.padding.bottom { + paddings.bottom = pb.to_pixels(base_size, rem_size); + } + + // x1 = 1/3, x2 = 2/3 make the bezier's time mapping the identity, + // preserving the trajectory this dialog was tuned with before + // `cubic_bezier` solved for x; vaul's (0.32, 0.72, 0., 1.) is far + // more front-loaded under the CSS-correct solver. + let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier( + 1. / 3., + 0.72, + 2. / 3., + 1., + )); + + anchored() + .position(point(window_paddings.left, window_paddings.top)) + .snap_to_window() + .child( + div() + .id("dialog") + .occlude() + .w(view_size.width) + .h(view_size.height) + .child( + self.base + .take() + .expect("Dialog base host is always present") + .layer( + layer_ix, + (self.layer_ix + 1) == Root::read(window, cx).active_dialogs.len(), + ) + .focus_handle(self.focus_handle.clone()) + .close_on_escape(self.props.keyboard) + .close_on_backdrop_press(self.props.overlay_closable) + .dismiss_below_y(TITLE_BAR_HEIGHT) + .when(self.props.overlay, |this| { + this.backdrop( + div() + .absolute() + .size_full() + .window_control_area(WindowControlArea::Drag) + .when(self.props.overlay_visible, |overlay| { + overlay.bg(overlay_color(true, cx)) + }), + ) + }) + .on_ok(move |event, window, cx| on_ok(event, window, cx)) + .on_cancel(move |event, window, cx| on_cancel(event, window, cx)) + .on_close(move |event, window, cx| on_close(event, window, cx)) + .request_close(move |deferred, window, cx| { + if deferred { + Self::defer_close_dialog(window, cx); + } else { + window.close_dialog(cx); + } + }) + .popup( + v_flex() + .id(layer_ix) + .bg(cx.theme().tokens.background) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .min_h_24() + .pt(paddings.top) + .pb(paddings.bottom) + .gap(paddings.top.max(px(8.))) + .refine_style(&self.style) + .px_0() + // There style is high priority, can't be overridden. + .absolute() + .occlude() + .relative() + .left(x) + .top(y) + .w(self.props.width) + .when_some(self.props.max_width, |this, w| this.max_w(w)) + .child( + v_flex() + .flex_1() + .overflow_hidden() + .gap_y_2() + .when_some(self.header, |this, header| { + this.child( + div() + .pl(paddings.left) + .pr(paddings.right) + .child(header), + ) + }) + .when_some(self.title, |this, title| { + this.child( + DialogTitle::new() + .pl(paddings.left) + .pr(paddings.right) + .child(title), + ) + }) + .when_some(self.content_builder, |this, builder| { + this.child(builder( + DialogContent::new() + .gap(paddings.bottom) + .pl(paddings.left) + .pr(paddings.right), + window, + cx, + )) + }) + .when(!self.children.is_empty(), |this| { + this.child( + div().flex_1().overflow_hidden().child( + // Body + v_flex() + .size_full() + .overflow_y_scrollbar() + .pl(paddings.left) + .pr(paddings.right) + .children(self.children), + ), + ) + }), + ) + .when_some(self.footer, |this, footer| { + this.child( + div() + .pl(paddings.left) + .pr(paddings.right) + .child(footer), + ) + }) + .children(self.props.close_button.then(|| { + let top = (paddings.top - px(10.)).max(px(8.)); + let right = (paddings.right - px(10.)).max(px(8.)); + + gpui_base::DialogClose::new() + .absolute() + .top(top) + .right(right) + .child( + Button::new("close") + .small() + .ghost() + .icon(IconName::Close), + ) + })) + .with_animation( + "slide-down", + animation.clone(), + move |this, delta| { + // This is equivalent to `shadow_xl` with an extra opacity. + let shadow = vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1 * delta), + offset: point(px(0.), px(20.)), + blur_radius: px(25.), + spread_radius: px(-5.), + inset: false, + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1 * delta), + offset: point(px(0.), px(8.)), + blur_radius: px(10.), + spread_radius: px(-6.), + inset: false, + }, + ]; + this.top(y * delta).shadow(shadow) + }, + ) + .text_selection_scope(selection_scope), + ), + ) + .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)), + ) + .into_any_element() + } +} diff --git a/vendor/gpui-component/src/dialog/footer.rs b/vendor/gpui-component/src/dialog/footer.rs new file mode 100644 index 00000000..57e3e01d --- /dev/null +++ b/vendor/gpui-component/src/dialog/footer.rs @@ -0,0 +1,119 @@ +use gpui::{ + AnyElement, App, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, + StatefulInteractiveElement, StyleRefinement, Styled, Window, div, relative, +}; + +use crate::{ActiveTheme as _, StyledExt as _, dialog::Confirm, h_flex}; + +/// Footer section of a dialog, typically contains action buttons. +/// +/// # Examples +/// +/// ```ignore +/// DialogFooter::new() +/// .child(DialogClose::new().child(Button::new("cancel").label("Cancel"))) +/// .child(Button::new("confirm").label("Confirm")) +/// ``` +#[derive(IntoElement)] +pub struct DialogFooter { + style: StyleRefinement, + children: Vec, +} + +impl DialogFooter { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: Vec::new(), + } + } +} + +impl ParentElement for DialogFooter { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for DialogFooter { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for DialogFooter { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .gap_2() + .justify_end() + .line_height(relative(1.)) + .rounded_b(cx.theme().radius_lg) + .refine_style(&self.style) + .children(self.children) + } +} + +pub trait DialogFooterButton { + fn is_cancel(&self) -> bool { + false + } + + fn is_action(&self) -> bool { + false + } +} +#[derive(IntoElement)] +pub struct DialogClose { + base: gpui_base::DialogClose, +} + +impl DialogClose { + pub fn new() -> Self { + Self { + base: gpui_base::DialogClose::new(), + } + } +} + +impl ParentElement for DialogClose { + fn extend(&mut self, elements: impl IntoIterator) { + self.base.extend(elements); + } +} + +impl RenderOnce for DialogClose { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div().size_full().child(self.base) + } +} + +#[derive(IntoElement)] +pub struct DialogAction { + children: Vec, +} + +impl DialogAction { + pub fn new() -> Self { + Self { + children: Vec::new(), + } + } +} + +impl ParentElement for DialogAction { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for DialogAction { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .size_full() + .id("dialog-action") + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(Confirm { secondary: false }), cx) + }) + .children(self.children) + } +} diff --git a/vendor/gpui-component/src/dialog/header.rs b/vendor/gpui-component/src/dialog/header.rs new file mode 100644 index 00000000..1167a7f1 --- /dev/null +++ b/vendor/gpui-component/src/dialog/header.rs @@ -0,0 +1,50 @@ +use gpui::{ + AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, +}; + +use crate::{StyledExt as _, v_flex}; + +/// Header section of a dialog, typically contains DialogTitle and DialogDescription. +/// +/// # Examples +/// +/// ```ignore +/// DialogHeader::new() +/// .child(DialogTitle::new().child("Delete Account")) +/// .child(DialogDescription::new().child("This action cannot be undone.")) +/// ``` +#[derive(IntoElement)] +pub struct DialogHeader { + style: StyleRefinement, + children: Vec, +} + +impl DialogHeader { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: Vec::new(), + } + } +} + +impl ParentElement for DialogHeader { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for DialogHeader { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for DialogHeader { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + v_flex() + .gap_2() + .refine_style(&self.style) + .children(self.children) + } +} diff --git a/vendor/gpui-component/src/dialog/mod.rs b/vendor/gpui-component/src/dialog/mod.rs new file mode 100644 index 00000000..1eed407e --- /dev/null +++ b/vendor/gpui-component/src/dialog/mod.rs @@ -0,0 +1,15 @@ +mod alert_dialog; +mod content; +mod description; +mod dialog; +mod footer; +mod header; +mod title; + +pub use alert_dialog::*; +pub use content::DialogContent; +pub use description::DialogDescription; +pub use dialog::*; +pub use footer::*; +pub use header::DialogHeader; +pub use title::DialogTitle; diff --git a/vendor/gpui-component/src/dialog/title.rs b/vendor/gpui-component/src/dialog/title.rs new file mode 100644 index 00000000..4751cd57 --- /dev/null +++ b/vendor/gpui-component/src/dialog/title.rs @@ -0,0 +1,48 @@ +use gpui::{ + AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, + relative, +}; +use gpui_base::DialogTitle as BaseDialogTitle; + +use crate::StyledExt as _; + +/// Title element for a dialog header. +#[derive(IntoElement)] +pub struct DialogTitle { + base: BaseDialogTitle, + style: StyleRefinement, + children: Vec, +} + +impl DialogTitle { + pub fn new() -> Self { + Self { + base: BaseDialogTitle::new(), + style: StyleRefinement::default(), + children: vec![], + } + } +} + +impl ParentElement for DialogTitle { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for DialogTitle { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for DialogTitle { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.base + .text_base() + .font_semibold() + .line_height(relative(1.)) + .refine_style(&self.style) + .children(self.children) + } +} diff --git a/vendor/gpui-component/src/dock/dock.rs b/vendor/gpui-component/src/dock/dock.rs index 2672cc94..97a80bf7 100644 --- a/vendor/gpui-component/src/dock/dock.rs +++ b/vendor/gpui-component/src/dock/dock.rs @@ -1,410 +1,172 @@ -//! Dock is a fixed container that places at left, bottom, right of the Windows. +//! The gpui-component appearance for the dock area: the outer frame, the +//! split frames, and one dock's chrome. -use std::{ops::Deref, sync::Arc}; +use std::{ops::Deref as _, rc::Rc, sync::Arc}; use gpui::{ - App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent, - MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _, - WeakEntity, Window, div, prelude::FluentBuilder as _, px, + AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _, + IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style, + Styled as _, Window, div, prelude::FluentBuilder as _, px, +}; +use gpui_base::dock::{ + DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState, PanelView, + TabGroupRenderer, TilesRenderer, }; -use serde::{Deserialize, Serialize}; use crate::{ - StyledExt, - resizable::{PANEL_MIN_SIZE, resize_handle}, + ActiveTheme as _, Side, StyledExt as _, + dock::{ + DockSkin, SkinShared, invalid_panel::InvalidPanel, panel_handle, tab_panel::TabGroupSkin, + tiles::TilesSkin, + }, + resize_handle, }; -use super::{DockArea, DockItem, PanelView, TabPanel}; - +/// The payload a dock's resize handle drags. It draws nothing: the handle +/// itself is the affordance. #[derive(Clone)] struct ResizePanel; impl Render for ResizePanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Empty } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum DockPlacement { - #[serde(rename = "center")] - Center, - #[serde(rename = "left")] - Left, - #[serde(rename = "bottom")] - Bottom, - #[serde(rename = "right")] - Right, -} - -impl DockPlacement { - fn axis(&self) -> Axis { - match self { - Self::Left | Self::Right => Axis::Horizontal, - Self::Bottom => Axis::Vertical, - Self::Center => unreachable!(), - } - } - - pub fn is_left(&self) -> bool { - matches!(self, Self::Left) +impl DockAreaRenderer for DockSkin { + fn frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id("dock-area") + .relative() + .size_full() + .overflow_hidden() + .flex() + .flex_row() } - pub fn is_bottom(&self) -> bool { - matches!(self, Self::Bottom) + fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id("dock-area-center") + .flex() + .flex_1() + .flex_col() + .overflow_hidden() } - pub fn is_right(&self) -> bool { - matches!(self, Self::Right) + fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful
{ + // A split frame with no size at all collapses: base puts it between + // a `resizable_panel` and the resizable group, and between + // `center_frame` and the centre's root split, and neither parent + // sizes it. `the_centre_and_the_bottom_dock_share_the_column` fails + // without this. `size_full` is what the old `StackPanel::render` + // carried; `flex_1` is belt and braces — either alone passes every + // case I could construct, so this does not depend on which one wins + // in a given parent. + div() + .id(("dock-split-frame", node.as_u64())) + .size_full() + .flex_1() + .min_h(px(0.)) + .overflow_hidden() + .bg(cx.theme().tokens.tab_bar) } -} -/// The Dock is a fixed container that places at left, bottom, right of the Windows. -/// -/// This is unlike Panel, it can't be move or add any other panel. -pub struct Dock { - pub(super) placement: DockPlacement, - dock_area: WeakEntity, - pub(crate) panel: DockItem, - /// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height. - pub(super) size: Pixels, - pub(super) open: bool, - /// Whether the Dock is collapsible, default: true - pub(super) collapsible: bool, - - // Runtime state - /// Whether the Dock is resizing - resizing: bool, -} - -impl Dock { - pub(crate) fn new( - dock_area: WeakEntity, - placement: DockPlacement, + fn render_dock( + &self, + dock: &DockContext, + content: AnyElement, window: &mut Window, - cx: &mut Context, - ) -> Self { - let panel = cx.new(|cx| { - let mut tab = TabPanel::new(None, dock_area.clone(), window, cx); - tab.closable = false; - tab - }); - - let panel = DockItem::Tabs { - size: None, - items: Vec::new(), - active_ix: 0, - view: panel.clone(), - }; - - Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx); - - Self { - placement, - dock_area, - panel, - open: true, - collapsible: true, - size: px(200.0), - resizing: false, + cx: &mut App, + ) -> AnyElement { + let placement = dock.placement(); + let open = dock.is_open(); + + // A closed left or right dock takes no space at all; a closed bottom + // dock keeps a strip so its tab bar stays clickable. + if !open && !placement.is_bottom() { + return div().into_any_element(); } - } - - pub fn left( - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::new(dock_area, DockPlacement::Left, window, cx) - } - - pub fn bottom( - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::new(dock_area, DockPlacement::Bottom, window, cx) - } - pub fn right( - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::new(dock_area, DockPlacement::Right, window, cx) + div() + .flex() + .flex_none() + .relative() + .overflow_hidden() + .map(|this| match placement { + DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()), + DockPlacement::Bottom => this.w_full().h(dock.size()), + // Base never builds a dock for the centre. + DockPlacement::Center => this, + }) + .when(!open && placement.is_bottom(), |this| this.h(px(29.))) + .child(content) + .child(self.render_resize_handle(dock, window, cx)) + .child(DockResizeTracker { + dock: dock.clone(), + shared: self.shared().clone(), + }) + .into_any_element() } - /// Update the Dock to be collapsible or not. + /// The "unknown panel" message the old `InvalidPanel` drew. /// - /// And if the Dock is not collapsible, it will be open. - pub fn set_collapsible(&mut self, collapsible: bool, _: &mut Window, cx: &mut Context) { - self.collapsible = collapsible; - if !collapsible { - self.open = true - } - cx.notify(); - } - - pub(super) fn from_state( - dock_area: WeakEntity, - placement: DockPlacement, - size: Pixels, - panel: DockItem, - open: bool, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx); - - if !open { - match panel.clone() { - DockItem::Tabs { view, .. } => { - view.update(cx, |panel, cx| { - panel.set_collapsed(true, window, cx); - }); - } - DockItem::Split { items, .. } => { - for item in items { - item.set_collapsed(true, window, cx); - } - } - _ => {} - } - } - - Self { - placement, - dock_area, - panel, - open, - size, - collapsible: true, - resizing: false, - } - } - - fn subscribe_panel_events( - dock_area: WeakEntity, - panel: &DockItem, - window: &mut Window, - cx: &mut Context, - ) { - match panel { - DockItem::Tabs { view, .. } => { - window.defer(cx, { - let view = view.clone(); - move |window, cx| { - _ = dock_area.update(cx, |this, cx| { - this.subscribe_panel(&view, window, cx); - }); - } - }); - } - DockItem::Split { items, view, .. } => { - for item in items { - Self::subscribe_panel_events(dock_area.clone(), item, window, cx); - } - window.defer(cx, { - let view = view.clone(); - move |window, cx| { - _ = dock_area.update(cx, |this, cx| { - this.subscribe_panel(&view, window, cx); - }); - } - }); - } - DockItem::Tiles { view, .. } => { - window.defer(cx, { - let view = view.clone(); - move |window, cx| { - _ = dock_area.update(cx, |this, cx| { - this.subscribe_panel(&view, window, cx); - }); - } - }); - } - DockItem::Panel { .. } => { - // Not supported - } - } - } - - pub fn set_panel(&mut self, panel: DockItem, _: &mut Window, cx: &mut Context) { - self.panel = panel; - cx.notify(); - } - - pub fn is_open(&self) -> bool { - self.open - } - - pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { - self.set_open(!self.open, window, cx); - } - - /// Returns the size of the Dock, the size is means the width or height of - /// the Dock, if the placement is left or right, the size is width, - /// otherwise the size is height. - pub fn size(&self) -> Pixels { - self.size - } - - /// Set the size of the Dock. - pub fn set_size(&mut self, size: Pixels, _: &mut Window, cx: &mut Context) { - self.size = size.max(PANEL_MIN_SIZE); - cx.notify(); - } - - /// Set the open state of the Dock. - pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context) { - self.open = open; - let item = self.panel.clone(); - cx.defer_in(window, move |_, window, cx| { - item.set_collapsed(!open, window, cx); - }); - cx.notify(); + /// It answers `dump` with the state it was handed, so a layout written by + /// a build that knows the panel survives a load and save here. + fn build_placeholder( + &self, + state: &PanelState, + _: &mut Window, + cx: &mut App, + ) -> Option> { + let state = state.clone(); + Some(panel_handle(cx.new(|cx| { + InvalidPanel::new(state.panel_name.clone(), state, cx) + }))) } - /// Add item to the Dock. - pub fn add_panel( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.panel - .add_panel(panel, &self.dock_area, None, window, cx); - cx.notify(); + fn tab_group_renderer(&self) -> Rc { + Rc::new(TabGroupSkin::new(self.shared().clone())) } - /// Remove item from the Dock. - pub fn remove_panel( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.panel.remove_panel(panel, window, cx); - cx.notify(); + fn tiles_renderer(&self) -> Rc { + Rc::new(TilesSkin::new(self.shared().clone())) } +} - fn render_resize_handle(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let axis = self.placement.axis(); - let view = cx.entity().clone(); - - resize_handle("resize-handle", axis) - .placement(self.placement) - .on_drag(ResizePanel {}, move |info, _, _, cx| { +impl DockSkin { + fn render_resize_handle( + &self, + dock: &DockContext, + _: &mut Window, + _: &mut App, + ) -> impl IntoElement { + let placement = dock.placement(); + let shared = self.shared().clone(); + + resize_handle("resize-handle", placement.axis()) + .when(placement.is_left(), |this| this.placement(Side::Left)) + .on_drag(ResizePanel, move |info, _, _, cx| { cx.stop_propagation(); - view.update(cx, |view, _| { - view.resizing = true; - }); + shared.resizing_dock().set(Some(placement)); cx.new(|_| info.deref().clone()) }) } - fn resize(&mut self, mouse_position: Point, _: &mut Window, cx: &mut Context) { - if !self.resizing { - return; - } - - let dock_area = self - .dock_area - .upgrade() - .expect("DockArea is missing") - .read(cx); - let area_bounds = dock_area.bounds; - let mut left_dock_size = px(0.0); - let mut right_dock_size = px(0.0); - - // Get the size of the left dock if it's open and not the current dock - if let Some(left_dock) = &dock_area.left_dock { - if left_dock.entity_id() != cx.entity().entity_id() { - let left_dock_read = left_dock.read(cx); - if left_dock_read.is_open() { - left_dock_size = left_dock_read.size; - } - } - } - - // Get the size of the right dock if it's open and not the current dock - if let Some(right_dock) = &dock_area.right_dock { - if right_dock.entity_id() != cx.entity().entity_id() { - let right_dock_read = right_dock.read(cx); - if right_dock_read.is_open() { - right_dock_size = right_dock_read.size; - } - } - } - - let size = match self.placement { - DockPlacement::Left => mouse_position.x - area_bounds.left(), - DockPlacement::Right => area_bounds.right() - mouse_position.x, - DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y, - DockPlacement::Center => unreachable!(), - }; - match self.placement { - DockPlacement::Left => { - let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size; - self.size = size.clamp(PANEL_MIN_SIZE, max_size); - } - DockPlacement::Right => { - let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size; - self.size = size.clamp(PANEL_MIN_SIZE, max_size); - } - DockPlacement::Bottom => { - let max_size = area_bounds.size.height - PANEL_MIN_SIZE; - self.size = size.clamp(PANEL_MIN_SIZE, max_size); - } - DockPlacement::Center => unreachable!(), - } - - cx.notify(); - } - - fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context) { - self.resizing = false; - } -} - -impl Render for Dock { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { - if !self.open && !self.placement.is_bottom() { - return div(); - } - - let cache_style = StyleRefinement::default().absolute().size_full(); - - div() - .relative() - .overflow_hidden() - .map(|this| match self.placement { - DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size), - DockPlacement::Bottom => this.w_full().h(self.size), - DockPlacement::Center => unreachable!(), - }) - // Bottom Dock should keep the title bar, then user can click the Toggle button - .when(!self.open && self.placement.is_bottom(), |this| { - this.h(px(29.)) - }) - .map(|this| match &self.panel { - DockItem::Split { view, .. } => this.child(view.clone()), - DockItem::Tabs { view, .. } => this.child(view.clone()), - DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)), - // Not support to render Tiles and Tile into Dock - DockItem::Tiles { .. } => this, - }) - .child(self.render_resize_handle(window, cx)) - .child(DockElement { - view: cx.entity().clone(), - }) - } } -struct DockElement { - view: Entity, +/// Turns the window's mouse stream into dock resizing. +/// +/// A resize is driven by pointer moves that land anywhere in the window, not +/// only on the handle, so it cannot be expressed as a listener on the handle +/// itself. This element paints nothing and exists for its `paint` hook, which +/// is the only place a window-level mouse listener can be registered — which +/// is why it stays in the skin rather than moving into base: it is a +/// paint-order concern of this appearance. +struct DockResizeTracker { + dock: DockContext, + shared: Rc, } -impl IntoElement for DockElement { +impl IntoElement for DockResizeTracker { type Element = Self; fn into_element(self) -> Self::Element { @@ -412,7 +174,7 @@ impl IntoElement for DockElement { } } -impl Element for DockElement { +impl Element for DockResizeTracker { type RequestLayoutState = (); type PrepaintState = (); @@ -428,7 +190,7 @@ impl Element for DockElement { &mut self, _: Option<&gpui::GlobalElementId>, _: Option<&gpui::InspectorElementId>, - window: &mut gpui::Window, + window: &mut Window, cx: &mut App, ) -> (gpui::LayoutId, Self::RequestLayoutState) { (window.request_layout(Style::default(), None, cx), ()) @@ -440,10 +202,9 @@ impl Element for DockElement { _: Option<&gpui::InspectorElementId>, _: gpui::Bounds, _: &mut Self::RequestLayoutState, - _window: &mut gpui::Window, - _cx: &mut App, + _: &mut Window, + _: &mut App, ) -> Self::PrepaintState { - () } fn paint( @@ -453,32 +214,46 @@ impl Element for DockElement { _: gpui::Bounds, _: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, - window: &mut gpui::Window, - cx: &mut App, + window: &mut Window, + _: &mut App, ) { + let placement = self.dock.placement(); + window.on_mouse_event({ - let view = self.view.clone(); - let resizing = view.read(cx).resizing; - move |e: &MouseMoveEvent, phase, window, cx| { - if !resizing { + let dock = self.dock.clone(); + let shared = self.shared.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { return; } - if !phase.bubble() { - return; + // Dragging a closed dock's handle reopens it, as the old dock + // did. The live state is read rather than the render-time + // snapshot in `dock`, which would still say closed for the + // rest of the frame and toggle it shut again on the next move. + let open = shared + .area() + .upgrade() + .is_some_and(|area| area.read(cx).is_dock_open(placement)); + if !open { + dock.toggle(window, cx); } - - view.update(cx, |view, cx| view.resize(e.position, window, cx)) + dock.resize_to(event.position, window, cx); } }); - // When any mouse up, stop dragging window.on_mouse_event({ - let view = self.view.clone(); - move |_: &MouseUpEvent, phase, window, cx| { - if phase.bubble() { - view.update(cx, |view, cx| view.done_resizing(window, cx)); + let shared = self.shared.clone(); + move |_: &MouseUpEvent, phase, _, cx| { + if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { + return; } + shared.resizing_dock().set(None); + // The size lives on the dock, not in the layout tree, so + // nothing else tells a subscriber to persist it. + _ = shared + .area() + .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); } - }) + }); } } diff --git a/vendor/gpui-component/src/dock/invalid_panel.rs b/vendor/gpui-component/src/dock/invalid_panel.rs index 63cdf4a1..732e2512 100644 --- a/vendor/gpui-component/src/dock/invalid_panel.rs +++ b/vendor/gpui-component/src/dock/invalid_panel.rs @@ -1,12 +1,18 @@ use gpui::{ - App, EventEmitter, FocusHandle, Focusable, ParentElement as _, Render, SharedString, - Styled as _, Window, + App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement as _, Render, + SharedString, Styled as _, Window, div, }; +use gpui_base::dock::{PanelEvent, PanelState}; -use crate::ActiveTheme as _; - -use super::{Panel, PanelEvent, PanelState}; +use crate::{ActiveTheme as _, dock::Panel}; +/// Stands in for a panel this build cannot construct — one whose +/// `panel_name` no [`PanelRegistry`](gpui_base::dock::PanelRegistry) builder +/// answers to. +/// +/// It reports the original [`PanelState`] from +/// [`dump`](gpui_base::dock::Panel::dump), so a layout written by a build that +/// knows the panel survives a load and a save here rather than losing it. pub(crate) struct InvalidPanel { name: SharedString, focus_handle: FocusHandle, @@ -14,36 +20,42 @@ pub(crate) struct InvalidPanel { } impl InvalidPanel { - pub(crate) fn new(name: &str, state: PanelState, _: &mut Window, cx: &mut App) -> Self { + pub(crate) fn new( + name: impl Into, + state: PanelState, + cx: &mut Context, + ) -> Self { Self { focus_handle: cx.focus_handle(), - name: SharedString::from(name.to_owned()), + name: name.into(), old_state: state, } } } -impl Panel for InvalidPanel { + +impl gpui_base::dock::Panel for InvalidPanel { fn panel_name(&self) -> &'static str { "InvalidPanel" } - fn dump(&self, _cx: &App) -> super::PanelState { + fn dump(&self, _: &App) -> PanelState { self.old_state.clone() } } + +impl Panel for InvalidPanel {} + impl EventEmitter for InvalidPanel {} + impl Focusable for InvalidPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } + impl Render for InvalidPanel { - fn render( - &mut self, - _: &mut gpui::Window, - cx: &mut gpui::Context, - ) -> impl gpui::IntoElement { - gpui::div() + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() .size_full() .my_6() .flex() diff --git a/vendor/gpui-component/src/dock/mod.rs b/vendor/gpui-component/src/dock/mod.rs index acfe60cc..b65016ae 100644 --- a/vendor/gpui-component/src/dock/mod.rs +++ b/vendor/gpui-component/src/dock/mod.rs @@ -1,1152 +1,285 @@ +//! The gpui-component appearance for the dock. +//! +//! The layout tree, the persisted schema, the drag geometry, the active-panel +//! state machine and the container entities all live in +//! [`gpui_base::dock`]. This module is the skin over them: it re-exports the +//! types a consumer needs, adds the presentation half of the panel traits +//! (see [`panel`]), and implements base's three renderer traits. +//! +//! ```ignore +//! let area = cx.new(|cx| { +//! DockArea::new("main", Some(1), window, cx).with_renderer(DockSkin::new(cx)) +//! }); +//! ``` +//! +//! A [`DockArea`] built without [`DockSkin`] still docks, drags and persists — +//! it simply draws no chrome at all. + mod dock; mod invalid_panel; mod panel; -mod stack_panel; -mod state; mod tab_panel; +#[cfg(test)] +mod test_support; mod tiles; -use anyhow::Result; -use gpui::{ - AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId, - EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, - SharedString, Styled, Subscription, WeakEntity, Window, actions, canvas, div, - prelude::FluentBuilder, +use std::{cell::Cell, rc::Rc}; + +use gpui::{App, AppContext as _, Context, Entity, SharedString, WeakEntity, Window, actions}; + +use crate::scroll::ScrollbarMode; + +/// The behavior half of the panel traits, which every panel implements +/// alongside [`Panel`]. Exported under this name because `Panel` in this +/// module is the presentation half that extends it. +pub use gpui_base::dock::Panel as BasePanel; +/// The object-safe counterpart of [`BasePanel`], for the same reason. +pub use gpui_base::dock::PanelView as BasePanelView; +/// Everything [`gpui_base::dock`] exports, so a consumer never has to depend +/// on the foundation crate directly to write a skin or read a container's +/// state. Kept in step with base's own list by +/// `every_base_dock_export_is_reachable_from_here`. +/// +/// Two names are handled elsewhere and one is deliberately absent: +/// base's `Panel` and `PanelView` arrive as [`BasePanel`] and [`BasePanelView`] +/// because this module's `Panel`/`PanelView` are the presentation halves that +/// extend them, and base's `Dock` — a plain state struct holding one dock's +/// open, collapsible, size and resizing flags — is not re-exported at all, +/// because the name meant a panel container in every released version of this +/// crate and handing it back with a different meaning is worse than dropping +/// it. A skin reads a dock through [`DockContext`]. +pub use gpui_base::dock::{ + AnyDrag, DRAG_BAR_HEIGHT, DockArea, DockAreaRenderer, DockAreaState, DockContext, DockEvent, + DockLayout, DockPlacement, DockSizing, DockState, DragPanel, DropIndicator, + DropPlaceholderBounds, DropTarget, EditResult, HANDLE_SIZE, InsertTarget, NodeId, PaneNode, + PaneRef, PaneTree, PanelBuildContext, PanelBuilder, PanelEvent, PanelId, PanelInfo, + PanelRegistry, PanelSource, PanelState, ResizeSide, RootKind, TabGroup, TabGroupConstraints, + TabGroupContext, TabGroupEvent, TabGroupRenderer, TileContext, TileMeta, TilePanel, TilesEvent, + TilesRenderer, TilesState, register_panel, }; -use std::sync::Arc; - -pub use dock::*; pub use panel::*; -pub use stack_panel::*; -pub use state::*; -pub use tab_panel::*; -pub use tiles::*; - -pub(crate) fn init(cx: &mut App) { - PanelRegistry::init(cx); -} +pub use tab_panel::DragPanelPreview; actions!(dock, [ToggleZoom, ClosePanel]); -pub enum DockEvent { - /// The layout of the dock has changed, subscribers this to save the layout. - /// - /// This event is emitted when every time the layout of the dock has changed, - /// So it emits may be too frequently, you may want to debounce the event. - LayoutChanged, - - /// The drag item drop event. - DragDrop(AnyDrag), -} - -/// The main area of the dock. -pub struct DockArea { - id: SharedString, - /// The version is used to special the default layout, this is like the `panel_version` in [`Panel`](Panel). - version: Option, - pub(crate) bounds: Bounds, - - /// The center view of the dockarea. - items: DockItem, - - /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, - toggle_button_panels: Edges>, - - /// Whether to show the toggle button. - toggle_button_visible: bool, - /// The left dock of the dock_area. - left_dock: Option>, - /// The bottom dock of the dock_area. - bottom_dock: Option>, - /// The right dock of the dock_area. - right_dock: Option>, - /// The top zoom view of the dock_area, if any. - zoom_view: Option, - - /// Lock panels layout, but allow to resize. - locked: bool, - - /// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default). - pub(crate) panel_style: PanelStyle, - - _subscriptions: Vec, -} - -/// DockItem is a tree structure that represents the layout of the dock. -#[derive(Clone)] -pub enum DockItem { - /// Split layout - Split { - axis: Axis, - /// Self size, only used for build split panels - size: Option, - items: Vec, - /// Items sizes - sizes: Vec>, - view: Entity, - }, - /// Tab layout - Tabs { - /// Self size, only used for build split panels - size: Option, - items: Vec>, - active_ix: usize, - view: Entity, - }, - /// Panel layout - Panel { - /// Self size, only used for build split panels - size: Option, - view: Arc, - }, - /// Tiles layout - Tiles { - /// Self size, only used for build split panels - size: Option, - items: Vec, - view: Entity, - }, -} - -impl std::fmt::Debug for DockItem { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DockItem::Split { - axis, items, sizes, .. - } => f - .debug_struct("Split") - .field("axis", axis) - .field("items", &items.len()) - .field("sizes", sizes) - .finish(), - DockItem::Tabs { - items, active_ix, .. - } => f - .debug_struct("Tabs") - .field("items", &items.len()) - .field("active_ix", active_ix) - .finish(), - DockItem::Panel { .. } => f.debug_struct("Panel").finish(), - DockItem::Tiles { .. } => f.debug_struct("Tiles").finish(), - } +pub(crate) fn init(cx: &mut App) { + // `gpui_base::dock::PanelRegistry::init` is crate-private, but the global + // it installs is not: `DockArea::new` and `register_panel` both create it + // on demand, and this keeps the old guarantee that it exists as soon as + // `gpui_component::init` has run. + if cx.try_global::().is_none() { + cx.set_global(PanelRegistry::new()); } } -impl DockItem { - /// Get the size of the DockItem. - fn get_size(&self) -> Option { - match self { - Self::Split { size, .. } => *size, - Self::Tabs { size, .. } => *size, - Self::Panel { size, .. } => *size, - Self::Tiles { size, .. } => *size, - } - } - - /// Set size for the DockItem. - pub fn size(mut self, new_size: impl Into) -> Self { - let new_size: Option = Some(new_size.into()); - match self { - Self::Split { ref mut size, .. } => *size = new_size, - Self::Tabs { ref mut size, .. } => *size = new_size, - Self::Tiles { ref mut size, .. } => *size = new_size, - Self::Panel { ref mut size, .. } => *size = new_size, - } - self - } - - /// Set active index for the DockItem, only valid for [`DockItem::Tabs`]. - pub fn active_index(mut self, new_active_ix: usize) -> Self { - debug_assert!( - matches!(self, Self::Tabs { .. }), - "active_ix can only be set for DockItem::Tabs" - ); +/// What every part of the skin reads, and the dock area it belongs to. +/// +/// The renderer is the only skin-owned object in the picture, so the settings +/// the old `DockArea` carried — the panel style, whether dock collapse +/// affordances are offered at all — live here. It is shared by reference with +/// the per-container renderers, which are built once each and outlive any one +/// frame. +pub(crate) struct SkinShared { + area: WeakEntity, + panel_style: Cell, + toggle_button_visible: Cell, + tiles_scrollbar_mode: Cell>, + /// The dock whose resize handle is being dragged, if any. Only one can be. + resizing_dock: Cell>, +} - if let Self::Tabs { - ref mut active_ix, .. - } = self - { - *active_ix = new_active_ix; - } - self +impl SkinShared { + pub(crate) fn area(&self) -> &WeakEntity { + &self.area } - /// Create DockItem::Split with given split layout. - pub fn split( - axis: Axis, - items: Vec, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - let sizes = items.iter().map(|item| item.get_size()).collect(); - Self::split_with_sizes(axis, items, sizes, dock_area, window, cx) + pub(crate) fn panel_style(&self) -> PanelStyle { + self.panel_style.get() } - /// Create DockItem with vertical split layout. - pub fn v_split( - items: Vec, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - Self::split(Axis::Vertical, items, dock_area, window, cx) + pub(crate) fn is_toggle_button_visible(&self) -> bool { + self.toggle_button_visible.get() } - /// Create DockItem with horizontal split layout. - pub fn h_split( - items: Vec, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - Self::split(Axis::Horizontal, items, dock_area, window, cx) + pub(crate) fn tiles_scrollbar_mode(&self) -> Option { + self.tiles_scrollbar_mode.get() } - /// Create DockItem with split layout, each item of panel have specified size. - /// - /// Please note that the `items` and `sizes` must have the same length. - /// Set `None` in `sizes` to make the index of panel have auto size. - pub fn split_with_sizes( - axis: Axis, - items: Vec, - sizes: Vec>, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - let mut items = items; - let stack_panel = cx.new(|cx| { - let mut stack_panel = StackPanel::new(axis, window, cx); - for (i, item) in items.iter_mut().enumerate() { - let view = item.view(); - let size = sizes.get(i).copied().flatten(); - stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx) - } - - for (i, item) in items.iter().enumerate() { - let view = item.view(); - let size = sizes.get(i).copied().flatten(); - stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx) - } - - stack_panel - }); - - window.defer(cx, { - let stack_panel = stack_panel.clone(); - let dock_area = dock_area.clone(); - move |window, cx| { - _ = dock_area.update(cx, |this, cx| { - this.subscribe_panel(&stack_panel, window, cx); - }); - } - }); - - Self::Split { - axis, - size: None, - items, - sizes, - view: stack_panel, - } + pub(crate) fn resizing_dock(&self) -> &Cell> { + &self.resizing_dock } - /// Create DockItem with panel layout - pub fn panel(panel: Arc) -> Self { - Self::Panel { - size: None, - view: panel, - } + /// Redraw the area after a setting changed. The skin is not an entity, so + /// nothing else would notice. + fn notify(&self, cx: &mut App) { + _ = self.area.update(cx, |_, cx| cx.notify()); } +} - /// Create DockItem with tiles layout - /// - /// This items and metas should have the same length. - pub fn tiles( - items: Vec, - metas: Vec + Copy>, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - assert!(items.len() == metas.len()); - - let tile_panel = cx.new(|cx| { - let mut tiles = Tiles::new(window, cx); - for (ix, item) in items.clone().into_iter().enumerate() { - match item { - DockItem::Tabs { view, .. } => { - let meta: TileMeta = metas[ix].into(); - let tile_item = - TileItem::new(Arc::new(view), meta.bounds).z_index(meta.z_index); - tiles.add_item(tile_item, dock_area, window, cx); - } - DockItem::Panel { view, .. } => { - let meta: TileMeta = metas[ix].into(); - let tile_item = - TileItem::new(view.clone(), meta.bounds).z_index(meta.z_index); - tiles.add_item(tile_item, dock_area, window, cx); - } - _ => { - // Ignore non-tabs items - } - } - } - tiles - }); - - window.defer(cx, { - let tile_panel = tile_panel.clone(); - let dock_area = dock_area.clone(); - move |window, cx| { - _ = dock_area.update(cx, |this, cx| { - this.subscribe_panel(&tile_panel, window, cx); - this.subscribe_tiles_item_drop(&tile_panel, window, cx); - }); - } - }); - - Self::Tiles { - size: None, - items: tile_panel.read(cx).panels.clone(), - view: tile_panel, - } - } +/// The gpui-component appearance for a [`DockArea`], and the handle its +/// settings are changed through. +/// +/// Install it at construction, where the area's own weak handle is available: +/// +/// ```ignore +/// let skin = DockSkin::new(cx); +/// DockArea::new("main", None, window, cx).with_renderer(skin) +/// ``` +/// +/// Keep the returned handle to change a setting later; it is an `Rc`, so a +/// clone and the installed renderer are the same skin. +pub struct DockSkin { + shared: Rc, +} - /// Create DockItem with tabs layout, items are displayed as tabs. +impl DockSkin { + /// Build a [`DockArea`] wearing this appearance, together with the handle + /// its settings are changed through. /// - /// The `active_ix` is the index of the active tab, if `None` the first tab is active. - pub fn tabs( - items: Vec>, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - let mut new_items: Vec> = vec![]; - for item in items.into_iter() { - new_items.push(item) - } - Self::new_tabs(new_items, None, dock_area, window, cx) - } - - pub fn tab( - item: Entity

, - dock_area: &WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Self { - Self::new_tabs(vec![Arc::new(item.clone())], None, dock_area, window, cx) - } - - fn new_tabs( - items: Vec>, - active_ix: Option, - dock_area: &WeakEntity, + /// The skin needs the area's own weak handle, so it can only be built + /// while the area is being constructed; this is that dance done once. + pub fn dock_area( + id: impl Into, + version: Option, window: &mut Window, cx: &mut App, - ) -> Self { - let active_ix = active_ix.unwrap_or(0); - let tab_panel = cx.new(|cx| { - let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx); - for item in items.iter() { - tab_panel.add_panel(item.clone(), window, cx) - } - tab_panel.active_ix = active_ix; - tab_panel + ) -> (Entity, Rc) { + let mut skin = None; + let area = cx.new(|cx| { + let this = Self::new(cx); + skin = Some(this.clone()); + DockArea::new(id, version, window, cx).with_renderer(this) }); - - Self::Tabs { - size: None, - items, - active_ix, - view: tab_panel, - } - } - - /// Returns the views of the dock item. - pub fn view(&self) -> Arc { - match self { - Self::Split { view, .. } => Arc::new(view.clone()), - Self::Tabs { view, .. } => Arc::new(view.clone()), - Self::Tiles { view, .. } => Arc::new(view.clone()), - Self::Panel { view, .. } => view.clone(), - } - } - - /// Find existing panel in the dock item. - pub fn find_panel(&self, panel: Arc) -> Option> { - match self { - Self::Split { items, .. } => { - items.iter().find_map(|item| item.find_panel(panel.clone())) - } - Self::Tabs { items, .. } => items.iter().find(|item| *item == &panel).cloned(), - Self::Panel { view, .. } => Some(view.clone()), - Self::Tiles { items, .. } => items.iter().find_map(|item| { - if &item.panel == &panel { - Some(item.panel.clone()) - } else { - None - } + // The closure above runs before `cx.new` returns. + ( + area, + skin.expect("DockSkin::new ran inside the constructor"), + ) + } + + pub fn new(cx: &mut Context) -> Rc { + Rc::new(Self { + shared: Rc::new(SkinShared { + area: cx.weak_entity(), + panel_style: Cell::new(PanelStyle::default()), + toggle_button_visible: Cell::new(true), + tiles_scrollbar_mode: Cell::new(None), + resizing_dock: Cell::new(None), }), - } - } - - /// Add a panel to the dock item. - pub fn add_panel( - &mut self, - panel: Arc, - dock_area: &WeakEntity, - bounds: Option>, - window: &mut Window, - cx: &mut App, - ) { - match self { - Self::Tabs { view, items, .. } => { - items.push(panel.clone()); - view.update(cx, |tab_panel, cx| { - tab_panel.add_panel(panel, window, cx); - }); - } - Self::Split { view, items, .. } => { - // Iter items to add panel to the first tabs - for item in items.into_iter() { - if let DockItem::Tabs { view, .. } = item { - view.update(cx, |tab_panel, cx| { - tab_panel.add_panel(panel.clone(), window, cx); - }); - return; - } - } - - // Unable to find tabs, create new tabs - let new_item = Self::tabs(vec![panel.clone()], dock_area, window, cx); - items.push(new_item.clone()); - view.update(cx, |stack_panel, cx| { - stack_panel.add_panel(new_item.view(), None, dock_area.clone(), window, cx); - }); - } - Self::Tiles { view, items, .. } => { - let tile_item = TileItem::new( - Arc::new(cx.new(|cx| { - let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx); - tab_panel.add_panel(panel.clone(), window, cx); - tab_panel - })), - bounds.unwrap_or_else(|| TileMeta::default().bounds), - ); - - items.push(tile_item.clone()); - view.update(cx, |tiles, cx| { - tiles.add_item(tile_item, dock_area, window, cx); - }); - } - Self::Panel { .. } => {} - } - } - - /// Remove a panel from the dock item. - pub fn remove_panel(&self, panel: Arc, window: &mut Window, cx: &mut App) { - match self { - DockItem::Tabs { view, .. } => { - view.update(cx, |tab_panel, cx| { - tab_panel.remove_panel(panel, window, cx); - }); - } - DockItem::Split { items, view, .. } => { - // For each child item, set collapsed state - for item in items { - item.remove_panel(panel.clone(), window, cx); - } - view.update(cx, |split, cx| { - split.remove_panel(panel, window, cx); - }); - } - DockItem::Tiles { view, .. } => { - view.update(cx, |tiles, cx| { - tiles.remove(panel, window, cx); - }); - } - DockItem::Panel { .. } => {} - } + }) } - pub fn set_collapsed(&self, collapsed: bool, window: &mut Window, cx: &mut App) { - match self { - DockItem::Tabs { view, .. } => { - view.update(cx, |tab_panel, cx| { - tab_panel.set_collapsed(collapsed, window, cx); - }); - } - DockItem::Split { items, .. } => { - // For each child item, set collapsed state - for item in items { - item.set_collapsed(collapsed, window, cx); - } - } - DockItem::Tiles { .. } => {} - DockItem::Panel { view, .. } => view.set_active(!collapsed, window, cx), - } + pub(crate) fn shared(&self) -> &Rc { + &self.shared } - /// Recursively traverses to find the left-most and top-most TabPanel. - pub(crate) fn left_top_tab_panel(&self, cx: &App) -> Option> { - match self { - DockItem::Tabs { view, .. } => Some(view.clone()), - DockItem::Split { view, .. } => view.read(cx).left_top_tab_panel(true, cx), - DockItem::Tiles { .. } => None, - DockItem::Panel { .. } => None, - } + /// Whether a single-panel tab group draws a plain title or a full tab bar. + pub fn panel_style(&self) -> PanelStyle { + self.shared.panel_style() } - /// Recursively traverses to find the right-most and top-most TabPanel. - pub(crate) fn right_top_tab_panel(&self, cx: &App) -> Option> { - match self { - DockItem::Tabs { view, .. } => Some(view.clone()), - DockItem::Split { view, .. } => view.read(cx).right_top_tab_panel(true, cx), - DockItem::Tiles { .. } => None, - DockItem::Panel { .. } => None, - } + pub fn set_panel_style(&self, style: PanelStyle, cx: &mut App) { + self.shared.panel_style.set(style); + self.shared.notify(cx); } -} - -impl DockArea { - pub fn new( - id: impl Into, - version: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let stack_panel = cx.new(|cx| StackPanel::new(Axis::Horizontal, window, cx)); - - let dock_item = DockItem::Split { - axis: Axis::Horizontal, - size: None, - items: vec![], - sizes: vec![], - view: stack_panel.clone(), - }; - - let mut this = Self { - id: id.into(), - version, - bounds: Bounds::default(), - items: dock_item, - zoom_view: None, - toggle_button_panels: Edges::default(), - toggle_button_visible: true, - left_dock: None, - right_dock: None, - bottom_dock: None, - locked: false, - panel_style: PanelStyle::default(), - _subscriptions: vec![], - }; - - this.subscribe_panel(&stack_panel, window, cx); - this + /// Whether tab bars offer the affordance that collapses a neighbouring + /// dock. + pub fn is_toggle_button_visible(&self) -> bool { + self.shared.is_toggle_button_visible() } - /// Return the bounds of the dock area. - pub fn bounds(&self) -> Bounds { - self.bounds + pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) { + self.shared.toggle_button_visible.set(visible); + self.shared.notify(cx); } - /// Return the items of the dock area. - pub fn items(&self) -> &DockItem { - &self.items + /// When a tiles canvas shows its scrollbar. `None` follows the theme. + pub fn tiles_scrollbar_mode(&self) -> Option { + self.shared.tiles_scrollbar_mode() } - /// Subscribe to the tiles item drag item drop event - fn subscribe_tiles_item_drop( - &mut self, - tile_panel: &Entity, - _: &mut Window, - cx: &mut Context, - ) { - self._subscriptions - .push(cx.subscribe(tile_panel, move |_, _, evt: &DragDrop, cx| { - let item = evt.0.clone(); - cx.emit(DockEvent::DragDrop(item)); - })); - } - - /// Set the panel style of the dock area. - pub fn panel_style(mut self, style: PanelStyle) -> Self { - self.panel_style = style; - self - } - - /// Set version of the dock area. - pub fn set_version(&mut self, version: usize, _: &mut Window, cx: &mut Context) { - self.version = Some(version); - cx.notify(); - } - - // FIXME: Remove this method after 2025-01-01 - #[deprecated(note = "Use `set_center` instead")] - pub fn set_root(&mut self, item: DockItem, window: &mut Window, cx: &mut Context) { - self.set_center(item, window, cx); - } - - /// The the DockItem as the center of the dock area. - /// - /// This is used to render at the Center of the DockArea. - pub fn set_center(&mut self, item: DockItem, window: &mut Window, cx: &mut Context) { - self.subscribe_item(&item, window, cx); - self.items = item; - self.update_toggle_button_tab_panels(window, cx); - cx.notify(); - } - - pub fn set_left_dock( - &mut self, - panel: DockItem, - size: Option, - open: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.subscribe_item(&panel, window, cx); - let weak_self = cx.entity().downgrade(); - self.left_dock = Some(cx.new(|cx| { - let mut dock = Dock::left(weak_self.clone(), window, cx); - if let Some(size) = size { - dock.set_size(size, window, cx); - } - dock.set_panel(panel, window, cx); - dock.set_open(open, window, cx); - dock - })); - self.update_toggle_button_tab_panels(window, cx); - } - - pub fn set_bottom_dock( - &mut self, - panel: DockItem, - size: Option, - open: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.subscribe_item(&panel, window, cx); - let weak_self = cx.entity().downgrade(); - self.bottom_dock = Some(cx.new(|cx| { - let mut dock = Dock::bottom(weak_self.clone(), window, cx); - if let Some(size) = size { - dock.set_size(size, window, cx); - } - dock.set_panel(panel, window, cx); - dock.set_open(open, window, cx); - dock - })); - self.update_toggle_button_tab_panels(window, cx); - } - - pub fn set_right_dock( - &mut self, - panel: DockItem, - size: Option, - open: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.subscribe_item(&panel, window, cx); - let weak_self = cx.entity().downgrade(); - self.right_dock = Some(cx.new(|cx| { - let mut dock = Dock::right(weak_self.clone(), window, cx); - if let Some(size) = size { - dock.set_size(size, window, cx); - } - dock.set_panel(panel, window, cx); - dock.set_open(open, window, cx); - dock - })); - self.update_toggle_button_tab_panels(window, cx); - } - - /// Set locked state of the dock area, if locked, the dock area cannot be split or move, but allows to resize panels. - pub fn set_locked(&mut self, locked: bool, _window: &mut Window, _cx: &mut App) { - self.locked = locked; - } - - /// Determine if the dock area is locked. - #[inline] - pub fn is_locked(&self) -> bool { - self.locked - } - - /// Determine if the dock area has a dock at the given placement. - pub fn has_dock(&self, placement: DockPlacement) -> bool { - match placement { - DockPlacement::Left => self.left_dock.is_some(), - DockPlacement::Bottom => self.bottom_dock.is_some(), - DockPlacement::Right => self.right_dock.is_some(), - DockPlacement::Center => false, - } - } - - /// Determine if the dock at the given placement is open. - pub fn is_dock_open(&self, placement: DockPlacement, cx: &App) -> bool { - match placement { - DockPlacement::Left => self - .left_dock - .as_ref() - .map(|dock| dock.read(cx).is_open()) - .unwrap_or(false), - DockPlacement::Bottom => self - .bottom_dock - .as_ref() - .map(|dock| dock.read(cx).is_open()) - .unwrap_or(false), - DockPlacement::Right => self - .right_dock - .as_ref() - .map(|dock| dock.read(cx).is_open()) - .unwrap_or(false), - DockPlacement::Center => false, - } - } - - /// Set the dock at the given placement to be open or closed. - /// - /// Only the left, bottom, right dock can be toggled. - pub fn set_dock_collapsible( - &mut self, - collapsible_edges: Edges, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(left_dock) = self.left_dock.as_ref() { - left_dock.update(cx, |dock, cx| { - dock.set_collapsible(collapsible_edges.left, window, cx); - }); - } - - if let Some(bottom_dock) = self.bottom_dock.as_ref() { - bottom_dock.update(cx, |dock, cx| { - dock.set_collapsible(collapsible_edges.bottom, window, cx); - }); - } - - if let Some(right_dock) = self.right_dock.as_ref() { - right_dock.update(cx, |dock, cx| { - dock.set_collapsible(collapsible_edges.right, window, cx); - }); - } - } - - /// Determine if the dock at the given placement is collapsible. - pub fn is_dock_collapsible(&self, placement: DockPlacement, cx: &App) -> bool { - match placement { - DockPlacement::Left => self - .left_dock - .as_ref() - .map(|dock| dock.read(cx).collapsible) - .unwrap_or(false), - DockPlacement::Bottom => self - .bottom_dock - .as_ref() - .map(|dock| dock.read(cx).collapsible) - .unwrap_or(false), - DockPlacement::Right => self - .right_dock - .as_ref() - .map(|dock| dock.read(cx).collapsible) - .unwrap_or(false), - DockPlacement::Center => false, - } - } - - /// Toggle the dock at the given placement. - pub fn toggle_dock( - &self, - placement: DockPlacement, - window: &mut Window, - cx: &mut Context, - ) { - let dock = match placement { - DockPlacement::Left => &self.left_dock, - DockPlacement::Bottom => &self.bottom_dock, - DockPlacement::Right => &self.right_dock, - DockPlacement::Center => return, - }; - - if let Some(dock) = dock { - dock.update(cx, |view, cx| { - view.toggle_open(window, cx); - }) - } - } - - /// Set the visibility of the toggle button. - pub fn set_toggle_button_visible(&mut self, visible: bool, _: &mut Context) { - self.toggle_button_visible = visible; - } - - /// Add a panel item to the dock area at the given placement. - pub fn add_panel( - &mut self, - panel: Arc, - placement: DockPlacement, - bounds: Option>, - window: &mut Window, - cx: &mut Context, - ) { - let weak_self = cx.entity().downgrade(); - match placement { - DockPlacement::Left => { - if let Some(dock) = self.left_dock.as_ref() { - dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) - } else { - self.set_left_dock( - DockItem::tabs(vec![panel], &weak_self, window, cx), - None, - true, - window, - cx, - ); - } - } - DockPlacement::Bottom => { - if let Some(dock) = self.bottom_dock.as_ref() { - dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) - } else { - self.set_bottom_dock( - DockItem::tabs(vec![panel], &weak_self, window, cx), - None, - true, - window, - cx, - ); - } - } - DockPlacement::Right => { - if let Some(dock) = self.right_dock.as_ref() { - dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) - } else { - self.set_right_dock( - DockItem::tabs(vec![panel], &weak_self, window, cx), - None, - true, - window, - cx, - ); - } - } - DockPlacement::Center => { - self.items - .add_panel(panel, &cx.entity().downgrade(), bounds, window, cx); - } - } - } - - /// Remove panel from the DockArea at the given placement. - pub fn remove_panel( - &mut self, - panel: Arc, - placement: DockPlacement, - window: &mut Window, - cx: &mut Context, - ) { - match placement { - DockPlacement::Left => { - if let Some(dock) = self.left_dock.as_mut() { - dock.update(cx, |dock, cx| { - dock.remove_panel(panel, window, cx); - }); - } - } - DockPlacement::Right => { - if let Some(dock) = self.right_dock.as_mut() { - dock.update(cx, |dock, cx| { - dock.remove_panel(panel, window, cx); - }); - } - } - DockPlacement::Bottom => { - if let Some(dock) = self.bottom_dock.as_mut() { - dock.update(cx, |dock, cx| { - dock.remove_panel(panel, window, cx); - }); - } - } - DockPlacement::Center => { - self.items.remove_panel(panel, window, cx); - } - } - cx.notify(); - } - - /// Remove a panel from all docks. - pub fn remove_panel_from_all_docks( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.remove_panel(panel.clone(), DockPlacement::Center, window, cx); - self.remove_panel(panel.clone(), DockPlacement::Left, window, cx); - self.remove_panel(panel.clone(), DockPlacement::Right, window, cx); - self.remove_panel(panel.clone(), DockPlacement::Bottom, window, cx); + pub fn set_tiles_scrollbar_mode(&self, mode: Option, cx: &mut App) { + self.shared.tiles_scrollbar_mode.set(mode); + self.shared.notify(cx); } +} - /// Load the state of the DockArea from the DockAreaState. +#[cfg(test)] +mod tests { + /// Every name `gpui_base::dock` exports has to be reachable from + /// `gpui_component::dock`, or an application cannot write its own skin + /// without depending on the foundation crate directly. /// - /// See also [DockeArea::dump]. - pub fn load( - &mut self, - state: DockAreaState, - window: &mut Window, - cx: &mut Context, - ) -> Result<()> { - self.version = state.version; - let weak_self = cx.entity().downgrade(); - - if let Some(left_dock_state) = state.left_dock { - self.left_dock = Some(left_dock_state.to_dock(weak_self.clone(), window, cx)); - } - - if let Some(right_dock_state) = state.right_dock { - self.right_dock = Some(right_dock_state.to_dock(weak_self.clone(), window, cx)); - } - - if let Some(bottom_dock_state) = state.bottom_dock { - self.bottom_dock = Some(bottom_dock_state.to_dock(weak_self.clone(), window, cx)); - } - - self.items = state.center.to_item(weak_self, window, cx); - self.update_toggle_button_tab_panels(window, cx); - Ok(()) - } - - /// Dump the dock panels layout to PanelState. + /// This reads both export lists rather than naming them, because the way + /// this went wrong was checking the list against a description of base + /// instead of against base itself: a hand-written list cannot notice a + /// name base gained after it was written. `TilesState` and `TilesEvent` + /// were missing when this was added. /// - /// See also [DockArea::load]. - pub fn dump(&self, cx: &App) -> DockAreaState { - let root = self.items.view(); - let center = root.dump(cx); - - let left_dock = self - .left_dock - .as_ref() - .map(|dock| DockState::new(dock.clone(), cx)); - let right_dock = self - .right_dock - .as_ref() - .map(|dock| DockState::new(dock.clone(), cx)); - let bottom_dock = self - .bottom_dock - .as_ref() - .map(|dock| DockState::new(dock.clone(), cx)); - - DockAreaState { - version: self.version, - center, - left_dock, - right_dock, - bottom_dock, - } - } - - /// Subscribe event on the panels - #[allow(clippy::only_used_in_recursion)] - fn subscribe_item(&mut self, item: &DockItem, window: &mut Window, cx: &mut Context) { - match item { - DockItem::Split { items, view, .. } => { - for item in items { - self.subscribe_item(item, window, cx); - } - - self._subscriptions.push(cx.subscribe_in( - view, - window, - move |_, _, event, window, cx| match event { - PanelEvent::LayoutChanged => { - cx.spawn_in(window, async move |view, window| { - _ = view.update_in(window, |view, window, cx| { - view.update_toggle_button_tab_panels(window, cx) - }); - }) - .detach(); - cx.emit(DockEvent::LayoutChanged); - } - _ => {} - }, - )); - } - DockItem::Tabs { .. } => { - // We subscribe to the tab panel event in StackPanel's insert_panel - } - DockItem::Tiles { .. } => { - // We subscribe to the tab panel event in Tiles's [`add_item`](Tiles::add_item) - } - DockItem::Panel { .. } => { - // Not supported - } - } - } - - /// Subscribe zoom event on the panel - pub(crate) fn subscribe_panel( - &mut self, - view: &Entity

, - window: &mut Window, - cx: &mut Context, - ) { - let subscription = - cx.subscribe_in( - view, - window, - move |_, panel, event, window, cx| match event { - PanelEvent::ZoomIn => { - let panel = panel.clone(); - cx.spawn_in(window, async move |view, window| { - _ = view.update_in(window, |view, window, cx| { - view.set_zoomed_in(panel, window, cx); - cx.notify(); - }); - }) - .detach(); - } - PanelEvent::ZoomOut => cx - .spawn_in(window, async move |view, window| { - _ = view.update_in(window, |view, window, cx| { - view.set_zoomed_out(window, cx); - }); - }) - .detach(), - PanelEvent::LayoutChanged => { - cx.spawn_in(window, async move |view, window| { - _ = view.update_in(window, |view, window, cx| { - view.update_toggle_button_tab_panels(window, cx) - }); - }) - .detach(); - cx.emit(DockEvent::LayoutChanged); - } - }, + /// The parse is deliberately crude — it takes the braces of each + /// `pub use ...::{..}` and the tail of each single-name `pub use a::b;` — + /// so a reformat of either file could trip it. That failure says "look at + /// the two lists", which is the right thing to do anyway. + fn exported_names(source: &str, prefix: &str) -> Vec { + let mut names = Vec::new(); + let mut rest = source; + while let Some(at) = rest.find(prefix) { + rest = &rest[at + prefix.len()..]; + let Some(end) = rest.find(';') else { break }; + let (item, tail) = rest.split_at(end); + rest = tail; + let item = item.trim(); + let list = match (item.find('{'), item.rfind('}')) { + (Some(open), Some(close)) if open < close => &item[open + 1..close], + // `pub use a::b;` — the name is the last path segment. + _ => item.rsplit("::").next().unwrap_or(""), + }; + names.extend( + list.split(',') + .map(|name| name.split(" as ").next().unwrap_or("").trim().to_string()) + .filter(|name| !name.is_empty()), ); - - self._subscriptions.push(subscription); - } - - /// Returns the ID of the dock area. - pub fn id(&self) -> SharedString { - self.id.clone() - } - - pub fn set_zoomed_in( - &mut self, - panel: Entity

, - _: &mut Window, - cx: &mut Context, - ) { - self.zoom_view = Some(panel.into()); - cx.notify(); - } - - pub fn set_zoomed_out(&mut self, _: &mut Window, cx: &mut Context) { - self.zoom_view = None; - cx.notify(); - } - - fn render_items(&self, _window: &mut Window, _cx: &mut Context) -> AnyElement { - match &self.items { - DockItem::Split { view, .. } => view.clone().into_any_element(), - DockItem::Tabs { view, .. } => view.clone().into_any_element(), - DockItem::Tiles { view, .. } => view.clone().into_any_element(), - DockItem::Panel { view, .. } => view.clone().view().into_any_element(), } - } - - pub fn update_toggle_button_tab_panels(&mut self, _: &mut Window, cx: &mut Context) { - // Left toggle button - self.toggle_button_panels.left = self - .items - .left_top_tab_panel(cx) - .map(|view| view.entity_id()); - - // Right toggle button - self.toggle_button_panels.right = self - .items - .right_top_tab_panel(cx) - .map(|view| view.entity_id()); - - // Bottom toggle button - self.toggle_button_panels.bottom = self - .bottom_dock - .as_ref() - .and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx)) - .map(|view| view.entity_id()); - } -} -impl EventEmitter for DockArea {} -impl Render for DockArea { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let view = cx.entity().clone(); + names.sort(); + names.dedup(); + names + } + + #[test] + fn every_base_dock_export_is_reachable_from_here() { + let base = include_str!("../../../base/src/dock/mod.rs"); + let skin = include_str!("mod.rs"); + + let exported = exported_names(base, "pub use "); + assert!( + exported.len() > 30, + "the parse found only {} names in base's dock module, so it is \ + reading the wrong thing rather than reporting the truth", + exported.len() + ); - div() - .id("dock-area") - .relative() - .size_full() - .overflow_hidden() - .child( - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) - .map(|this| { - if let Some(zoom_view) = self.zoom_view.clone() { - this.child(zoom_view) - } else { - match &self.items { - DockItem::Tiles { view, .. } => { - // render tiles - this.child(view.clone()) - } - _ => { - // render dock - this.child( - div() - .flex() - .flex_row() - .h_full() - // Left dock - .when_some(self.left_dock.clone(), |this, dock| { - this.child(div().flex().flex_none().child(dock)) - }) - // Center - .child( - div() - .flex() - .flex_1() - .flex_col() - .overflow_hidden() - // Top center - .child( - div() - .flex_1() - .overflow_hidden() - .child(self.render_items(window, cx)), - ) - // Bottom Dock - .when_some(self.bottom_dock.clone(), |this, dock| { - this.child(dock) - }), - ) - // Right Dock - .when_some(self.right_dock.clone(), |this, dock| { - this.child(div().flex().flex_none().child(dock)) - }), - ) - } - } - } + let reachable = exported_names(skin, "pub use gpui_base::dock::"); + // `Panel` and `PanelView` are re-exported under other names because + // this module's own `Panel`/`PanelView` extend them; `Dock` is a + // documented omission. See the doc on the re-export block. + let renamed = ["Panel", "PanelView"]; + let omitted = ["Dock"]; + + let missing: Vec<&String> = exported + .iter() + .filter(|name| { + !reachable.contains(name) + && !renamed.contains(&name.as_str()) + && !omitted.contains(&name.as_str()) }) + .collect(); + + assert!( + missing.is_empty(), + "gpui_base::dock exports these, and gpui_component::dock does not \ + re-export them: {missing:?}. Add them to the list, or add the \ + name to `omitted` with the reason on the re-export block." + ); } } diff --git a/vendor/gpui-component/src/dock/panel.rs b/vendor/gpui-component/src/dock/panel.rs index ea65c590..dbd562ba 100644 --- a/vendor/gpui-component/src/dock/panel.rs +++ b/vendor/gpui-component/src/dock/panel.rs @@ -1,18 +1,31 @@ -use crate::{button::Button, dock::TabPanel, menu::PopupMenu}; +//! The presentation half of a dockable panel, and the concrete handle that +//! carries it back across the renderer seam. +//! +//! `gpui-base` owns panel *behavior*: [`gpui_base::dock::Panel`] and its +//! object-safe mirror answer for the name, the id, closability, visibility, +//! and the activation callbacks. Everything a tab bar actually draws — the +//! title, the tab name, the toolbar, the ellipsis menu — is presentation and +//! lives here. +//! +//! A sub-trait alone cannot join the two. Base hands a skin +//! `Arc`; `Arc` coerces *to* +//! that, and Rust has no coercion back, so a renderer holding base's handle +//! can never reach a presentation method through it. What does work is a +//! *concrete* type: [`PanelHandle`] wraps `Arc` and implements +//! base's trait by delegation, so base holds a `PanelHandle` and the skin +//! recovers it with [`PanelHandle::of`], which downcasts to a single known +//! type. + +use std::{any::Any, sync::Arc}; + use gpui::{ - AnyElement, AnyView, App, AppContext as _, Context, Entity, EntityId, EventEmitter, - FocusHandle, Focusable, Global, Hsla, IntoElement, Render, SharedString, WeakEntity, Window, + AnyElement, AnyView, App, Context, Entity, FocusHandle, Hsla, IntoElement, SharedString, + WeakEntity, Window, }; +use gpui_base::dock::{PanelId, PanelState, TabGroup}; use rust_i18n::t; -use std::{collections::HashMap, sync::Arc}; - -use super::{DockArea, PanelInfo, PanelState, invalid_panel::InvalidPanel}; -pub enum PanelEvent { - ZoomIn, - ZoomOut, - LayoutChanged, -} +use crate::{button::Button, menu::PopupMenu}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum PanelStyle { @@ -49,35 +62,33 @@ impl PanelControl { } } -/// The Panel trait used to define the panel. +/// What a panel draws, on top of the behavior [`gpui_base::dock::Panel`] +/// defines. +/// +/// Everything here has a default, so a panel that only implements base's trait +/// plus this one gets an unnamed title and no chrome. #[allow(unused_variables)] -pub trait Panel: EventEmitter + Render + Focusable { - /// The name of the panel used to serialize, deserialize and identify the panel. - /// - /// This is used to identify the panel when deserializing the panel. - /// Once you have defined a panel name, this must not be changed. - fn panel_name(&self) -> &'static str; - - /// The name of the tab of the panel, default is `None`. +pub trait Panel: gpui_base::dock::Panel { + /// The short name shown when a tab bar has no room for the full title. /// - /// Used to display in the already collapsed tab panel. + /// Used by an already-collapsed tab group, where only the strip of tabs is + /// on screen. fn tab_name(&self, cx: &App) -> Option { None } - /// The title of the panel + /// The panel's title, as an element rather than a string so a panel can + /// draw an icon, a badge, or a styled fragment in the tab. fn title(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - SharedString::from(t!("Dock.Unnamed")) + t!("Dock.Unnamed") } - /// The theme of the panel title, default is `None`. + /// Colors for the title, for a panel that wants its tab to stand out. fn title_style(&self, cx: &App) -> Option { None } - /// The suffix of the panel title, default is `None`. - /// - /// This is used to add a suffix element to the panel title. + /// An element pinned to the trailing end of the title bar. fn title_suffix( &mut self, window: &mut Window, @@ -86,116 +97,62 @@ pub trait Panel: EventEmitter + Render + Focusable { None:: } - /// Whether the panel can be closed, default is `true`. - /// - /// This method called in Panel render, we should make sure it is fast. - fn closable(&self, cx: &App) -> bool { - true - } - - /// Return `PanelControl` if the panel is zoomable, default is `PanelControl::Menu`. - /// - /// This method called in Panel render, we should make sure it is fast. - fn zoomable(&self, cx: &App) -> Option { - Some(PanelControl::Menu) - } - - /// Return false to hide panel, true to show panel, default is `true`. - /// - /// This method called in Panel render, we should make sure it is fast. - fn visible(&self, cx: &App) -> bool { - true - } - - /// Set active state of the panel. - /// - /// This method will be called when the panel is active or inactive. - /// - /// The last_active_panel and current_active_panel will be touched when the panel is active. - fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) {} - - /// Set zoomed state of the panel. - /// - /// This method will be called when the panel is zoomed or unzoomed. - /// - /// Only current Panel will touch this method. - fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context) {} - - /// When this Panel is added to a TabPanel, this will be called. - fn on_added_to( + /// Buttons for the title bar's toolbar. + fn toolbar_buttons( &mut self, - tab_panel: WeakEntity, window: &mut Window, cx: &mut Context, - ) { + ) -> Option> { + None } - /// When this Panel is removed from a TabPanel, this will be called. - fn on_removed(&mut self, window: &mut Window, cx: &mut Context) {} - - /// The addition dropdown menu of the panel, default is `None`. + /// Entries the panel adds to the title bar's ellipsis menu. fn dropdown_menu( &mut self, - this: PopupMenu, + menu: PopupMenu, window: &mut Window, cx: &mut Context, ) -> PopupMenu { - this - } - - /// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`. - fn toolbar_buttons( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - None + menu } - /// Dump the panel, used to serialize the panel. - fn dump(&self, cx: &App) -> PanelState { - PanelState::new(self) + /// Where the zoom affordance appears, or `None` for nowhere. + /// + /// `None` withholds the whole affordance, not just the button: the + /// [`ToggleZoom`](super::ToggleZoom) action refuses to zoom a panel that + /// offers no control, so either answer alone is enough to mean "never + /// zoom". Zooming *out* is never refused — a panel that stops offering + /// the control while zoomed would otherwise strand the user with no way + /// back. + /// + /// [`gpui_base::dock::Panel::zoomable`] is the other half: it decides + /// whether zooming happens at all, and base refuses a zoom that fails it + /// however the zoom was asked for. + fn zoom_control(&self, cx: &App) -> Option { + Some(PanelControl::Menu) } - /// Whether the panel has inner padding when the panel is in the tabs layout, default is `true`. + /// Whether the tab group pads the panel's content when it draws it inside + /// a tab bar. fn inner_padding(&self, cx: &App) -> bool { true } } -/// The PanelView trait used to define the panel view. -#[allow(unused_variables)] -pub trait PanelView: 'static + Send + Sync { - fn panel_name(&self, cx: &App) -> &'static str; - fn panel_id(&self, cx: &App) -> EntityId; +/// Object-safe counterpart of [`Panel`], and the presentation half of the +/// handle a skin holds. +pub trait PanelView: gpui_base::dock::PanelView { fn tab_name(&self, cx: &App) -> Option; fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement; - fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option; fn title_style(&self, cx: &App) -> Option; - fn closable(&self, cx: &App) -> bool; - fn zoomable(&self, cx: &App) -> Option; - fn visible(&self, cx: &App) -> bool; - fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); - fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App); - fn on_added_to(&self, tab_panel: WeakEntity, window: &mut Window, cx: &mut App); - fn on_removed(&self, window: &mut Window, cx: &mut App); - fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu; + fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option; fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option>; - fn view(&self) -> AnyView; - fn focus_handle(&self, cx: &App) -> FocusHandle; - fn dump(&self, cx: &App) -> PanelState; + fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu; + fn zoom_control(&self, cx: &App) -> Option; fn inner_padding(&self, cx: &App) -> bool; } impl PanelView for Entity { - fn panel_name(&self, cx: &App) -> &'static str { - self.read(cx).panel_name() - } - - fn panel_id(&self, _: &App) -> EntityId { - self.entity_id() - } - fn tab_name(&self, cx: &App) -> Option { self.read(cx).tab_name(cx) } @@ -204,168 +161,463 @@ impl PanelView for Entity { self.update(cx, |this, cx| this.title(window, cx).into_any_element()) } + fn title_style(&self, cx: &App) -> Option { + self.read(cx).title_style(cx) + } + fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option { self.update(cx, |this, cx| { this.title_suffix(window, cx) - .map(|el| el.into_any_element()) + .map(|element| element.into_any_element()) }) } - fn title_style(&self, cx: &App) -> Option { - self.read(cx).title_style(cx) + fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option> { + self.update(cx, |this, cx| this.toolbar_buttons(window, cx)) + } + + fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu { + self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx)) + } + + fn zoom_control(&self, cx: &App) -> Option { + self.read(cx).zoom_control(cx) + } + + fn inner_padding(&self, cx: &App) -> bool { + self.read(cx).inner_padding(cx) + } +} + +/// The panel handle `gpui-base` holds on this crate's behalf. +/// +/// Concrete on purpose. Base stores it as +/// `Arc`, and every renderer hook — the tab +/// bar, the tile drag bar — gets it back with [`Self::of`], which is an `Any` +/// downcast to this one type. That is the only recovery Rust offers: the +/// sub-trait object `Arc` cannot be reconstructed from base's +/// handle, but a concrete wrapper around it can be. +#[derive(Clone)] +pub struct PanelHandle(Arc); + +impl PanelHandle { + pub fn new(panel: Entity

) -> Self { + Self(Arc::new(panel)) + } + + /// Wrap a presentation handle that is already erased. + pub fn from_view(panel: Arc) -> Self { + Self(panel) + } + + /// Recover the handle behind one of base's, or `None` when base is + /// holding a panel this crate did not wrap — a bare `Entity

` handed + /// straight to [`gpui_base::dock::DockLayout::panel`], say. A skin must + /// draw something for that case; base's own + /// [`gpui_base::dock::PanelView::panel_name`] is the fallback with no + /// obligations on the panel author. + pub fn of(panel: &Arc) -> Option<&Self> { + panel.as_any().downcast_ref::() + } + + /// The presentation handle, cloned out. + /// + /// Owned rather than borrowed because the title bar's ellipsis menu is + /// built inside a `'static` callback: the menu closure outlives the + /// `render_tab_bar` call that created it, so it cannot borrow from the + /// render context it was made in. + pub fn panel(&self) -> Arc { + self.0.clone() + } +} + +/// So a recovered handle answers the presentation trait directly: +/// `PanelHandle::of(panel)?.title(window, cx)`. Only [`Self::panel`] hands out +/// an owned clone, which is what a `'static` callback needs. +impl std::ops::Deref for PanelHandle { + type Target = dyn PanelView; + + fn deref(&self) -> &Self::Target { + &*self.0 + } +} + +impl gpui_base::dock::PanelView for PanelHandle { + fn panel_name(&self, cx: &App) -> &'static str { + self.0.panel_name(cx) + } + + fn panel_id(&self, cx: &App) -> PanelId { + self.0.panel_id(cx) } fn closable(&self, cx: &App) -> bool { - self.read(cx).closable(cx) + self.0.closable(cx) } - fn zoomable(&self, cx: &App) -> Option { - self.read(cx).zoomable(cx) + fn zoomable(&self, cx: &App) -> bool { + self.0.zoomable(cx) } fn visible(&self, cx: &App) -> bool { - self.read(cx).visible(cx) + self.0.visible(cx) } fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| { - this.set_active(active, window, cx); - }) + self.0.set_active(active, window, cx); } fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| { - this.set_zoomed(zoomed, window, cx); - }) + self.0.set_zoomed(zoomed, window, cx); } - fn on_added_to(&self, tab_panel: WeakEntity, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| this.on_added_to(tab_panel, window, cx)); + fn on_added_to(&self, group: WeakEntity, window: &mut Window, cx: &mut App) { + self.0.on_added_to(group, window, cx); } fn on_removed(&self, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| this.on_removed(window, cx)); - } - - fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu { - self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx)) - } - - fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option> { - self.update(cx, |this, cx| this.toolbar_buttons(window, cx)) + self.0.on_removed(window, cx); } fn view(&self) -> AnyView { - self.clone().into() + self.0.view() } fn focus_handle(&self, cx: &App) -> FocusHandle { - self.read(cx).focus_handle(cx) + self.0.focus_handle(cx) } fn dump(&self, cx: &App) -> PanelState { - self.read(cx).dump(cx) + self.0.dump(cx) } - fn inner_padding(&self, cx: &App) -> bool { - self.read(cx).inner_padding(cx) + fn as_any(&self) -> &dyn Any { + self } } -impl From<&dyn PanelView> for AnyView { - fn from(handle: &dyn PanelView) -> Self { - handle.view() - } +/// Wrap `panel` so base carries its presentation across the renderer seam. +/// +/// This is what every entry point into the dock wants: +/// `DockLayout::tabs().panel_view(panel_handle(story), cx)`, +/// `DockLayout::tiles().tile_view(panel_handle(story), bounds, cx)`, +/// `DockArea::add_panel_view(panel_handle(story), ..)`, +/// `DockArea::add_tile_view(panel_handle(story), ..)`, and the closure a +/// [`register_panel`](gpui_base::dock::register_panel) builder returns. +/// +/// Base's own `DockLayout::panel` / `tile` and `DockArea::add_panel` / +/// `add_tile` also accept a panel — a `gpui_component::dock::Panel` is a +/// `gpui_base::dock::Panel` — but they store the bare entity, and a skin +/// cannot recover presentation from one. Such a panel still docks, drags and +/// persists; it just draws its `panel_name` where its title would be. +pub fn panel_handle(panel: Entity

) -> Arc { + Arc::new(PanelHandle::new(panel)) } -impl From<&dyn PanelView> for Entity { - fn from(value: &dyn PanelView) -> Self { - value.view().downcast::().unwrap() +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc}; + + use gpui::{ + AppContext as _, Bounds, Div, Empty, EventEmitter, Focusable, InteractiveElement as _, + ParentElement as _, Render, Stateful, Styled as _, TestAppContext, div, point, px, size, + }; + use gpui_base::dock::{ + DockArea, DockAreaRenderer, DockLayout, PanelEvent, TabGroupContext, TabGroupRenderer, + TileContext, TilesRenderer, + }; + + use super::*; + + struct Probe { + focus_handle: FocusHandle, + tab_name: SharedString, + } + + impl Probe { + fn new(tab_name: &str, cx: &mut App) -> Entity { + let tab_name = SharedString::from(tab_name.to_string()); + cx.new(|cx| Self { + focus_handle: cx.focus_handle(), + tab_name, + }) + } } -} -impl PartialEq for dyn PanelView { - fn eq(&self, other: &Self) -> bool { - self.view() == other.view() + impl gpui_base::dock::Panel for Probe { + fn panel_name(&self) -> &'static str { + "Probe" + } } -} -pub struct PanelRegistry { - pub(super) items: HashMap< - String, - Arc< - dyn Fn( - WeakEntity, - &PanelState, - &PanelInfo, - &mut Window, - &mut App, - ) -> Box, - >, - >, -} -impl PanelRegistry { - /// Initialize the panel registry. - pub(crate) fn init(cx: &mut App) { - if let None = cx.try_global::() { - cx.set_global(PanelRegistry::new()); + impl Panel for Probe { + fn tab_name(&self, _: &App) -> Option { + Some(self.tab_name.clone()) + } + } + + impl EventEmitter for Probe {} + + impl Focusable for Probe { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() } } - pub fn new() -> Self { - Self { - items: HashMap::new(), + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty } } - pub fn global(cx: &App) -> &Self { - cx.global::() + /// A read the skin took later, out of a handle it kept. + type DeferredRead = Box Option>; + + #[derive(Default)] + struct Recovered { + /// What the tab bar read off each panel while it drew, or `None` for a + /// panel it could not recover. + tab_names: Vec>, + /// The same, read by the tiles drag bar rather than the tab bar. + drag_bar_names: Vec>, + /// A read the tab bar deferred, the way an ellipsis menu defers + /// building its items. + deferred: Option, + } + + /// The skin: a tab bar that recovers this crate's handle out of base's and + /// reads presentation off it. + struct Skin { + recovered: Rc>, + } + + impl TabGroupRenderer for Skin { + fn render_tab_bar( + &self, + group: &TabGroupContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let mut recovered = self.recovered.borrow_mut(); + let mut tabs = Vec::new(); + for panel in group.panels() { + let Some(handle) = PanelHandle::of(panel) else { + recovered.tab_names.push(None); + continue; + }; + recovered.tab_names.push(handle.tab_name(cx)); + + // `title` takes the window and a mutable app while `group` is + // still borrowed, which is the shape every tab in a real tab + // bar is built from. + tabs.push(handle.title(window, cx)); + + // And the shape an ellipsis menu needs: an owned handle, + // called back with a window and an app long after this borrow + // of `group` is gone. + let panel = handle.panel(); + recovered.deferred = Some(Box::new(move |window, cx| { + let _ = panel.title(window, cx); + panel.tab_name(cx) + })); + } + + div().children(tabs).into_any_element() + } } - pub fn global_mut(cx: &mut App) -> &mut Self { - cx.global_mut::() + impl TilesRenderer for Skin { + fn render_drag_bar( + &self, + tile: &TileContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let handle = PanelHandle::of(tile.panel()); + self.recovered + .borrow_mut() + .drag_bar_names + .push(handle.and_then(|handle| handle.tab_name(cx))); + + match handle { + Some(handle) => div().child(handle.title(window, cx)).into_any_element(), + None => Empty.into_any_element(), + } + } } - /// Build a panel by name. - /// - /// If not registered, return InvalidPanel. - pub fn build_panel( - panel_name: &str, - dock_area: WeakEntity, - panel_state: &PanelState, - panel_info: &PanelInfo, - window: &mut Window, - cx: &mut App, - ) -> Box { - if let Some(view) = Self::global(cx) - .items - .get(panel_name) - .cloned() - .map(|f| f(dock_area, panel_state, panel_info, window, cx)) - { - return view; - } else { - // Show an invalid panel if the panel is not registered. - Box::new(cx.new(|cx| InvalidPanel::new(&panel_name, panel_state.clone(), window, cx))) + impl DockAreaRenderer for Skin { + fn frame(&self, _: &mut Window, _: &mut App) -> Stateful

{ + div().id("skin-dock-area").size_full() + } + + fn tab_group_renderer(&self) -> Rc { + Rc::new(Skin { + recovered: self.recovered.clone(), + }) + } + + fn tiles_renderer(&self) -> Rc { + Rc::new(Skin { + recovered: self.recovered.clone(), + }) } } -} -impl Global for PanelRegistry {} - -/// Register the Panel init by panel_name to global registry. -pub fn register_panel(cx: &mut App, panel_name: &str, deserialize: F) -where - F: Fn( - WeakEntity, - &PanelState, - &PanelInfo, - &mut Window, - &mut App, - ) -> Box - + 'static, -{ - PanelRegistry::init(cx); - PanelRegistry::global_mut(cx) - .items - .insert(panel_name.to_string(), Arc::new(deserialize)); + + /// The seam this module exists for: a panel wrapped in a [`PanelHandle`], + /// installed through base's `DockLayout`, comes back to the skin's tab bar + /// as a handle it can read presentation off. + /// + /// Nothing in `TabGroupContext` is typed to this crate — the renderer + /// holds `Arc` — so if the `Any` downcast + /// stopped working the recorded name would be `None` and this fails. + #[gpui::test] + fn the_tab_bar_recovers_presentation_from_a_base_panel_handle(cx: &mut TestAppContext) { + cx.update(|cx| { + let _ = gpui_base::Theme::global_mut(cx); + }); + let recovered = Rc::new(RefCell::new(Recovered::default())); + let skin = Rc::new(Skin { + recovered: recovered.clone(), + }); + + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("seam", None, window, cx).with_renderer(skin) + }); + + cx.update(|window, cx| { + let panel = PanelHandle::new(Probe::new("Probe Tab", cx)); + let layout = DockLayout::tabs().panel_view(Arc::new(panel), cx); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + }); + cx.run_until_parked(); + recovered.borrow_mut().tab_names.clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + recovered.borrow().tab_names, + vec![Some(SharedString::from("Probe Tab"))], + "the skin read its own panel trait off base's handle" + ); + + // And the handle it kept still answers once the frame that recovered + // it is over, which is what an ellipsis menu's callback needs. + let deferred = recovered.borrow_mut().deferred.take().expect("kept"); + let later = cx.update(|window, cx| deferred(window, cx)); + assert_eq!(later, Some(SharedString::from("Probe Tab"))); + } + + /// A panel rebuilt from persisted state is recoverable too. This needs no + /// new entry point: [`gpui_base::dock::register_panel`] already takes a + /// builder returning `Arc`, so the skin's + /// builder returns a [`PanelHandle`] and base stores that handle as-is. + #[gpui::test] + fn a_panel_rebuilt_from_persisted_state_is_recoverable(cx: &mut TestAppContext) { + cx.update(|cx| { + let _ = gpui_base::Theme::global_mut(cx); + }); + let recovered = Rc::new(RefCell::new(Recovered::default())); + let skin = Rc::new(Skin { + recovered: recovered.clone(), + }); + + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("seam", None, window, cx).with_renderer(skin) + }); + + cx.update(|window, cx| { + gpui_base::dock::register_panel(cx, "Probe", |_, _, cx| { + Arc::new(PanelHandle::new(Probe::new("Restored Tab", cx))) + as Arc + }); + let panel = PanelHandle::new(Probe::new("Probe Tab", cx)); + let layout = DockLayout::tabs().panel_view(Arc::new(panel), cx); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + }); + cx.run_until_parked(); + + let state = cx.read(|cx| area.read(cx).dump(cx)); + cx.update(|window, cx| area.update(cx, |area, cx| area.load(state, window, cx).unwrap())); + cx.run_until_parked(); + recovered.borrow_mut().tab_names.clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + recovered.borrow().tab_names, + vec![Some(SharedString::from("Restored Tab"))], + "the rebuilt panel reached the tab bar as a handle, not a bare entity" + ); + } + + /// A tile's drag bar is its title bar, so it has to reach the same + /// presentation the tab bar does. It does: [`TileContext::panel`] hands + /// over the same base handle, and the same downcast recovers it. + /// + /// This also exercises `DockLayout::tile_view`, the tiles half of the new + /// entry points. + #[gpui::test] + fn a_tile_drag_bar_reaches_the_same_presentation(cx: &mut TestAppContext) { + cx.update(|cx| { + let _ = gpui_base::Theme::global_mut(cx); + }); + let recovered = Rc::new(RefCell::new(Recovered::default())); + let skin = Rc::new(Skin { + recovered: recovered.clone(), + }); + + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("seam", None, window, cx).with_renderer(skin) + }); + + cx.update(|window, cx| { + let panel = PanelHandle::new(Probe::new("Tile Tab", cx)); + let bounds = Bounds { + origin: point(px(0.), px(0.)), + size: size(px(200.), px(150.)), + }; + let layout = DockLayout::tiles().tile_view(Arc::new(panel), bounds, cx); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + }); + cx.run_until_parked(); + recovered.borrow_mut().drag_bar_names.clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + recovered.borrow().drag_bar_names, + vec![Some(SharedString::from("Tile Tab"))], + "the drag bar can draw a title and a menu off the panel" + ); + } + + /// The other half of [`PanelHandle::of`]'s contract: a panel base was + /// handed directly is not this crate's handle, and the skin is told so + /// rather than being handed something wrong. + #[gpui::test] + fn a_panel_base_was_handed_directly_is_not_recoverable(cx: &mut TestAppContext) { + cx.update(|cx| { + let _ = gpui_base::Theme::global_mut(cx); + }); + let recovered = Rc::new(RefCell::new(Recovered::default())); + let skin = Rc::new(Skin { + recovered: recovered.clone(), + }); + + let (area, cx) = cx.add_window_view(|window, cx| { + DockArea::new("seam", None, window, cx).with_renderer(skin) + }); + + cx.update(|window, cx| { + let layout = DockLayout::tabs().panel(Probe::new("Probe Tab", cx)); + area.update(cx, |area, cx| area.set_center(layout, window, cx)); + }); + cx.run_until_parked(); + recovered.borrow_mut().tab_names.clear(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + assert_eq!( + recovered.borrow().tab_names, + vec![None], + "an unwrapped panel is reported as unrecoverable, not wrongly recovered" + ); + } } diff --git a/vendor/gpui-component/src/dock/stack_panel.rs b/vendor/gpui-component/src/dock/stack_panel.rs deleted file mode 100644 index 1dd91081..00000000 --- a/vendor/gpui-component/src/dock/stack_panel.rs +++ /dev/null @@ -1,434 +0,0 @@ -use std::sync::Arc; - -use crate::{ - ActiveTheme, AxisExt as _, Placement, - dock::PanelInfo, - h_flex, - resizable::{ - PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, - ResizableState, resizable_panel, - }, -}; - -use super::{DockArea, Panel, PanelEvent, PanelState, PanelView, TabPanel}; -use gpui::{ - App, AppContext as _, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, WeakEntity, - Window, -}; -use smallvec::SmallVec; - -pub struct StackPanel { - pub(super) parent: Option>, - pub(super) axis: Axis, - focus_handle: FocusHandle, - pub(crate) panels: SmallVec<[Arc; 2]>, - state: Entity, - _subscriptions: Vec, -} - -impl Panel for StackPanel { - fn panel_name(&self) -> &'static str { - "StackPanel" - } - - fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - "StackPanel" - } - - fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { - for panel in &self.panels { - panel.set_active(active, window, cx); - } - } - fn dump(&self, cx: &App) -> PanelState { - let sizes = self.state.read(cx).sizes().clone(); - let mut state = PanelState::new(self); - for panel in &self.panels { - state.add_child(panel.dump(cx)); - state.info = PanelInfo::stack(sizes.clone(), self.axis); - } - - state - } -} - -impl StackPanel { - pub fn new(axis: Axis, _: &mut Window, cx: &mut Context) -> Self { - let state = cx.new(|_| ResizableState::default()); - - let _subscriptions = vec![ - // Bubble up the resize event. - cx.subscribe(&state, |_, _, _: &ResizablePanelEvent, cx| { - cx.emit(PanelEvent::LayoutChanged) - }), - ]; - - Self { - axis, - parent: None, - focus_handle: cx.focus_handle(), - panels: SmallVec::new(), - state, - _subscriptions, - } - } - - /// The first level of the stack panel is root, will not have a parent. - fn is_root(&self) -> bool { - self.parent.is_none() - } - - /// Return true if self or parent only have last panel. - pub(super) fn is_last_panel(&self, cx: &App) -> bool { - if self.panels.len() > 1 { - return false; - } - - if let Some(parent) = &self.parent { - if let Some(parent) = parent.upgrade() { - return parent.read(cx).is_last_panel(cx); - } - } - - true - } - - pub(super) fn panels_len(&self) -> usize { - self.panels.len() - } - - /// Return the index of the panel. - pub(crate) fn index_of_panel(&self, panel: Arc) -> Option { - self.panels.iter().position(|p| p == &panel) - } - - fn assert_panel_is_valid(&self, panel: &Arc) { - assert!( - panel.view().downcast::().is_ok() - || panel.view().downcast::().is_ok(), - "Panel must be a `TabPanel` or `StackPanel`" - ); - } - - /// Add a panel at the end of the stack. - /// - /// If `size` is `None`, the panel will be given the average size of all panels in the stack. - /// - /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. - pub fn add_panel( - &mut self, - panel: Arc, - size: Option, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx); - } - - /// Add a panel at the [`Placement`]. - /// - /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. - pub fn add_panel_at( - &mut self, - panel: Arc, - placement: Placement, - size: Option, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_panel_at( - panel, - self.panels_len(), - placement, - size, - dock_area, - window, - cx, - ); - } - - /// Insert a panel at the index. - /// - /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. - #[allow(clippy::too_many_arguments)] - pub fn insert_panel_at( - &mut self, - panel: Arc, - ix: usize, - placement: Placement, - size: Option, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - match placement { - Placement::Top | Placement::Left => { - self.insert_panel_before(panel, ix, size, dock_area, window, cx) - } - Placement::Right | Placement::Bottom => { - self.insert_panel_after(panel, ix, size, dock_area, window, cx) - } - } - } - - /// Insert a panel at the index. - /// - /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. - pub fn insert_panel_before( - &mut self, - panel: Arc, - ix: usize, - size: Option, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_panel(panel, ix, size, dock_area, window, cx); - } - - /// Insert a panel after the index. - /// - /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. - pub fn insert_panel_after( - &mut self, - panel: Arc, - ix: usize, - size: Option, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_panel(panel, ix + 1, size, dock_area, window, cx); - } - - fn insert_panel( - &mut self, - panel: Arc, - ix: usize, - size: Option, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { - self.assert_panel_is_valid(&panel); - - // If the panel is already in the stack, return. - if let Some(_) = self.index_of_panel(panel.clone()) { - return; - } - - let view = cx.entity().clone(); - window.defer(cx, { - let panel = panel.clone(); - - move |window, cx| { - // If the panel is a TabPanel, set its parent to this. - if let Ok(tab_panel) = panel.view().downcast::() { - tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade())); - } else if let Ok(stack_panel) = panel.view().downcast::() { - stack_panel.update(cx, |stack_panel, _| { - stack_panel.parent = Some(view.downgrade()) - }); - } - - // Subscribe to the panel's layout change event. - _ = dock_area.update(cx, |this, cx| { - if let Ok(tab_panel) = panel.view().downcast::() { - this.subscribe_panel(&tab_panel, window, cx); - } else if let Ok(stack_panel) = panel.view().downcast::() { - this.subscribe_panel(&stack_panel, window, cx); - } - }); - } - }); - - let ix = if ix > self.panels.len() { - self.panels.len() - } else { - ix - }; - - // Get avg size of all panels to insert new panel, if size is None. - let size = match size { - Some(size) => size, - None => { - let state = self.state.read(cx); - (state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE) - } - }; - - self.panels.insert(ix, panel.clone()); - self.state.update(cx, |state, cx| { - state.insert_panel(Some(size), Some(ix), cx); - }); - cx.emit(PanelEvent::LayoutChanged); - cx.notify(); - } - - /// Remove panel from the stack. - /// - /// If `ix` is not found, do nothing. - pub fn remove_panel( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - let Some(ix) = self.index_of_panel(panel.clone()) else { - return; - }; - - self.panels.remove(ix); - self.state.update(cx, |state, cx| { - state.remove_panel(ix, cx); - }); - - cx.emit(PanelEvent::LayoutChanged); - self.remove_self_if_empty(window, cx); - } - - /// Replace the old panel with the new panel at same index. - pub(super) fn replace_panel( - &mut self, - old_panel: Arc, - new_panel: Entity, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(ix) = self.index_of_panel(old_panel.clone()) { - self.panels[ix] = Arc::new(new_panel.clone()); - - let panel_state = ResizablePanelState::default(); - self.state.update(cx, |state, cx| { - state.replace_panel(ix, panel_state, cx); - }); - cx.emit(PanelEvent::LayoutChanged); - } - } - - /// If children is empty, remove self from parent view. - pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context) { - if self.is_root() { - return; - } - - if !self.panels.is_empty() { - return; - } - - let view = cx.entity().clone(); - if let Some(parent) = self.parent.as_ref() { - _ = parent.update(cx, |parent, cx| { - parent.remove_panel(Arc::new(view.clone()), window, cx); - }); - } - - cx.emit(PanelEvent::LayoutChanged); - cx.notify(); - } - - /// Find the first top left in the stack. - pub(super) fn left_top_tab_panel( - &self, - check_parent: bool, - cx: &App, - ) -> Option> { - if check_parent { - if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) { - if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) { - return Some(panel); - } - } - } - - let first_panel = self.panels.first(); - if let Some(view) = first_panel { - if let Ok(tab_panel) = view.view().downcast::() { - Some(tab_panel) - } else if let Ok(stack_panel) = view.view().downcast::() { - stack_panel.read(cx).left_top_tab_panel(false, cx) - } else { - None - } - } else { - None - } - } - - /// Find the first top right in the stack. - pub(super) fn right_top_tab_panel( - &self, - check_parent: bool, - cx: &App, - ) -> Option> { - if check_parent { - if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) { - if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) { - return Some(panel); - } - } - } - - let panel = if self.axis.is_vertical() { - self.panels.first() - } else { - self.panels.last() - }; - - if let Some(view) = panel { - if let Ok(tab_panel) = view.view().downcast::() { - Some(tab_panel) - } else if let Ok(stack_panel) = view.view().downcast::() { - stack_panel.read(cx).right_top_tab_panel(false, cx) - } else { - None - } - } else { - None - } - } - - /// Remove all panels from the stack. - pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { - self.panels.clear(); - self.state.update(cx, |state, cx| { - state.clear(); - cx.notify(); - }); - } - - /// Change the axis of the stack panel. - pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { - self.axis = axis; - cx.notify(); - } -} - -impl Focusable for StackPanel { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} -impl EventEmitter for StackPanel {} -impl EventEmitter for StackPanel {} -impl Render for StackPanel { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .size_full() - .overflow_hidden() - .bg(cx.theme().tab_bar) - .child( - ResizablePanelGroup::new("stack-panel-group") - .with_state(&self.state) - .axis(self.axis) - .children(self.panels.clone().into_iter().map(|panel| { - resizable_panel() - .child(panel.view()) - .visible(panel.visible(cx)) - })), - ) - } -} diff --git a/vendor/gpui-component/src/dock/state.rs b/vendor/gpui-component/src/dock/state.rs deleted file mode 100644 index f54d0c3e..00000000 --- a/vendor/gpui-component/src/dock/state.rs +++ /dev/null @@ -1,282 +0,0 @@ -use gpui::{App, AppContext, Axis, Bounds, Entity, Pixels, WeakEntity, Window, point, px, size}; -use itertools::Itertools as _; -use serde::{Deserialize, Serialize}; - -use super::{Dock, DockArea, DockItem, DockPlacement, Panel, PanelRegistry}; - -/// Used to serialize and deserialize the DockArea -#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] -pub struct DockAreaState { - /// The version is used to mark this persisted state is compatible with the current version - /// For example, some times we many totally changed the structure of the Panel, - /// then we can compare the version to decide whether we can use the state or ignore. - #[serde(default)] - pub version: Option, - pub center: PanelState, - #[serde(skip_serializing_if = "Option::is_none")] - pub left_dock: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub right_dock: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bottom_dock: Option, -} - -/// Used to serialize and deserialize the Dock -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DockState { - panel: PanelState, - placement: DockPlacement, - size: Pixels, - open: bool, -} - -impl DockState { - pub fn new(dock: Entity, cx: &App) -> Self { - let dock = dock.read(cx); - - Self { - placement: dock.placement, - size: dock.size, - open: dock.open, - panel: dock.panel.view().dump(cx), - } - } - - /// Convert the DockState to Dock - pub fn to_dock( - &self, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> Entity { - let item = self.panel.to_item(dock_area.clone(), window, cx); - cx.new(|cx| { - Dock::from_state( - dock_area.clone(), - self.placement, - self.size, - item, - self.open, - window, - cx, - ) - }) - } -} - -/// Used to serialize and deserialize the DockerItem -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PanelState { - pub panel_name: String, - pub children: Vec, - pub info: PanelInfo, -} - -#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] -pub struct TileMeta { - pub bounds: Bounds, - pub z_index: usize, -} - -impl Default for TileMeta { - fn default() -> Self { - Self { - bounds: Bounds { - origin: point(px(10.), px(10.)), - size: size(px(200.), px(200.)), - }, - z_index: 0, - } - } -} - -impl From> for TileMeta { - fn from(bounds: Bounds) -> Self { - Self { bounds, z_index: 0 } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum PanelInfo { - #[serde(rename = "stack")] - Stack { - sizes: Vec, - axis: usize, // 0 for horizontal, 1 for vertical - }, - #[serde(rename = "tabs")] - Tabs { active_index: usize }, - #[serde(rename = "panel")] - Panel(serde_json::Value), - #[serde(rename = "tiles")] - Tiles { metas: Vec }, -} - -impl PanelInfo { - pub fn stack(sizes: Vec, axis: Axis) -> Self { - Self::Stack { - sizes, - axis: if axis == Axis::Horizontal { 0 } else { 1 }, - } - } - - pub fn tabs(active_index: usize) -> Self { - Self::Tabs { active_index } - } - - pub fn panel(info: serde_json::Value) -> Self { - Self::Panel(info) - } - - pub fn tiles(metas: Vec) -> Self { - Self::Tiles { metas } - } - - pub fn axis(&self) -> Option { - match self { - Self::Stack { axis, .. } => Some(if *axis == 0 { - Axis::Horizontal - } else { - Axis::Vertical - }), - _ => None, - } - } - - pub fn sizes(&self) -> Option<&Vec> { - match self { - Self::Stack { sizes, .. } => Some(sizes), - _ => None, - } - } - - pub fn active_index(&self) -> Option { - match self { - Self::Tabs { active_index } => Some(*active_index), - _ => None, - } - } -} - -impl Default for PanelState { - fn default() -> Self { - Self { - panel_name: "".to_string(), - children: Vec::new(), - info: PanelInfo::Panel(serde_json::Value::Null), - } - } -} - -impl PanelState { - pub fn new(panel: &P) -> Self { - Self { - panel_name: panel.panel_name().to_string(), - ..Default::default() - } - } - - pub fn add_child(&mut self, panel: PanelState) { - self.children.push(panel); - } - - pub fn to_item( - &self, - dock_area: WeakEntity, - window: &mut Window, - cx: &mut App, - ) -> DockItem { - let info = self.info.clone(); - - let items: Vec = self - .children - .iter() - .map(|child| child.to_item(dock_area.clone(), window, cx)) - .collect(); - - match info { - PanelInfo::Stack { sizes, axis } => { - let axis = if axis == 0 { - Axis::Horizontal - } else { - Axis::Vertical - }; - let sizes = sizes.iter().map(|s| Some(*s)).collect_vec(); - DockItem::split_with_sizes(axis, items, sizes, &dock_area, window, cx) - } - PanelInfo::Tabs { active_index } => { - if items.len() == 1 { - return items[0].clone(); - } - - let items = items - .iter() - .flat_map(|item| match item { - DockItem::Tabs { items, .. } => items.clone(), - _ => { - // ignore invalid panels in tabs - vec![] - } - }) - .collect_vec(); - - DockItem::tabs(items, &dock_area, window, cx).active_index(active_index) - } - PanelInfo::Panel(_) => { - let view = PanelRegistry::build_panel( - &self.panel_name, - dock_area.clone(), - self, - &info, - window, - cx, - ); - DockItem::tabs(vec![view.into()], &dock_area, window, cx) - } - PanelInfo::Tiles { metas } => DockItem::tiles(items, metas, &dock_area, window, cx), - } - } -} - -#[cfg(test)] -mod tests { - use gpui::px; - - use super::*; - #[test] - fn test_deserialize_item_state() { - let json = include_str!("../../tests/fixtures/layout.json"); - let state: DockAreaState = serde_json::from_str(json).unwrap(); - assert_eq!(state.version, None); - assert_eq!(state.center.panel_name, "StackPanel"); - assert_eq!(state.center.children.len(), 2); - assert_eq!(state.center.children[0].panel_name, "TabPanel"); - assert_eq!(state.center.children[1].children.len(), 1); - assert_eq!( - state.center.children[1].children[0].panel_name, - "StoryContainer" - ); - assert_eq!(state.center.children[1].panel_name, "TabPanel"); - - let left_dock = state.left_dock.unwrap(); - assert_eq!(left_dock.open, true); - assert_eq!(left_dock.size, px(350.0)); - assert_eq!(left_dock.placement, DockPlacement::Left); - assert_eq!(left_dock.panel.panel_name, "TabPanel"); - assert_eq!(left_dock.panel.children.len(), 1); - assert_eq!(left_dock.panel.children[0].panel_name, "StoryContainer"); - - let bottom_dock = state.bottom_dock.unwrap(); - assert_eq!(bottom_dock.open, true); - assert_eq!(bottom_dock.size, px(200.0)); - assert_eq!(bottom_dock.panel.panel_name, "TabPanel"); - assert_eq!(bottom_dock.panel.children.len(), 2); - assert_eq!(bottom_dock.panel.children[0].panel_name, "StoryContainer"); - - let right_dock = state.right_dock.unwrap(); - assert_eq!(right_dock.open, true); - assert_eq!(right_dock.size, px(320.0)); - assert_eq!(right_dock.panel.panel_name, "TabPanel"); - assert_eq!(right_dock.panel.children.len(), 1); - assert_eq!(right_dock.panel.children[0].panel_name, "StoryContainer"); - } -} diff --git a/vendor/gpui-component/src/dock/tab_panel.rs b/vendor/gpui-component/src/dock/tab_panel.rs index 308ec8e8..b9e023a8 100644 --- a/vendor/gpui-component/src/dock/tab_panel.rs +++ b/vendor/gpui-component/src/dock/tab_panel.rs @@ -1,50 +1,58 @@ -use std::sync::Arc; +//! The gpui-component appearance for a tab group. +//! +//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed +//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything +//! visible is here: the tab bar, the toolbar, the ellipsis menu, the dock +//! collapse affordances, the drop placeholder, and the styled drag preview. + +use std::{ + cell::{Cell, RefCell}, + collections::HashSet, + rc::Rc, + sync::Arc, + time::Duration, +}; use gpui::{ - App, AppContext, Context, Corner, DismissEvent, Div, DragMoveEvent, Empty, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement, - Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, StyleRefinement, - Styled, WeakEntity, Window, div, prelude::FluentBuilder, px, relative, rems, + Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Context, Div, + Empty, InteractiveElement as _, IntoElement, ParentElement as _, Point, Render, ScrollHandle, + SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, + div, prelude::FluentBuilder as _, px, +}; +use gpui_base::dock::{ + AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef, PanelId, + TabGroupContext, TabGroupRenderer, }; use rust_i18n::t; use crate::{ - ActiveTheme, AxisExt, IconName, Placement, Selectable, Sizable, + ActiveTheme as _, IconName, Selectable as _, Sizable as _, + animation::{Lerp as _, ease_out_cubic}, button::{Button, ButtonVariants as _}, - dock::PanelInfo, + dock::{ClosePanel, PanelControl, PanelHandle, PanelStyle, SkinShared, ToggleZoom}, h_flex, - menu::{DropdownMenu, PopupMenu}, + menu::DropdownMenu as _, tab::{Tab, TabBar}, v_flex, }; -use super::{ - ClosePanel, DockArea, DockPlacement, Panel, PanelControl, PanelEvent, PanelState, PanelStyle, - PanelView, StackPanel, ToggleZoom, -}; - -#[derive(Clone)] -struct TabState { - closable: bool, - zoomable: Option, - draggable: bool, - droppable: bool, - active_panel: Option>, -} +/// Names the tab bar's zoom button in the debug-bounds map, so a test can ask +/// a really-drawn frame whether the control was offered. +const ZOOM_CONTROL_SELECTOR: &str = "dock-tab-bar-zoom-control"; -#[derive(Clone)] -pub(crate) struct DragPanel { - pub(crate) panel: Arc, - pub(crate) tab_panel: Entity, -} +/// The size the styled drag preview occupies, reported to base so a drop +/// placeholder knows where to fly in from. +const DRAG_PREVIEW_SIZE: gpui::Size = gpui::size(px(96.), px(30.)); -impl DragPanel { - pub(crate) fn new(panel: Arc, tab_panel: Entity) -> Self { - Self { panel, tab_panel } - } +/// The preview that follows the cursor while a panel is dragged. +/// +/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the +/// appearance half, reintroduced here. +pub struct DragPanelPreview { + panel: Arc, } -impl Render for DragPanel { +impl Render for DragPanelPreview { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .id("drag-panel") @@ -58,412 +66,244 @@ impl Render for DragPanel { .border_color(cx.theme().border) .rounded(cx.theme().radius) .text_color(cx.theme().tab_foreground) - .bg(cx.theme().tab_active) + .bg(cx.theme().tokens.tab_active) .opacity(0.75) - .child(self.panel.title(window, cx)) + .child(panel_title(&self.panel, window, cx)) } } -pub struct TabPanel { - focus_handle: FocusHandle, - dock_area: WeakEntity, - /// The stock_panel can be None, if is None, that means the panels can't be split or move - stack_panel: Option>, - pub(crate) panels: Vec>, - pub(crate) active_ix: usize, - /// If this is true, the Panel closable will follow the active panel's closable, - /// otherwise this TabPanel will not able to close - /// - /// This is used for Dock to limit the last TabPanel not able to close, see [`super::Dock::new`]. - pub(crate) closable: bool, - - tab_bar_scroll_handle: ScrollHandle, - zoomed: bool, - collapsed: bool, - /// When drag move, will get the placement of the panel to be split - will_split_placement: Option, - /// Is TabPanel used in Tiles. - in_tiles: bool, +/// A panel's title, or its registered name when it reached base without this +/// crate's handle and so carries no presentation. See [`PanelHandle::of`]. +pub(crate) fn panel_title( + panel: &Arc, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let Some(handle) = PanelHandle::of(panel) else { + let name = panel.panel_name(cx); + warn_unwrapped_once(panel.panel_id(cx), name); + return SharedString::from(name).into_any_element(); + }; + handle.title(window, cx) } -impl Panel for TabPanel { - fn panel_name(&self) -> &'static str { - "TabPanel" - } - - fn title(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.active_panel(cx) - .map(|panel| panel.title(window, cx)) - .unwrap_or("Empty Tab".into_any_element()) - } - - fn closable(&self, cx: &App) -> bool { - if !self.closable { - return false; - } - - // 1. When is the final panel in the dock, it will not able to close. - // 2. When is in the Tiles, it will always able to close (by active panel state). - if !self.draggable(cx) && !self.in_tiles { - return false; - } - - self.active_panel(cx) - .map(|panel| panel.closable(cx)) - .unwrap_or(false) - } +thread_local! { + /// Panels already warned about. `panel_title` sits on the render path, so + /// an unguarded warning would repeat at frame rate and bury the very + /// signal it exists to give. + /// + /// Keyed by panel rather than a bare `Once` so a second wrongly installed + /// panel is still named, and a runtime set rather than a `debug_assert!` + /// so a release build says it too — the consequence is a shipped app whose + /// tabs are titleless, which is exactly when someone needs to be told. + /// Thread-local because rendering happens on one thread, so no lock is + /// needed. + static WARNED_UNWRAPPED: RefCell> = RefCell::new(HashSet::new()); +} - fn zoomable(&self, cx: &App) -> Option { - self.active_panel(cx).and_then(|panel| panel.zoomable(cx)) +/// Say once, per panel, that a panel reached the skin without its +/// presentation handle. Silent otherwise, and visual-only: the panel docks, +/// drags and persists, it just has no title. The shorter method is the wrong +/// one — `DockLayout::panel` and `DockArea::add_panel` accept a +/// `gpui_component::dock::Panel` and store the bare entity — so this says +/// which panel and what to call instead. +fn warn_unwrapped_once(panel: PanelId, name: &'static str) { + if !WARNED_UNWRAPPED.with(|warned| warned.borrow_mut().insert(panel)) { + return; } + tracing::warn!( + panel = name, + "dock panel reached the skin without its presentation handle, so it \ + draws its panel name instead of its title; install it with \ + `gpui_component::dock::panel_handle(..)` and `DockLayout::panel_view` \ + / `DockArea::add_panel_view` rather than `DockLayout::panel` / \ + `DockArea::add_panel`" + ); +} - fn visible(&self, cx: &App) -> bool { - self.visible_panels(cx).next().is_some() - } +/// Where the zoom affordance goes for the group's displayed panel, or `None` +/// when there is none to offer. +/// +/// Two questions, and both have to be asked. [`Panel::zoom_control`] says +/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says +/// whether zooming happens at all, and base refuses a zoom that fails it. The +/// old dock had a single `zoomable() -> Option` that could not +/// disagree with itself; split across the seam it can, and a panel answering +/// `zoomable() == false` with `zoom_control() == Some(Toolbar)` would +/// otherwise draw a button that does nothing. +fn zoom_control(group: &TabGroupContext, cx: &App) -> Option { + let panel = group.active_panel()?; + panel + .zoomable(cx) + .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx))) + .flatten() +} - fn dropdown_menu( - &mut self, - menu: PopupMenu, - window: &mut Window, - cx: &mut Context, - ) -> PopupMenu { - if let Some(panel) = self.active_panel(cx) { - panel.dropdown_menu(menu, window, cx) - } else { - menu - } - } +/// The payload for dragging the tab at `ix` out of its group, or `None` when +/// this group must not be rearranged. +/// +/// The guard is the skin's. [`TabGroupContext::drag_panel`] answers for any +/// tab in range whether or not the group may be rearranged, so a tab bar that +/// forgets to ask [`TabGroupContext::is_draggable`] makes a group that has +/// nowhere to go — a dock's last group, a locked dock — draggable anyway. The +/// old dock asked the same question, spelled `state.draggable`. +fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option { + group + .is_draggable() + .then(|| group.drag_panel(ix, cx)) + .flatten() +} - fn toolbar_buttons( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - self.active_panel(cx) - .and_then(|panel| panel.toolbar_buttons(window, cx)) +/// The left-most, top-most tab group in a container — where a left dock's +/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`. +fn left_top_group(node: &PaneNode) -> Option { + match node.kind() { + PaneRef::Tabs { .. } => Some(node.id()), + PaneRef::Split { children, .. } => children.first().and_then(left_top_group), + PaneRef::Tiles { .. } => None, } +} - fn dump(&self, cx: &App) -> PanelState { - let mut state = PanelState::new(self); - for panel in self.panels.iter() { - state.add_child(panel.dump(cx)); - state.info = PanelInfo::tabs(self.active_ix); +/// The right-most, top-most tab group. A vertical split stacks its children, +/// so its *first* child is the top one; a horizontal split's last child is the +/// right-most. Mirrors the old `StackPanel::right_top_tab_panel`. +fn right_top_group(node: &PaneNode) -> Option { + match node.kind() { + PaneRef::Tabs { .. } => Some(node.id()), + PaneRef::Split { axis, children, .. } => match axis { + gpui::Axis::Vertical => children.first(), + gpui::Axis::Horizontal => children.last(), } - state + .and_then(right_top_group), + PaneRef::Tiles { .. } => None, } +} - fn inner_padding(&self, cx: &App) -> bool { - self.active_panel(cx) - .map_or(true, |panel| panel.inner_padding(cx)) - } +/// One tab group's appearance. +/// +/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once per +/// container — so the tab bar's scroll position belongs to the group whose +/// tabs it scrolls. +pub(crate) struct TabGroupSkin { + shared: Rc, + scroll_handle: ScrollHandle, + /// The displayed tab the last frame drew, so a change scrolls the new tab + /// into view. The old dock recorded this at the moment of selection; the + /// group now owns selection, so the skin notices instead of being told. + last_active_ix: Cell>, } -impl TabPanel { - pub fn new( - stack_panel: Option>, - dock_area: WeakEntity, - _: &mut Window, - cx: &mut Context, - ) -> Self { +impl TabGroupSkin { + pub(crate) fn new(shared: Rc) -> Self { Self { - focus_handle: cx.focus_handle(), - dock_area, - stack_panel, - panels: Vec::new(), - active_ix: 0, - tab_bar_scroll_handle: ScrollHandle::new(), - will_split_placement: None, - zoomed: false, - collapsed: false, - closable: true, - in_tiles: false, - } - } - - /// Mark the TabPanel as being used in Tiles. - pub(super) fn set_in_tiles(&mut self, in_tiles: bool) { - self.in_tiles = in_tiles; - } - - pub(super) fn set_parent(&mut self, view: WeakEntity) { - self.stack_panel = Some(view); - } - - /// Return current active_panel View - pub fn active_panel(&self, cx: &App) -> Option> { - let panel = self.panels.get(self.active_ix); - - if let Some(panel) = panel { - if panel.visible(cx) { - Some(panel.clone()) - } else { - // Return the first visible panel - self.visible_panels(cx).next() - } - } else { - None - } - } - - fn set_active_ix(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { - if ix == self.active_ix { - return; - } - - let last_active_ix = self.active_ix; - - self.active_ix = ix; - self.tab_bar_scroll_handle.scroll_to_item(ix); - self.focus_active_panel(window, cx); - - // Sync the active state to all panels - cx.spawn_in(window, async move |view, cx| { - _ = cx.update(|window, cx| { - _ = view.update(cx, |view, cx| { - if let Some(last_active) = view.panels.get(last_active_ix) { - last_active.set_active(false, window, cx); - } - if let Some(active) = view.panels.get(view.active_ix) { - active.set_active(true, window, cx); - } - }); - }); - }) - .detach(); - - cx.emit(PanelEvent::LayoutChanged); - cx.notify(); - } - - /// Add a panel to the end of the tabs - pub fn add_panel( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.add_panel_with_active(panel, true, window, cx); - } - - fn add_panel_with_active( - &mut self, - panel: Arc, - active: bool, - window: &mut Window, - cx: &mut Context, - ) { - assert_ne!( - panel.panel_name(cx), - "StackPanel", - "can not allows add `StackPanel` to `TabPanel`" - ); - - if self - .panels - .iter() - .any(|p| p.view().entity_id() == panel.view().entity_id()) - { - return; - } - - panel.on_added_to(cx.entity().downgrade(), window, cx); - self.panels.push(panel); - // set the active panel to the new panel - if active { - self.set_active_ix(self.panels.len() - 1, window, cx); - } - cx.emit(PanelEvent::LayoutChanged); - cx.notify(); - } - - /// Add panel to try to split - pub fn add_panel_at( - &mut self, - panel: Arc, - placement: Placement, - size: Option, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |view, cx| { - cx.update(|window, cx| { - view.update(cx, |view, cx| { - view.will_split_placement = Some(placement); - view.split_panel(panel, placement, size, window, cx) - }) - .ok() - }) - .ok() - }) - .detach(); - cx.emit(PanelEvent::LayoutChanged); - cx.notify(); - } - - fn insert_panel_at( - &mut self, - panel: Arc, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) { - if self - .panels - .iter() - .any(|p| p.view().entity_id() == panel.view().entity_id()) - { - return; - } - - panel.on_added_to(cx.entity().downgrade(), window, cx); - self.panels.insert(ix, panel); - self.set_active_ix(ix, window, cx); - cx.emit(PanelEvent::LayoutChanged); - cx.notify(); - } - - /// Remove a panel from the tab panel - pub fn remove_panel( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.detach_panel(panel, window, cx); - self.remove_self_if_empty(window, cx); - cx.emit(PanelEvent::ZoomOut); - cx.emit(PanelEvent::LayoutChanged); - } - - fn detach_panel( - &mut self, - panel: Arc, - window: &mut Window, - cx: &mut Context, - ) { - panel.on_removed(window, cx); - let panel_view = panel.view(); - self.panels.retain(|p| p.view() != panel_view); - if self.active_ix >= self.panels.len() { - self.set_active_ix(self.panels.len().saturating_sub(1), window, cx) + shared, + scroll_handle: ScrollHandle::default(), + last_active_ix: Cell::new(None), } } - /// Check to remove self from the parent StackPanel, if there is no panel left - fn remove_self_if_empty(&self, window: &mut Window, cx: &mut Context) { - if !self.panels.is_empty() { - return; - } - - let tab_view = cx.entity().clone(); - if let Some(stack_panel) = self.stack_panel.as_ref() { - _ = stack_panel.update(cx, |view, cx| { - view.remove_panel(Arc::new(tab_view), window, cx); - }); + /// Whether a dock's collapse affordance belongs in *this* group's tab bar, + /// and which way it points. `None` means this group draws none. + fn dock_toggle_button( + &self, + placement: DockPlacement, + group: &TabGroupContext, + cx: &mut App, + ) -> Option