diff --git a/.tasque/events.jsonl b/.tasque/events.jsonl
index a78de9f6..a48d3642 100644
--- a/.tasque/events.jsonl
+++ b/.tasque/events.jsonl
@@ -31,3 +31,28 @@
{"id":"01KHYT4D2FJNYPGZG737EA3QJ1","event_id":"01KHYT4D2FJNYPGZG737EA3QJ1","ts":"2026-02-21T00:38:28.175Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-xava98vb","payload":{"blocker":"tsq-qaph5d8a","dep_type":"blocks"}}
{"id":"01KHYT4EE3Z1JCBH4884WX9NSW","event_id":"01KHYT4EE3Z1JCBH4884WX9NSW","ts":"2026-02-21T00:38:29.571Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-xava98vb","payload":{"blocker":"tsq-sk0a27kc","dep_type":"blocks"}}
{"id":"01KHYT4FBTCR16F5RFYWPYA48Z","event_id":"01KHYT4FBTCR16F5RFYWPYA48Z","ts":"2026-02-21T00:38:30.522Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-xava98vb","payload":{"blocker":"tsq-84cxpjd8","dep_type":"blocks"}}
+{"id":"01KJ3RAHXTZ8ZVEBYRNTQ3Y5ZP","event_id":"01KJ3RAHXTZ8ZVEBYRNTQ3Y5ZP","ts":"2026-02-22T22:43:04.762Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-pmjtma8b","payload":{"description":"Manually port exact code snippets from upstream commits d2f0960d,c045f74f,d60c6d5f,b4310a66,3724b071 into highlighter/input code without cherry-picking and preserving fork changes.","discovered_from":null,"external_ref":null,"id":"tsq-pmjtma8b","kind":"task","parent_id":null,"planning_state":"planned","priority":2,"status":"open","title":"Port upstream editor syntax/perf manually"}}
+{"id":"01KJ3RB8ZEDG6E0VDZVNPJJBXW","event_id":"01KJ3RB8ZEDG6E0VDZVNPJJBXW","ts":"2026-02-22T22:43:28.366Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-7whgzsjy","payload":{"description":"Manually port exact code snippets from upstream commits 662e291a,24b552d8,545c3488,9fb44739 into table state/API/docs while preserving fork customizations.","discovered_from":null,"external_ref":null,"id":"tsq-7whgzsjy","kind":"task","parent_id":null,"planning_state":"planned","priority":2,"status":"open","title":"Port upstream table cell-selection series manually"}}
+{"id":"01KJ3RB96WMJM0N044QXEPVDDY","event_id":"01KJ3RB96WMJM0N044QXEPVDDY","ts":"2026-02-22T22:43:28.604Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-seytfm51","payload":{"description":"Manually port exact code snippets from upstream commits 94fdac9b,dc3850b8,3c10503b,c5fe81f1 into radio/popup_menu/input/tree while preserving fork customizations.","discovered_from":null,"external_ref":null,"id":"tsq-seytfm51","kind":"task","parent_id":null,"planning_state":"planned","priority":2,"status":"open","title":"Port upstream targeted bugfixes manually"}}
+{"id":"01KJ3RB9EB36CYNQD7VZ3ZQY79","event_id":"01KJ3RB9EB36CYNQD7VZ3ZQY79","ts":"2026-02-22T22:43:28.843Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-nhqse7rn","payload":{"description":"Run focused checks and document what was imported, skipped, or adapted due to fork customizations.","discovered_from":null,"external_ref":null,"id":"tsq-nhqse7rn","kind":"task","parent_id":null,"planning_state":"planned","priority":2,"status":"open","title":"Validate upstream port batch"}}
+{"id":"01KJ3RB9NND9GWQSS4KMPY39VW","event_id":"01KJ3RB9NND9GWQSS4KMPY39VW","ts":"2026-02-22T22:43:29.077Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-bd0a41d4","payload":{"description":"Update README.md with exact upstream commit/ref incorporated by this sync batch and summarize imported categories.","discovered_from":null,"external_ref":null,"id":"tsq-bd0a41d4","kind":"task","parent_id":null,"planning_state":"planned","priority":2,"status":"open","title":"Update README upstream sync reference"}}
+{"id":"01KJ3RB9YB61X7P67HC1JSTBRT","event_id":"01KJ3RB9YB61X7P67HC1JSTBRT","ts":"2026-02-22T22:43:29.355Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-nhqse7rn","payload":{"blocker":"tsq-pmjtma8b","dep_type":"blocks"}}
+{"id":"01KJ3RBA2FHTFMDRP4NDQK85DB","event_id":"01KJ3RBA2FHTFMDRP4NDQK85DB","ts":"2026-02-22T22:43:29.487Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-nhqse7rn","payload":{"blocker":"tsq-7whgzsjy","dep_type":"blocks"}}
+{"id":"01KJ3RBA6ARDCWC3A8YQPCT9ZP","event_id":"01KJ3RBA6ARDCWC3A8YQPCT9ZP","ts":"2026-02-22T22:43:29.610Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-nhqse7rn","payload":{"blocker":"tsq-seytfm51","dep_type":"blocks"}}
+{"id":"01KJ3RBAA4A3E2T0XAMCJTMAYA","event_id":"01KJ3RBAA4A3E2T0XAMCJTMAYA","ts":"2026-02-22T22:43:29.732Z","actor":"Aditya Sharma","type":"dep.added","task_id":"tsq-bd0a41d4","payload":{"blocker":"tsq-nhqse7rn","dep_type":"blocks"}}
+{"id":"01KJ3RCVDVE9CCZDS6P0TTY3RZ","event_id":"01KJ3RCVDVE9CCZDS6P0TTY3RZ","ts":"2026-02-22T22:44:20.027Z","actor":"Aditya Sharma","type":"task.claimed","task_id":"tsq-pmjtma8b","payload":{"assignee":"Aditya Sharma"}}
+{"id":"01KJ3RCVMDTWXF9K3E0RKX2RVF","event_id":"01KJ3RCVMDTWXF9K3E0RKX2RVF","ts":"2026-02-22T22:44:20.237Z","actor":"Aditya Sharma","type":"task.claimed","task_id":"tsq-7whgzsjy","payload":{"assignee":"Aditya Sharma"}}
+{"id":"01KJ3RCVVYX6GCVEF3XQ2PQHD2","event_id":"01KJ3RCVVYX6GCVEF3XQ2PQHD2","ts":"2026-02-22T22:44:20.478Z","actor":"Aditya Sharma","type":"task.claimed","task_id":"tsq-seytfm51","payload":{"assignee":"Aditya Sharma"}}
+{"id":"01KJ3SB4XVK1TEYRKGSWMFZSJ2","event_id":"01KJ3SB4XVK1TEYRKGSWMFZSJ2","ts":"2026-02-22T23:00:52.795Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-wmdq4gnv","payload":{"description":null,"discovered_from":null,"external_ref":null,"id":"tsq-wmdq4gnv","kind":"task","parent_id":null,"planning_state":"needs_planning","priority":2,"status":"open","title":"Consolidate theme sets with system mode"}}
+{"id":"01KJ3SCAPDS0B1KD45WTV4HQPX","event_id":"01KJ3SCAPDS0B1KD45WTV4HQPX","ts":"2026-02-22T23:01:31.469Z","actor":"Aditya Sharma","type":"task.spec_attached","task_id":"tsq-wmdq4gnv","payload":{"spec_attached_at":"2026-02-22T23:01:31.469Z","spec_attached_by":"Aditya Sharma","spec_fingerprint":"867c37fd3d507d7673a3822e54568031eb819d0efc36fefe83d48ee166d6d703","spec_path":".tasque/specs/tsq-wmdq4gnv/spec.md"}}
+{"id":"01KJ3SCASX9QR1PPSDY00T1FTG","event_id":"01KJ3SCASX9QR1PPSDY00T1FTG","ts":"2026-02-22T23:01:31.581Z","actor":"Aditya Sharma","type":"task.updated","task_id":"tsq-wmdq4gnv","payload":{"planning_state":"planned"}}
+{"id":"01KJ3SXSNE4N74MXGCN3SQ4MYG","event_id":"01KJ3SXSNE4N74MXGCN3SQ4MYG","ts":"2026-02-22T23:11:03.854Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-pmjtma8b","payload":{"closed_at":"2026-02-22T23:11:03.854Z","reason":"done","status":"closed"}}
+{"id":"01KJ3SXSWJNCB4X22PTKXYWVQW","event_id":"01KJ3SXSWJNCB4X22PTKXYWVQW","ts":"2026-02-22T23:11:04.082Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-7whgzsjy","payload":{"closed_at":"2026-02-22T23:11:04.082Z","reason":"done","status":"closed"}}
+{"id":"01KJ3SXT0PWF4J7E0G9NXBY0TG","event_id":"01KJ3SXT0PWF4J7E0G9NXBY0TG","ts":"2026-02-22T23:11:04.214Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-seytfm51","payload":{"closed_at":"2026-02-22T23:11:04.214Z","reason":"done","status":"closed"}}
+{"id":"01KJ3SXT59EQGQ27K2AGFMCGEE","event_id":"01KJ3SXT59EQGQ27K2AGFMCGEE","ts":"2026-02-22T23:11:04.361Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-nhqse7rn","payload":{"closed_at":"2026-02-22T23:11:04.361Z","reason":"done","status":"closed"}}
+{"id":"01KJ3SXTB7ENZRV2BTYWVMVJ9N","event_id":"01KJ3SXTB7ENZRV2BTYWVMVJ9N","ts":"2026-02-22T23:11:04.551Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-bd0a41d4","payload":{"closed_at":"2026-02-22T23:11:04.551Z","reason":"done","status":"closed"}}
+{"id":"01KJ40A9HYAR6X9QEVCVZNJ9TB","event_id":"01KJ40A9HYAR6X9QEVCVZNJ9TB","ts":"2026-02-23T01:02:44.798Z","actor":"Aditya Sharma","type":"task.created","task_id":"tsq-mw98vs2b","payload":{"description":null,"discovered_from":null,"external_ref":null,"id":"tsq-mw98vs2b","kind":"task","parent_id":null,"planning_state":"needs_planning","priority":2,"status":"open","title":"Add FloatingSidebar component"}}
+{"id":"01KJ40B49SCERD2XMPPHEHN7BK","event_id":"01KJ40B49SCERD2XMPPHEHN7BK","ts":"2026-02-23T01:03:12.185Z","actor":"Aditya Sharma","type":"task.updated","task_id":"tsq-mw98vs2b","payload":{"planning_state":"planned"}}
+{"id":"01KJ40B4DSMYXS5PD7A9ZB1TZ1","event_id":"01KJ40B4DSMYXS5PD7A9ZB1TZ1","ts":"2026-02-23T01:03:12.313Z","actor":"Aditya Sharma","type":"task.claimed","task_id":"tsq-mw98vs2b","payload":{"assignee":"Aditya Sharma"}}
+{"id":"01KJ40B4HA6Y5KXN8B0NJ6SJ6A","event_id":"01KJ40B4HA6Y5KXN8B0NJ6SJ6A","ts":"2026-02-23T01:03:12.426Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-mw98vs2b","payload":{"closed_at":null,"status":"in_progress"}}
+{"id":"01KJ429TGNR3K7QSGQZWNC6TDX","event_id":"01KJ429TGNR3K7QSGQZWNC6TDX","ts":"2026-02-23T01:37:26.549Z","actor":"Aditya Sharma","type":"task.status_set","task_id":"tsq-mw98vs2b","payload":{"closed_at":"2026-02-23T01:37:26.549Z","status":"closed"}}
diff --git a/AGENTS.md b/AGENTS.md
index 3e29294b..3370410c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,30 +2,7 @@
This project uses **tsq** (tasque) for task management. Use the tasque skill.
-## Landing the Plane (Session Completion)
-
-**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
-
-**MANDATORY WORKFLOW:**
-
-1. **File issues for remaining work** - Create issues for anything that needs follow-up
-2. **Run quality gates** (if code changed) - Tests, linters, builds
-3. **Update issue status** - Close finished work, update in-progress items
-4. **PUSH TO REMOTE** - This is MANDATORY:
- ```bash
- git pull --rebase
- git push
- git status # MUST show "up to date with origin"
- ```
-5. **Clean up** - Clear stashes, prune remote branches
-6. **Verify** - All changes committed AND pushed
-7. **Hand off** - Provide context for next session
-
-**CRITICAL RULES:**
-- Work is NOT complete until `git push` succeeds
-- NEVER stop before pushing - that leaves work stranded locally
-- NEVER say "ready to push when you are" - YOU must push
-- If push fails, resolve and retry until it succeeds
+**Platform parity** is critical. When implementing a feature, we must implement it across macOS, Windows, and Linux. If a feature is not supported on a platform, we must document the reason and any potential workarounds.
## Animation Guardrails (GPUI)
diff --git a/Cargo.lock b/Cargo.lock
index 32432f8f..ee7d0726 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -875,10 +875,11 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.32"
+version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
+ "find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -1962,6 +1963,12 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
[[package]]
name = "flate2"
version = "1.1.2"
@@ -2684,6 +2691,7 @@ dependencies = [
"smol",
"tracing",
"tree-sitter",
+ "tree-sitter-astro-next",
"tree-sitter-bash",
"tree-sitter-c",
"tree-sitter-c-sharp",
@@ -2700,6 +2708,8 @@ dependencies = [
"tree-sitter-javascript",
"tree-sitter-jsdoc",
"tree-sitter-json",
+ "tree-sitter-kotlin-sg",
+ "tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-md",
"tree-sitter-php",
@@ -2709,6 +2719,7 @@ dependencies = [
"tree-sitter-rust",
"tree-sitter-scala",
"tree-sitter-sequel",
+ "tree-sitter-svelte-next",
"tree-sitter-swift",
"tree-sitter-toml-ng",
"tree-sitter-typescript",
@@ -4555,7 +4566,7 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d"
dependencies = [
- "proc-macro-crate 1.3.1",
+ "proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
"syn 2.0.105",
@@ -7496,6 +7507,16 @@ dependencies = [
"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.25.1"
@@ -7656,11 +7677,31 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "tree-sitter-kotlin-sg"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0e175b7530765d1e36ad234a7acaa8b2a3316153f239d724376c7ee5e8d8e98"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
[[package]]
name = "tree-sitter-language"
-version = "0.1.5"
+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 = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8"
+checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
[[package]]
name = "tree-sitter-make"
@@ -7763,6 +7804,16 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "tree-sitter-svelte-next"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
[[package]]
name = "tree-sitter-swift"
version = "0.7.1"
diff --git a/README.md b/README.md
index d2dfdf7b..9f7517cc 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,13 @@
This is a fork of the original gpui-component by longbridge. Goal is to maintain compatibility with gpui while adding custom components as needed.
+## Upstream sync status
+
+- Manual snippet sync source: `longbridge/gpui-component@94fdac9b` (`upstream/main` as of 2026-02-22).
+- Imported upstream fixes/features in this sync batch:
+ - Editor syntax/perf: `d2f0960d`, `c045f74f`, `d60c6d5f`, `b4310a66`, `3724b071`
+ - Table selection/docs: `662e291a`, `24b552d8`, `545c3488`, `9fb44739`
+ - Targeted bugfixes: `94fdac9b`, `dc3850b8`, `3c10503b`, `c5fe81f1`
+
# GPUI Component
[](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml) [](https://docs.rs/gpui-component/) [](https://crates.io/crates/gpui-component)
diff --git a/crates/story/examples/fixtures/test.astro b/crates/story/examples/fixtures/test.astro
new file mode 100644
index 00000000..ff739d86
--- /dev/null
+++ b/crates/story/examples/fixtures/test.astro
@@ -0,0 +1,131 @@
+---
+// @ts-nocheck
+// This file demonstrates Astro 5 syntax highlighting features.
+// Frontmatter: TypeScript/JavaScript code that runs at build time
+
+import type { GetStaticPaths } from 'astro';
+
+interface Props {
+ title: string;
+ count?: number;
+}
+
+const { title, count = 0 } = Astro.props;
+const currentYear = new Date().getFullYear();
+
+const items = ['Astro', 'TypeScript', 'React', 'Vue'];
+const isProduction = import.meta.env.PROD;
+
+function formatGreeting(name: string): string {
+ return `Hello, ${name}!`;
+}
+
+const apiUrl = import.meta.env.PUBLIC_API_URL ?? 'https://api.example.com';
+---
+
+
+
+
+
+
+ {title} - Astro 5 Demo
+
+
+
+
+
{formatGreeting(title)}
+
+
Current year: {currentYear}
+
+ Environment: {isProduction ? 'Production' : 'Development'}
+
+
+ {
+ count > 0 && (
+
+
+ You have {count} {count === 1 ? 'item' : 'items'}
+
+
+ )
+ }
+
+
+ {
+ items.map((item, index) => (
+ -
+ {item}
+
+ ))
+ }
+
+
+
+ {items.length > 0 ? `Found ${items.length} technologies` : 'No technologies found'}
+
+
+
5 }]}
+ >
+
Interactive card with dynamic attributes
+
+
+
+
+
+
diff --git a/crates/story/examples/fixtures/test.kt b/crates/story/examples/fixtures/test.kt
new file mode 100644
index 00000000..4edf4f68
--- /dev/null
+++ b/crates/story/examples/fixtures/test.kt
@@ -0,0 +1,97 @@
+package hello
+
+import kotlin.math.roundToInt
+
+const val VERSION = "1.0.0"
+
+data class Config(
+ val timeout: Int = 5000,
+ val retries: Int = 3,
+ val debug: Boolean = false,
+)
+
+enum class LogLevel(val label: String) {
+ INFO("INFO"), WARN("WARN"), ERROR("ERROR");
+ companion object {
+ fun fromString(s: String): LogLevel? = entries.find { it.label == s }
+ }
+}
+
+sealed class Result {
+ data class Success(val data: T) : Result()
+ data class Error(val message: String) : Result()
+}
+
+interface Greetable {
+ fun greet(vararg names: String): List
+}
+
+class HelloWorld(private val name: String = "World") : Greetable {
+ private var config = Config()
+ private val createdAt = System.currentTimeMillis()
+
+ override fun greet(vararg names: String): List {
+ return names.map { "Hello, $it!" }.also { lines ->
+ if (config.debug) lines.forEach { println(" [debug] $it") }
+ }
+ }
+
+ fun configure(block: Config.() -> Config) {
+ config = config.block()
+ }
+
+ fun processNames(names: List?): List =
+ names?.filter { it.isNotBlank() }
+ ?.map { it.uppercase() }
+ ?.sorted()
+ ?: emptyList()
+
+ fun generateReport(): String {
+ val elapsed = ((System.currentTimeMillis() - createdAt) / 1000.0).roundToInt()
+ return """
+ |HelloWorld Report
+ |=================
+ |Name: $name
+ |Elapsed: ${elapsed}s
+ |Config: timeout=${config.timeout}, retries=${config.retries}
+ """.trimMargin()
+ }
+
+ override fun toString(): String = "HelloWorld(name=$name)"
+}
+
+fun safely(block: () -> T): Result = try {
+ Result.Success(block())
+} catch (e: Exception) {
+ Result.Error(e.message ?: "unknown error")
+}
+
+fun describe(obj: Any?): String = when (obj) {
+ null -> "null"
+ is String -> "String(${obj.length}): \"${obj.take(20)}\""
+ is Result.Success<*> -> "ok: ${obj.data}"
+ is Result.Error -> "err: ${obj.message}"
+ else -> obj::class.simpleName ?: "unknown"
+}
+
+fun main() {
+ val greeter = HelloWorld("Kotlin")
+
+ greeter.configure { copy(debug = true, retries = 5) }
+ greeter.greet("Alice", "Bob", "Charlie")
+
+ val processed = greeter.processNames(listOf("alice", "", "bob"))
+ println("Processed: $processed")
+
+ val result = safely { greeter.generateReport() }
+ when (result) {
+ is Result.Success -> println(result.data)
+ is Result.Error -> println("Failed: ${result.message}")
+ }
+
+ // Null safety & describe
+ val items: List = listOf("hello", 42, null, result)
+ items.forEach { println(" ${describe(it)}") }
+
+ println("Instances: $greeter (v$VERSION)")
+}
diff --git a/crates/story/examples/fixtures/test.lua b/crates/story/examples/fixtures/test.lua
new file mode 100644
index 00000000..8302b203
--- /dev/null
+++ b/crates/story/examples/fixtures/test.lua
@@ -0,0 +1,130 @@
+--[[
+ HelloWorld module demonstrating Lua syntax highlighting
+ Version: 1.0.0
+--]]
+
+local VERSION = "1.0.0"
+
+-- Module configuration with default values
+local Config = {
+ timeout = 5000,
+ retries = 3,
+ debug = false
+}
+
+-- Enum-like table for log levels
+local LogLevel = {
+ INFO = "INFO",
+ WARN = "WARN",
+ ERROR = "ERROR"
+}
+
+-- Create a class using metatables
+local HelloWorld = {}
+HelloWorld.__index = HelloWorld
+
+function HelloWorld.new(name)
+ local self = setmetatable({}, HelloWorld)
+ self.name = name or "World"
+ self.config = {timeout = 5000, retries = 3, debug = false}
+ self.createdAt = os.time()
+ self._greetCount = 0 -- private-like field
+ return self
+end
+
+-- Method using colon syntax (implicit self)
+function HelloWorld:greet(...)
+ local names = {...}
+ local results = {}
+
+ for _, name in ipairs(names) do
+ local greeting = string.format("Hello, %s!", name)
+ table.insert(results, greeting)
+ self._greetCount = self._greetCount + 1
+
+ if self.config.debug then
+ print(string.format(" [debug] %s", greeting))
+ end
+ end
+
+ return results
+end
+
+function HelloWorld:configure(newConfig)
+ for k, v in pairs(newConfig) do
+ self.config[k] = v
+ end
+end
+
+function HelloWorld:processNames(names)
+ local processed = {}
+
+ for _, name in ipairs(names or {}) do
+ if name ~= "" and name:match("%S") then
+ table.insert(processed, name:upper())
+ end
+ end
+
+ table.sort(processed)
+ return processed
+end
+
+function HelloWorld:generateReport()
+ local elapsed = os.time() - self.createdAt
+ local lines = {
+ "HelloWorld Report",
+ "=================",
+ string.format("Name: %s", self.name),
+ string.format("Elapsed: %ds", elapsed),
+ string.format("Greetings: %d", self._greetCount),
+ string.format("Config: timeout=%d, retries=%d",
+ self.config.timeout, self.config.retries)
+ }
+ return table.concat(lines, "\n")
+end
+
+-- Metatable for Result type (Success/Error)
+local function Success(data)
+ return {success = true, data = data}
+end
+
+local function Error(message)
+ return {success = false, message = message}
+end
+
+-- Protected call wrapper
+local function safely(fn)
+ local status, result = pcall(fn)
+ return status and Success(result) or Error(tostring(result))
+end
+
+-- Pattern matching-like function
+local function describe(obj)
+ if obj == nil then return "nil"
+ elseif type(obj) == "string" then
+ return string.format('String(%d): "%s"', #obj, obj:sub(1, 20))
+ elseif type(obj) == "table" and obj.success ~= nil then
+ return obj.success and ("ok: " .. tostring(obj.data))
+ or ("err: " .. obj.message)
+ else
+ return type(obj)
+ end
+end
+
+-- Main execution
+local greeter = HelloWorld.new("Lua")
+
+greeter:configure({debug = true, retries = 5})
+greeter:greet("Alice", "Bob", "Charlie")
+
+local processed = greeter:processNames({"alice", "", "bob", " charlie "})
+print("Processed: " .. table.concat(processed, ", "))
+
+local result = safely(function() return greeter:generateReport() end)
+if result.success then
+ print(result.data)
+else
+ print("Failed: " .. result.message)
+end
+
+print(string.format("\nVersion: %s", VERSION))
diff --git a/crates/story/examples/fixtures/test.svelte b/crates/story/examples/fixtures/test.svelte
new file mode 100644
index 00000000..97f071a7
--- /dev/null
+++ b/crates/story/examples/fixtures/test.svelte
@@ -0,0 +1,117 @@
+
+
+
+ Svelte 5 Features
+
+
+ Counter
+ Count: {state.count}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/story/src/app_menus.rs b/crates/story/src/app_menus.rs
index 3d08e4b2..00de7fc8 100644
--- a/crates/story/src/app_menus.rs
+++ b/crates/story/src/app_menus.rs
@@ -1,5 +1,7 @@
use gpui::{App, Entity, Menu, MenuItem, SharedString};
-use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry, menu::AppMenuBar};
+use gpui_component::{
+ ActiveTheme as _, Theme, ThemeModePreference, ThemeRegistry, menu::AppMenuBar,
+};
use crate::{
About, Open, Quit, SelectLocale, ToggleSearch,
@@ -33,7 +35,7 @@ pub fn init(title: impl Into, cx: &mut App) -> Entity
}
fn update_app_menu(title: impl Into, app_menu_bar: Entity, cx: &mut App) {
- let mode = cx.theme().mode;
+ let preference = cx.theme().mode_preference;
cx.set_menus(vec![
Menu {
name: title.into(),
@@ -45,10 +47,12 @@ fn update_app_menu(title: impl Into, app_menu_bar: Entity MenuItem {
}
fn theme_menu(cx: &App) -> MenuItem {
- let themes = ThemeRegistry::global(cx).sorted_themes();
- let current_name = cx.theme().theme_name();
+ let sets = ThemeRegistry::global(cx).sorted_theme_sets();
+ let current_set = &cx.theme().theme_set_name;
MenuItem::Submenu(Menu {
name: "Theme".into(),
- items: themes
+ items: sets
.iter()
- .map(|theme| {
- let checked = current_name == &theme.name;
- MenuItem::action(theme.name.clone(), SwitchTheme(theme.name.clone()))
- .checked(checked)
+ .map(|set| {
+ let checked = current_set == &set.name;
+ MenuItem::action(set.name.clone(), SwitchTheme(set.name.clone())).checked(checked)
})
.collect(),
})
diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs
index 3dbd4941..918e8033 100644
--- a/crates/story/src/lib.rs
+++ b/crates/story/src/lib.rs
@@ -474,6 +474,7 @@ impl StoryState {
"LabelStory" => story!(LabelStory),
"TooltipStory" => story!(TooltipStory),
"AccordionStory" => story!(AccordionStory),
+ "FloatingSidebarStory" => story!(FloatingSidebarStory),
"SidebarStory" => story!(SidebarStory),
"FormStory" => story!(FormStory),
"NotificationStory" => story!(NotificationStory),
diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs
index d0b31cdc..a450ab75 100644
--- a/crates/story/src/main.rs
+++ b/crates/story/src/main.rs
@@ -1,9 +1,8 @@
use gpui::{prelude::*, *};
use gpui_component::{
- ActiveTheme as _, Icon, IconName, h_flex,
+ ActiveTheme as _, FloatingSidebar, Icon, IconName, h_flex,
input::{Input, InputEvent, InputState},
- resizable::{h_resizable, resizable_panel},
- sidebar::{Sidebar, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem},
+ sidebar::{SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem},
v_flex,
};
use gpui_component_assets::Assets;
@@ -14,6 +13,7 @@ pub struct Gallery {
active_group_index: Option,
active_index: Option,
collapsed: bool,
+ sidebar_width: Pixels,
search_input: Entity,
_subscriptions: Vec,
}
@@ -78,6 +78,7 @@ impl Gallery {
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
+ StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
@@ -103,6 +104,7 @@ impl Gallery {
active_group_index: Some(0),
active_index: Some(0),
collapsed: false,
+ sidebar_width: px(255.0),
_subscriptions,
};
@@ -159,119 +161,131 @@ impl Render for Gallery {
("".into(), "".into())
};
- h_resizable("gallery-container")
+ let inset = px(8.0);
+ let collapsed_width = px(48.0);
+ let sidebar_width = if self.collapsed {
+ collapsed_width
+ } else {
+ self.sidebar_width
+ };
+ let content_offset = sidebar_width + inset;
+ let view = cx.entity().downgrade();
+
+ div()
+ .relative()
+ .size_full()
+ .overflow_hidden()
.child(
- resizable_panel()
- .size(px(255.))
- .size_range(px(200.)..px(320.))
- .child(
- Sidebar::new("gallery-sidebar")
- .w(relative(1.))
- .border_0()
- .collapsed(self.collapsed)
- .header_with({
- let search_input = self.search_input.clone();
- move |collapsed, _, cx| {
- v_flex()
+ FloatingSidebar::new("gallery-sidebar")
+ .width(self.sidebar_width)
+ .collapsed(self.collapsed)
+ .inset(inset)
+ .on_resize_end(move |width, _, cx| {
+ if let Some(view) = view.upgrade() {
+ view.update(cx, |this, cx| {
+ this.sidebar_width = width;
+ cx.notify();
+ });
+ }
+ })
+ .header_with({
+ let search_input = self.search_input.clone();
+ move |collapsed, _, cx| {
+ v_flex()
+ .w_full()
+ .gap_4()
+ .child(
+ SidebarHeader::new()
.w_full()
- .gap_4()
- .child(
- SidebarHeader::new()
- .w_full()
- .child(
- div()
- .flex()
- .items_center()
- .justify_center()
- .rounded(cx.theme().radius)
- .bg(cx.theme().primary)
- .text_color(cx.theme().primary_foreground)
- .size_8()
- .flex_shrink_0()
- .when(!collapsed, |this| {
- this.child(Icon::new(
- IconName::GalleryVerticalEnd,
- ))
- })
- .when(collapsed, |this| {
- this.size_4()
- .bg(cx.theme().transparent)
- .text_color(cx.theme().foreground)
- .child(Icon::new(
- IconName::GalleryVerticalEnd,
- ))
- })
- .rounded_lg(),
- )
- .when(!collapsed, |this| {
- this.child(
- v_flex()
- .gap_0()
- .text_sm()
- .flex_1()
- .line_height(relative(1.25))
- .overflow_hidden()
- .text_ellipsis()
- .child("GPUI Component")
- .child(
- div()
- .text_color(
- cx.theme().muted_foreground,
- )
- .child("Gallery")
- .text_xs(),
- ),
- )
- }),
- )
.child(
div()
- .bg(cx.theme().sidebar_accent)
- .rounded_full()
- .px_1()
- .when(cx.theme().radius.is_zero(), |this| {
- this.rounded(px(0.))
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(cx.theme().radius)
+ .bg(cx.theme().primary)
+ .text_color(cx.theme().primary_foreground)
+ .size_8()
+ .flex_shrink_0()
+ .when(!collapsed, |this| {
+ this.child(Icon::new(
+ IconName::GalleryVerticalEnd,
+ ))
+ })
+ .when(collapsed, |this| {
+ this.size_4()
+ .bg(cx.theme().transparent)
+ .text_color(cx.theme().foreground)
+ .child(Icon::new(
+ IconName::GalleryVerticalEnd,
+ ))
})
- .flex_1()
- .mx_1()
- .child(
- Input::new(&search_input)
- .appearance(false)
- .cleanable(true),
- ),
+ .rounded_lg(),
)
- }
- })
- .children(stories.clone().into_iter().enumerate().map(
- |(group_ix, (group_name, sub_stories))| {
- SidebarGroup::new(*group_name).child(
- SidebarMenu::new().children(
- sub_stories.iter().enumerate().map(|(ix, story)| {
- SidebarMenuItem::new(story.read(cx).name.clone())
- .active(
- self.active_group_index == Some(group_ix)
- && self.active_index == Some(ix),
- )
- .on_click(cx.listener(
- move |this, _: &ClickEvent, _, cx| {
- this.active_group_index =
- Some(group_ix);
- this.active_index = Some(ix);
- cx.notify();
- },
- ))
- }),
+ .when(!collapsed, |this| {
+ this.child(
+ v_flex()
+ .gap_0()
+ .text_sm()
+ .flex_1()
+ .line_height(relative(1.25))
+ .overflow_hidden()
+ .text_ellipsis()
+ .child("GPUI Component")
+ .child(
+ div()
+ .text_color(cx.theme().muted_foreground)
+ .child("Gallery")
+ .text_xs(),
+ ),
+ )
+ }),
+ )
+ .child(
+ div()
+ .bg(cx.theme().sidebar_accent)
+ .rounded_full()
+ .px_1()
+ .when(cx.theme().radius.is_zero(), |this| {
+ this.rounded(px(0.))
+ })
+ .flex_1()
+ .mx_1()
+ .child(
+ Input::new(&search_input)
+ .appearance(false)
+ .cleanable(true),
),
- )
- },
- )),
- ),
+ )
+ }
+ })
+ .children(stories.clone().into_iter().enumerate().map(
+ |(group_ix, (group_name, sub_stories))| {
+ SidebarGroup::new(*group_name).child(SidebarMenu::new().children(
+ sub_stories.iter().enumerate().map(|(ix, story)| {
+ SidebarMenuItem::new(story.read(cx).name.clone())
+ .active(
+ self.active_group_index == Some(group_ix)
+ && self.active_index == Some(ix),
+ )
+ .on_click(cx.listener(
+ move |this, _: &ClickEvent, _, cx| {
+ this.active_group_index = Some(group_ix);
+ this.active_index = Some(ix);
+ cx.notify();
+ },
+ ))
+ }),
+ ))
+ },
+ )),
)
.child(
v_flex()
.flex_1()
.h_full()
.overflow_x_hidden()
+ .pl(content_offset)
.child(
h_flex()
.id("header")
diff --git a/crates/story/src/stories/floating_sidebar_story.rs b/crates/story/src/stories/floating_sidebar_story.rs
new file mode 100644
index 00000000..58c5c5c6
--- /dev/null
+++ b/crates/story/src/stories/floating_sidebar_story.rs
@@ -0,0 +1,175 @@
+use gpui::{
+ App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render,
+ Styled, Window, div, prelude::FluentBuilder as _, px,
+};
+
+use gpui_component::{
+ ActiveTheme, FloatingSidebar, Icon, IconName, Side, h_flex,
+ sidebar::{SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem},
+ switch::Switch,
+ v_flex,
+};
+
+use crate::section;
+
+pub struct FloatingSidebarStory {
+ focus_handle: FocusHandle,
+ collapsed: bool,
+ side: Side,
+ wide_inset: bool,
+}
+
+impl FloatingSidebarStory {
+ fn new(_: &mut Window, cx: &mut Context) -> Self {
+ Self {
+ focus_handle: cx.focus_handle(),
+ collapsed: false,
+ side: Side::Left,
+ wide_inset: false,
+ }
+ }
+
+ pub fn view(window: &mut Window, cx: &mut App) -> Entity {
+ cx.new(|cx| Self::new(window, cx))
+ }
+
+ fn render_example(
+ &self,
+ id: &'static str,
+ inset: gpui::Pixels,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ let sidebar_width = px(240.0);
+ let collapsed_width = px(48.0);
+ let content_offset = if self.collapsed {
+ collapsed_width
+ } else {
+ sidebar_width
+ } + inset;
+
+ let sidebar = FloatingSidebar::new(id)
+ .side(self.side)
+ .collapsed(self.collapsed)
+ .width(sidebar_width)
+ .inset(inset)
+ .header_with(|collapsed, _, _cx| {
+ SidebarHeader::new()
+ .child(Icon::new(IconName::PanelLeft).size_4())
+ .when(!collapsed, |this| this.child("Workspace"))
+ .when(!collapsed, |this| {
+ this.child(Icon::new(IconName::ChevronsUpDown).size_4())
+ })
+ })
+ .child(
+ SidebarGroup::new("Navigation").child(SidebarMenu::new().children([
+ SidebarMenuItem::new("Overview").icon(IconName::LayoutDashboard),
+ SidebarMenuItem::new("Projects").icon(IconName::Folder),
+ SidebarMenuItem::new("Settings").icon(IconName::Settings2),
+ ])),
+ )
+ .footer_with(|collapsed, _, _| {
+ SidebarFooter::new()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(Icon::new(IconName::CircleUser))
+ .when(!collapsed, |this| this.child("Avery")),
+ )
+ .when(!collapsed, |this| {
+ this.child(Icon::new(IconName::ChevronsUpDown).size_4())
+ })
+ });
+
+ div()
+ .relative()
+ .w(px(640.0))
+ .h(px(360.0))
+ .overflow_hidden()
+ .rounded(cx.theme().radius_lg)
+ .border_1()
+ .border_color(cx.theme().border)
+ .child(sidebar)
+ .child(
+ div()
+ .size_full()
+ .pt(px(16.0))
+ .when(self.side.is_left(), |this| this.pl(content_offset))
+ .when(self.side.is_right(), |this| this.pr(content_offset))
+ .child(
+ div()
+ .rounded(cx.theme().radius)
+ .bg(cx.theme().secondary)
+ .p_4()
+ .text_sm()
+ .text_color(cx.theme().secondary_foreground)
+ .child("Main content area"),
+ ),
+ )
+ }
+}
+
+impl super::Story for FloatingSidebarStory {
+ fn title() -> &'static str {
+ "Floating Sidebar"
+ }
+
+ fn description() -> &'static str {
+ "FloatingSidebar combines SidebarShell and Sidebar with built-in resize handling."
+ }
+
+ fn new_view(window: &mut Window, cx: &mut App) -> Entity {
+ Self::view(window, cx)
+ }
+}
+
+impl Focusable for FloatingSidebarStory {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for FloatingSidebarStory {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let inset = if self.wide_inset { px(12.0) } else { px(8.0) };
+
+ v_flex().gap_6().child(
+ section("Floating Sidebar").child(
+ v_flex()
+ .gap_4()
+ .child(
+ h_flex()
+ .gap_3()
+ .child(
+ Switch::new("floating-sidebar-collapsed")
+ .label("Collapsed")
+ .checked(self.collapsed)
+ .on_click(cx.listener(|this, checked: &bool, _, cx| {
+ this.collapsed = *checked;
+ cx.notify();
+ })),
+ )
+ .child(
+ Switch::new("floating-sidebar-side")
+ .label("Right Side")
+ .checked(self.side.is_right())
+ .on_click(cx.listener(|this, checked: &bool, _, cx| {
+ this.side = if *checked { Side::Right } else { Side::Left };
+ cx.notify();
+ })),
+ )
+ .child(
+ Switch::new("floating-sidebar-inset")
+ .label("Wide Inset")
+ .checked(self.wide_inset)
+ .on_click(cx.listener(|this, checked: &bool, _, cx| {
+ this.wide_inset = *checked;
+ cx.notify();
+ })),
+ ),
+ )
+ .child(self.render_example("floating-sidebar-example", inset, window, cx)),
+ ),
+ )
+ }
+}
diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs
index 4912949e..a4fd67c4 100644
--- a/crates/story/src/stories/menu_story.rs
+++ b/crates/story/src/stories/menu_story.rs
@@ -341,6 +341,10 @@ impl Render for MenuStory {
.max_h(px(300.))
.label(format!("Total {} items", 100));
for i in 0..100 {
+ if i > 0 && i % 5 == 0 {
+ this = this.separator();
+ }
+
this = this.menu(
SharedString::from(format!("Item {}", i)),
Box::new(Info(i)),
diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs
index 9d3dc3e5..84bdf49f 100644
--- a/crates/story/src/stories/mod.rs
+++ b/crates/story/src/stories/mod.rs
@@ -19,6 +19,7 @@ mod description_list_story;
mod dialog_story;
mod divider_story;
mod dropdown_button_story;
+mod floating_sidebar_story;
mod form_story;
mod group_box_story;
mod hover_card_story;
@@ -77,6 +78,7 @@ pub use description_list_story::DescriptionListStory;
pub use dialog_story::DialogStory;
pub use divider_story::DividerStory;
pub use dropdown_button_story::DropdownButtonStory;
+pub use floating_sidebar_story::FloatingSidebarStory;
pub use form_story::FormStory;
pub use group_box_story::GroupBoxStory;
pub use hover_card_story::HoverCardStory;
diff --git a/crates/story/src/stories/settings_story.rs b/crates/story/src/stories/settings_story.rs
index d2af58fc..d18c9e13 100644
--- a/crates/story/src/stories/settings_story.rs
+++ b/crates/story/src/stories/settings_story.rs
@@ -4,7 +4,7 @@ use gpui::{
};
use gpui_component::{
- ActiveTheme, Icon, IconName, Sizable, Size, Theme, ThemeMode,
+ ActiveTheme, Icon, IconName, Sizable, Size, Theme, ThemeModePreference, ThemeRegistry,
button::Button,
group_box::GroupBoxVariant,
h_flex,
@@ -18,7 +18,6 @@ use gpui_component::{
};
struct AppSettings {
- auto_switch_theme: bool,
cli_path: SharedString,
font_family: SharedString,
font_size: f64,
@@ -31,7 +30,6 @@ struct AppSettings {
impl Default for AppSettings {
fn default() -> Self {
Self {
- auto_switch_theme: false,
cli_path: "/usr/local/bin/bash".into(),
font_family: "Arial".into(),
font_size: 14.0,
@@ -134,33 +132,61 @@ impl SettingsStory {
.groups(vec![
SettingGroup::new().title("Appearance").items(vec![
SettingItem::new(
- "Dark Mode",
- SettingField::switch(
- |cx: &App| cx.theme().mode.is_dark(),
- |val: bool, cx: &mut App| {
- let mode = if val {
- ThemeMode::Dark
- } else {
- ThemeMode::Light
- };
- Theme::global_mut(cx).mode = mode;
- Theme::change(mode, None, cx);
+ "Color Theme",
+ SettingField::dropdown(
+ {
+ let sets = ThemeRegistry::global(cx).sorted_theme_sets();
+ sets.iter()
+ .map(|s| (s.name.clone(), s.name.clone()))
+ .collect()
+ },
+ |cx: &App| cx.theme().theme_set_name.clone(),
+ |val: SharedString, cx: &mut App| {
+ let set =
+ ThemeRegistry::global(cx).theme_sets().get(&val).cloned();
+ if let Some(set) = set {
+ let preference = Theme::global(cx).mode_preference;
+ Theme::apply_theme_set(&set, preference, None, cx);
+ }
},
)
- .default_value(false),
+ .default_value("Default"),
)
- .description("Switch between light and dark themes."),
+ .description("Select the color theme for the application."),
SettingItem::new(
- "Auto Switch Theme",
- SettingField::checkbox(
- |cx: &App| AppSettings::global(cx).auto_switch_theme,
- |val: bool, cx: &mut App| {
- AppSettings::global_mut(cx).auto_switch_theme = val;
+ "Mode",
+ SettingField::dropdown(
+ vec![
+ ("system".into(), "System".into()),
+ ("light".into(), "Light".into()),
+ ("dark".into(), "Dark".into()),
+ ],
+ |cx: &App| {
+ SharedString::from(match cx.theme().mode_preference {
+ ThemeModePreference::System => "system",
+ ThemeModePreference::Light => "light",
+ ThemeModePreference::Dark => "dark",
+ })
+ },
+ |val: SharedString, cx: &mut App| {
+ let preference = match val.as_ref() {
+ "light" => ThemeModePreference::Light,
+ "dark" => ThemeModePreference::Dark,
+ _ => ThemeModePreference::System,
+ };
+ let set_name = Theme::global(cx).theme_set_name.clone();
+ let set = ThemeRegistry::global(cx)
+ .theme_sets()
+ .get(&set_name)
+ .cloned();
+ if let Some(set) = set {
+ Theme::apply_theme_set(&set, preference, None, cx);
+ }
},
)
- .default_value(default_settings.auto_switch_theme),
+ .default_value("system"),
)
- .description("Automatically switch theme based on system settings."),
+ .description("Choose System to follow OS appearance, or force Light/Dark."),
SettingItem::new(
"resettable",
SettingField::switch(
diff --git a/crates/story/src/stories/table_story.rs b/crates/story/src/stories/table_story.rs
index 765d0cf1..23f274ad 100644
--- a/crates/story/src/stories/table_story.rs
+++ b/crates/story/src/stories/table_story.rs
@@ -888,10 +888,20 @@ impl TableStory {
TableEvent::SelectColumn(ix) => println!("Select col: {}", ix),
TableEvent::DoubleClickedRow(ix) => println!("Double clicked row: {}", ix),
TableEvent::SelectRow(ix) => println!("Select row: {}", ix),
+ TableEvent::SelectCell(row_ix, col_ix) => {
+ println!("Select cell: row={}, col={}", row_ix, col_ix)
+ }
+ TableEvent::DoubleClickedCell(row_ix, col_ix) => {
+ println!("Double clicked cell: row={}, col={}", row_ix, col_ix)
+ }
+ TableEvent::RightClickedCell(row_ix, col_ix) => {
+ println!("Right clicked cell: row={}, col={}", row_ix, col_ix)
+ }
TableEvent::MoveColumn(origin_idx, target_idx) => {
println!("Move col index: {} -> {}", origin_idx, target_idx);
}
TableEvent::RightClickedRow(ix) => println!("Right clicked row: {:?}", ix),
+ TableEvent::ClearSelection => println!("Clear selection"),
}
}
diff --git a/crates/story/src/themes.rs b/crates/story/src/themes.rs
index bd41ab6b..199963e3 100644
--- a/crates/story/src/themes.rs
+++ b/crates/story/src/themes.rs
@@ -1,21 +1,25 @@
use std::path::PathBuf;
use gpui::{Action, App, SharedString};
-use gpui_component::{ActiveTheme, Theme, ThemeMode, ThemeRegistry, scroll::ScrollbarShow};
+use gpui_component::{
+ ActiveTheme, Theme, ThemeModePreference, ThemeRegistry, scroll::ScrollbarShow,
+};
use serde::{Deserialize, Serialize};
const STATE_FILE: &str = "target/state.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct State {
- theme: SharedString,
+ theme_set: SharedString,
+ mode_preference: ThemeModePreference,
scrollbar_show: Option,
}
impl Default for State {
fn default() -> Self {
Self {
- theme: "Default Light".into(),
+ theme_set: "Default".into(),
+ mode_preference: ThemeModePreference::System,
scrollbar_show: None,
}
}
@@ -27,12 +31,12 @@ pub fn init(cx: &mut App) {
tracing::info!("Load themes...");
let state = serde_json::from_str::(&json).unwrap_or_default();
if let Err(err) = ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| {
- if let Some(theme) = ThemeRegistry::global(cx)
- .themes()
- .get(&state.theme)
+ if let Some(set) = ThemeRegistry::global(cx)
+ .theme_sets()
+ .get(&state.theme_set)
.cloned()
{
- Theme::global_mut(cx).apply_config(&theme);
+ Theme::apply_theme_set(&set, state.mode_preference, None, cx);
}
}) {
tracing::error!("Failed to watch themes directory: {}", err);
@@ -45,7 +49,8 @@ pub fn init(cx: &mut App) {
cx.observe_global::(|cx| {
let state = State {
- theme: cx.theme().theme_name().clone(),
+ theme_set: cx.theme().theme_set_name.clone(),
+ mode_preference: cx.theme().mode_preference,
scrollbar_show: Some(cx.theme().scrollbar_show),
};
@@ -57,15 +62,27 @@ pub fn init(cx: &mut App) {
.detach();
cx.on_action(|switch: &SwitchTheme, cx| {
- let theme_name = switch.0.clone();
- if let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() {
- Theme::global_mut(cx).apply_config(&theme_config);
+ let set_name = switch.0.clone();
+ if let Some(set) = ThemeRegistry::global(cx)
+ .theme_sets()
+ .get(&set_name)
+ .cloned()
+ {
+ let preference = Theme::global(cx).mode_preference;
+ Theme::apply_theme_set(&set, preference, None, cx);
}
cx.refresh_windows();
});
cx.on_action(|switch: &SwitchThemeMode, cx| {
- let mode = switch.0;
- Theme::change(mode, None, cx);
+ let preference = switch.0;
+ let set_name = Theme::global(cx).theme_set_name.clone();
+ if let Some(set) = ThemeRegistry::global(cx)
+ .theme_sets()
+ .get(&set_name)
+ .cloned()
+ {
+ Theme::apply_theme_set(&set, preference, None, cx);
+ }
cx.refresh_windows();
});
}
@@ -76,4 +93,4 @@ pub(crate) struct SwitchTheme(pub(crate) SharedString);
#[derive(Action, Clone, PartialEq)]
#[action(namespace = themes, no_json)]
-pub(crate) struct SwitchThemeMode(pub(crate) ThemeMode);
+pub(crate) struct SwitchThemeMode(pub(crate) ThemeModePreference);
diff --git a/crates/story/src/title_bar.rs b/crates/story/src/title_bar.rs
index 1eaae70c..51a6bde2 100644
--- a/crates/story/src/title_bar.rs
+++ b/crates/story/src/title_bar.rs
@@ -68,7 +68,7 @@ impl Render for AppTitleBar {
.child((self.child.clone())(window, cx))
.child(
Label::new("theme:")
- .secondary(cx.theme().theme_name())
+ .secondary(cx.theme().theme_set_name.clone())
.text_sm(),
)
.child(self.font_size_selector.clone())
diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml
index fda593ac..e2cc7193 100644
--- a/crates/ui/Cargo.toml
+++ b/crates/ui/Cargo.toml
@@ -20,6 +20,7 @@ inspector = ["gpui-macros/inspector", "gpui/inspector"]
# For syntax highlighting in Markdown and CodeEditor.
tree-sitter-languages = [
+ "dep:tree-sitter-astro-next",
"dep:tree-sitter-bash",
"dep:tree-sitter-c",
"dep:tree-sitter-c-sharp",
@@ -35,6 +36,8 @@ tree-sitter-languages = [
"dep:tree-sitter-java",
"dep:tree-sitter-javascript",
"dep:tree-sitter-jsdoc",
+ "dep:tree-sitter-kotlin-sg",
+ "dep:tree-sitter-lua",
"dep:tree-sitter-make",
"dep:tree-sitter-md",
"dep:tree-sitter-php",
@@ -46,6 +49,7 @@ tree-sitter-languages = [
"dep:tree-sitter-sequel",
"dep:tree-sitter-swift",
"dep:tree-sitter-toml-ng",
+ "dep:tree-sitter-svelte-next",
"dep:tree-sitter-typescript",
"dep:tree-sitter-yaml",
"dep:tree-sitter-zig",
@@ -97,6 +101,7 @@ chrono = "0.4.38"
aho-corasick = "1.1.3"
lsp-types.workspace = true
tree-sitter = "0.25.4"
+tree-sitter-astro-next = { version = "0.1.1", optional = true }
tree-sitter-bash = { version = "0.25.1", optional = true }
tree-sitter-c = { version = "0.24.1", optional = true }
tree-sitter-c-sharp = { version = "0.23.1", optional = true }
@@ -113,6 +118,8 @@ tree-sitter-java = { version = "0.23.5", optional = true }
tree-sitter-javascript = { version = "0.23.1", optional = true }
tree-sitter-jsdoc = { version = "0.25.0", optional = true }
tree-sitter-json = "0.24.8"
+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 }
@@ -123,6 +130,7 @@ tree-sitter-rust = { version = "0.24.0", optional = true }
tree-sitter-scala = { version = "0.24.0", optional = true }
tree-sitter-sequel = { version = "0.3.8", optional = true }
tree-sitter-swift = { version = "0.7.0", optional = true }
+tree-sitter-svelte-next = { version = "0.1.1", 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 }
diff --git a/crates/ui/src/accordion.rs b/crates/ui/src/accordion.rs
index e92fd1b7..cd4784ee 100644
--- a/crates/ui/src/accordion.rs
+++ b/crates/ui/src/accordion.rs
@@ -254,7 +254,7 @@ impl RenderOnce for AccordionItem {
let spring_preset = SpringPreset::Mild;
let layout_anim = point_to_point_animation(&motion, reduced_motion);
let close_anim = layout_anim.clone();
- let open_layout_anim = layout_anim.clone();
+ let open_layout_anim = layout_anim;
let open_transform_anim = spring_preset_animation(&motion, reduced_motion, spring_preset);
let chevron_open_anim = spring_preset_animation(&motion, reduced_motion, spring_preset);
let chevron_close_anim = spring_preset_animation(&motion, reduced_motion, spring_preset);
diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs
index c1b3cc7f..9e4379cf 100644
--- a/crates/ui/src/animation.rs
+++ b/crates/ui/src/animation.rs
@@ -226,6 +226,7 @@ pub struct PresenceOptions {
/// - `target_open=true` moves to Entering/Entered
/// - `target_open=false` moves to Exiting/Exited
/// - stale async timers are ignored via generation guard
+#[allow(clippy::too_many_arguments)]
pub fn keyed_presence(
key_base: SharedString,
target_open: bool,
diff --git a/crates/ui/src/dialog.rs b/crates/ui/src/dialog.rs
index a79eec96..b3f7e3b2 100644
--- a/crates/ui/src/dialog.rs
+++ b/crates/ui/src/dialog.rs
@@ -14,8 +14,8 @@ use crate::{
actions::{Cancel, Confirm},
animation::{
PresenceOptions, PresencePhase, SpringPreset, fade_animation, fast_invoke_animation,
- keyed_presence, point_to_point_animation, soft_dismiss_animation,
- spring_preset_animation, spring_preset_duration_ms,
+ keyed_presence, point_to_point_animation, soft_dismiss_animation, spring_preset_animation,
+ spring_preset_duration_ms,
},
button::{Button, ButtonVariant, ButtonVariants as _},
global_state::GlobalState,
@@ -482,8 +482,8 @@ impl RenderOnce for Dialog {
});
let open_panel_transform_animation =
spring_preset_animation(motion, reduced_motion, SpringPreset::Medium);
- let close_panel_animation = soft_dismiss_animation(motion, reduced_motion)
- .unwrap_or_else(|| {
+ let close_panel_animation =
+ soft_dismiss_animation(motion, reduced_motion).unwrap_or_else(|| {
gpui::Animation::new(std::time::Duration::from_millis(u64::from(
motion.soft_dismiss_duration_ms,
)))
diff --git a/crates/ui/src/floating_sidebar/mod.rs b/crates/ui/src/floating_sidebar/mod.rs
new file mode 100644
index 00000000..7777ad39
--- /dev/null
+++ b/crates/ui/src/floating_sidebar/mod.rs
@@ -0,0 +1,528 @@
+//! FloatingSidebar combines SidebarShell and Sidebar with internal resize handling.
+
+use std::rc::Rc;
+
+use gpui::{
+ App, Element, ElementId, Hsla, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement,
+ Pixels, RenderOnce, SharedString, Style, StyleRefinement, Styled, Window, px,
+};
+
+use crate::{
+ ElevationToken, Side, StyledExt,
+ sidebar::{COLLAPSED_WIDTH, DEFAULT_WIDTH, Sidebar, SidebarItem},
+ sidebar_shell::SidebarShell,
+};
+
+/// Default values for floating sidebar configuration.
+const DEFAULT_MIN_WIDTH: Pixels = px(200.0);
+const DEFAULT_MAX_WIDTH: Pixels = px(400.0);
+const DEFAULT_RESIZER_WIDTH: Pixels = px(6.0);
+const DEFAULT_INSET: Pixels = px(8.0);
+
+#[derive(Clone)]
+struct FloatingSidebarState {
+ expanded_width: Pixels,
+ resizing: bool,
+ drag_origin_x: Pixels,
+ drag_origin_width: Pixels,
+}
+
+impl FloatingSidebarState {
+ fn new(width: Pixels) -> Self {
+ Self {
+ expanded_width: width,
+ resizing: false,
+ drag_origin_x: px(0.0),
+ drag_origin_width: width,
+ }
+ }
+}
+
+/// A floating sidebar that composes SidebarShell and Sidebar with internal resize handling.
+#[derive(IntoElement)]
+pub struct FloatingSidebar {
+ id: ElementId,
+ sidebar: Sidebar,
+ style: StyleRefinement,
+ side: Side,
+ collapsed: bool,
+ width: Pixels,
+ min_width: Pixels,
+ max_width: Pixels,
+ resizer_width: Pixels,
+ resizer_hover_bg: Option,
+ inset: Option,
+ top_inset: Pixels,
+ blur_enabled: Option,
+ elevation: ElevationToken,
+ on_resize_end: Option>,
+}
+
+impl FloatingSidebar {
+ /// Create a new FloatingSidebar with the given ID.
+ pub fn new(id: impl Into) -> Self {
+ let id = id.into();
+ Self {
+ id: id.clone(),
+ sidebar: Sidebar::new(id),
+ style: StyleRefinement::default(),
+ side: Side::Left,
+ collapsed: false,
+ width: DEFAULT_WIDTH,
+ min_width: DEFAULT_MIN_WIDTH,
+ max_width: DEFAULT_MAX_WIDTH,
+ resizer_width: DEFAULT_RESIZER_WIDTH,
+ resizer_hover_bg: None,
+ inset: Some(DEFAULT_INSET),
+ top_inset: px(0.0),
+ blur_enabled: None,
+ elevation: ElevationToken::Lg,
+ on_resize_end: None,
+ }
+ }
+
+ /// Set the side of the floating sidebar.
+ ///
+ /// Default is `Side::Left`.
+ pub fn side(mut self, side: Side) -> Self {
+ self.side = side;
+ self
+ }
+
+ /// Set the sidebar to be collapsible, default is true.
+ pub fn collapsible(mut self, collapsible: bool) -> Self {
+ self.sidebar = self.sidebar.collapsible(collapsible);
+ self
+ }
+
+ /// Set the sidebar to be collapsed.
+ pub fn collapsed(mut self, collapsed: bool) -> Self {
+ self.collapsed = collapsed;
+ self
+ }
+
+ /// Set the expanded width of the floating sidebar.
+ ///
+ /// The value is clamped to the current `[min_width, max_width]` constraints.
+ pub fn width(mut self, width: impl Into) -> Self {
+ self.width = width.into().max(self.min_width).min(self.max_width);
+ self
+ }
+
+ /// Set the minimum width constraint for resizing.
+ pub fn min_width(mut self, width: impl Into) -> Self {
+ self.min_width = width.into();
+ if self.min_width > self.max_width {
+ self.max_width = self.min_width;
+ }
+ self.width = self.width.max(self.min_width).min(self.max_width);
+ self
+ }
+
+ /// Set the maximum width constraint for resizing.
+ pub fn max_width(mut self, width: impl Into) -> Self {
+ self.max_width = width.into();
+ if self.max_width < self.min_width {
+ self.min_width = self.max_width;
+ }
+ self.width = self.width.max(self.min_width).min(self.max_width);
+ self
+ }
+
+ /// Set the width of the resize handle.
+ pub fn resizer_width(mut self, width: impl Into) -> Self {
+ self.resizer_width = width.into();
+ self
+ }
+
+ /// Set the hover background color for the resize handle.
+ pub fn resizer_hover_bg(mut self, color: impl Into) -> Self {
+ self.resizer_hover_bg = Some(color.into());
+ self
+ }
+
+ /// Set the inset from window edges.
+ ///
+ /// Default is 8px.
+ pub fn inset(mut self, inset: impl Into) -> Self {
+ self.inset = Some(inset.into());
+ self
+ }
+
+ /// Set additional top inset on top of the base inset.
+ pub fn top_inset(mut self, inset: impl Into) -> Self {
+ self.top_inset = inset.into();
+ self
+ }
+
+ /// Explicitly set whether blur effects are enabled for the glass surface.
+ pub fn blur_enabled(mut self, enabled: bool) -> Self {
+ self.blur_enabled = Some(enabled);
+ self
+ }
+
+ /// Set the shadow elevation level for the sidebar panel.
+ pub fn elevation(mut self, elevation: ElevationToken) -> Self {
+ self.elevation = elevation;
+ self
+ }
+
+ /// Set a callback invoked when resizing ends with the final width.
+ pub fn on_resize_end(
+ mut self,
+ callback: impl Fn(Pixels, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_resize_end = Some(Rc::new(callback));
+ self
+ }
+
+ /// Set the header of the sidebar.
+ pub fn header(mut self, header: impl IntoElement) -> Self {
+ self.sidebar = self.sidebar.header(header);
+ self
+ }
+
+ /// Set a dynamic header that receives the visual collapsed state.
+ pub fn header_with(mut self, builder: F) -> Self
+ where
+ F: Fn(bool, &mut Window, &mut App) -> H + 'static,
+ H: IntoElement,
+ {
+ self.sidebar = self.sidebar.header_with(builder);
+ self
+ }
+
+ /// Set the footer of the sidebar.
+ pub fn footer(mut self, footer: impl IntoElement) -> Self {
+ self.sidebar = self.sidebar.footer(footer);
+ self
+ }
+
+ /// Set a dynamic footer that receives the visual collapsed state.
+ pub fn footer_with(mut self, builder: F) -> Self
+ where
+ F: Fn(bool, &mut Window, &mut App) -> H + 'static,
+ H: IntoElement,
+ {
+ self.sidebar = self.sidebar.footer_with(builder);
+ self
+ }
+
+ /// Add a child element to the sidebar, the child must implement `SidebarItem`.
+ pub fn child(mut self, child: E) -> Self {
+ self.sidebar = self.sidebar.child(child);
+ self
+ }
+
+ /// Add multiple children to the sidebar, the children must implement `SidebarItem`.
+ pub fn children(mut self, children: impl IntoIterator- ) -> Self {
+ self.sidebar = self.sidebar.children(children);
+ self
+ }
+}
+
+impl Styled for FloatingSidebar {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style
+ }
+}
+
+impl RenderOnce for FloatingSidebar {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let FloatingSidebar {
+ id,
+ sidebar,
+ style,
+ side,
+ collapsed,
+ width,
+ min_width,
+ max_width,
+ resizer_width,
+ resizer_hover_bg,
+ inset,
+ top_inset,
+ blur_enabled,
+ elevation,
+ on_resize_end,
+ } = self;
+
+ let initial_width = width.max(min_width).min(max_width);
+ let state_key = SharedString::from(format!("{}-floating-sidebar-state", id));
+ let state = window.use_keyed_state(state_key, cx, |_, _| {
+ FloatingSidebarState::new(initial_width)
+ });
+
+ let expanded_width = {
+ let current_width = state.read(cx).expanded_width;
+ let clamped_width = current_width.max(min_width).min(max_width);
+ if clamped_width != current_width {
+ state.update(cx, |state, cx| {
+ state.expanded_width = clamped_width;
+ cx.notify();
+ });
+ }
+ clamped_width
+ };
+
+ let shell_width = if collapsed {
+ COLLAPSED_WIDTH
+ } else {
+ expanded_width
+ };
+ let resizer_width = if collapsed { px(0.0) } else { resizer_width };
+
+ let end_resize = {
+ let state = state.clone();
+ let on_resize_end = on_resize_end.clone();
+ Rc::new(move |window: &mut Window, cx: &mut App| {
+ let is_resizing = state.read(cx).resizing;
+ if !is_resizing {
+ return;
+ }
+ let width = state.read(cx).expanded_width;
+ state.update(cx, |state, cx| {
+ state.resizing = false;
+ cx.notify();
+ });
+ if let Some(callback) = on_resize_end.as_ref() {
+ callback(width, window, cx);
+ }
+ })
+ };
+
+ let on_resize_start = {
+ let state = state.clone();
+ move |width: Pixels, x: Pixels, _window: &mut Window, cx: &mut App| {
+ if collapsed {
+ return;
+ }
+ let width = width.max(min_width).min(max_width);
+ state.update(cx, |state, cx| {
+ state.expanded_width = width;
+ state.drag_origin_width = width;
+ state.drag_origin_x = x;
+ state.resizing = true;
+ cx.notify();
+ });
+ }
+ };
+
+ let resize_tracker = FloatingSidebarResizeTracker {
+ state: state.clone(),
+ side,
+ min_width,
+ max_width,
+ collapsed,
+ end_resize: end_resize.clone(),
+ };
+
+ let mut shell = if side.is_left() {
+ SidebarShell::left(shell_width)
+ } else {
+ SidebarShell::right(shell_width)
+ };
+
+ shell = shell
+ .min_width(min_width)
+ .max_width(max_width)
+ .resizer_width(resizer_width)
+ .top_inset(top_inset)
+ .elevation(elevation)
+ .on_resize_start(on_resize_start)
+ .on_resize_end(move |window, cx| {
+ end_resize(window, cx);
+ });
+
+ if let Some(color) = resizer_hover_bg {
+ shell = shell.resizer_hover_bg(color);
+ }
+ if let Some(inset) = inset {
+ shell = shell.inset(inset);
+ }
+ if let Some(blur_enabled) = blur_enabled {
+ shell = shell.blur_enabled(blur_enabled);
+ }
+
+ shell
+ .child(
+ sidebar
+ .side(side)
+ .collapsed(collapsed)
+ .width(expanded_width)
+ .animate_width(false)
+ .refine_style(&style),
+ )
+ .child(resize_tracker)
+ }
+}
+
+struct FloatingSidebarResizeTracker {
+ state: gpui::Entity,
+ side: Side,
+ min_width: Pixels,
+ max_width: Pixels,
+ collapsed: bool,
+ end_resize: Rc,
+}
+
+impl IntoElement for FloatingSidebarResizeTracker {
+ type Element = Self;
+
+ fn into_element(self) -> Self::Element {
+ self
+ }
+}
+
+impl Element for FloatingSidebarResizeTracker {
+ type RequestLayoutState = ();
+ type PrepaintState = ();
+
+ fn id(&self) -> Option {
+ None
+ }
+
+ fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _: Option<&gpui::GlobalElementId>,
+ _: Option<&gpui::InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (gpui::LayoutId, Self::RequestLayoutState) {
+ (window.request_layout(Style::default(), None, cx), ())
+ }
+
+ fn prepaint(
+ &mut self,
+ _: Option<&gpui::GlobalElementId>,
+ _: Option<&gpui::InspectorElementId>,
+ _: gpui::Bounds,
+ _: &mut Self::RequestLayoutState,
+ _window: &mut Window,
+ _cx: &mut App,
+ ) -> Self::PrepaintState {
+ ()
+ }
+
+ fn paint(
+ &mut self,
+ _: Option<&gpui::GlobalElementId>,
+ _: Option<&gpui::InspectorElementId>,
+ _: gpui::Bounds,
+ _: &mut Self::RequestLayoutState,
+ _: &mut Self::PrepaintState,
+ window: &mut Window,
+ _cx: &mut App,
+ ) {
+ window.on_mouse_event({
+ let state = self.state.clone();
+ let side = self.side;
+ let min_width = self.min_width;
+ let max_width = self.max_width;
+ let collapsed = self.collapsed;
+ move |event: &MouseMoveEvent, phase, _window, cx| {
+ if !phase.bubble() || collapsed {
+ return;
+ }
+ let (resizing, start_x, start_width, current_width) = {
+ let state = state.read(cx);
+ (
+ state.resizing,
+ state.drag_origin_x,
+ state.drag_origin_width,
+ state.expanded_width,
+ )
+ };
+ if !resizing {
+ return;
+ }
+ let delta = if side.is_left() {
+ event.position.x - start_x
+ } else {
+ start_x - event.position.x
+ };
+ let next_width = (start_width + delta).max(min_width).min(max_width);
+ if next_width == current_width {
+ return;
+ }
+ state.update(cx, |state, cx| {
+ state.expanded_width = next_width;
+ cx.notify();
+ });
+ }
+ });
+
+ window.on_mouse_event({
+ let state = self.state.clone();
+ let end_resize = self.end_resize.clone();
+ move |_: &MouseUpEvent, phase, window, cx| {
+ if !phase.bubble() {
+ return;
+ }
+ if state.read(cx).resizing {
+ end_resize(window, cx);
+ }
+ }
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sidebar::SidebarMenu;
+ use gpui::hsla;
+
+ #[gpui::test]
+ fn test_floating_sidebar_builder(_cx: &mut gpui::TestAppContext) {
+ let sidebar = FloatingSidebar::::new("floating-sidebar")
+ .side(Side::Right)
+ .collapsed(true)
+ .width(px(280.0))
+ .min_width(px(220.0))
+ .max_width(px(420.0))
+ .resizer_width(px(8.0))
+ .resizer_hover_bg(hsla(0.0, 0.0, 0.0, 0.2))
+ .inset(px(6.0))
+ .top_inset(px(12.0))
+ .blur_enabled(false)
+ .elevation(ElevationToken::Md)
+ .on_resize_end(|_, _, _| {});
+
+ assert_eq!(sidebar.side, Side::Right);
+ assert!(sidebar.collapsed);
+ assert_eq!(sidebar.width, px(280.0));
+ assert_eq!(sidebar.min_width, px(220.0));
+ assert_eq!(sidebar.max_width, px(420.0));
+ assert_eq!(sidebar.resizer_width, px(8.0));
+ assert!(sidebar.resizer_hover_bg.is_some());
+ assert_eq!(sidebar.inset, Some(px(6.0)));
+ assert_eq!(sidebar.top_inset, px(12.0));
+ assert_eq!(sidebar.blur_enabled, Some(false));
+ assert!(matches!(sidebar.elevation, ElevationToken::Md));
+ assert!(sidebar.on_resize_end.is_some());
+ }
+
+ #[gpui::test]
+ fn test_floating_sidebar_width_constraints(_cx: &mut gpui::TestAppContext) {
+ let raised_min = FloatingSidebar::::new("raised-min").min_width(px(450.0));
+ assert_eq!(raised_min.min_width, px(450.0));
+ assert_eq!(raised_min.max_width, px(450.0));
+ assert_eq!(raised_min.width, px(450.0));
+
+ let lowered_max = FloatingSidebar::::new("lowered-max").max_width(px(180.0));
+ assert_eq!(lowered_max.min_width, px(180.0));
+ assert_eq!(lowered_max.max_width, px(180.0));
+ assert_eq!(lowered_max.width, px(180.0));
+
+ let clamped_width = FloatingSidebar::::new("clamped-width")
+ .min_width(px(220.0))
+ .max_width(px(420.0))
+ .width(px(100.0));
+ assert_eq!(clamped_width.width, px(220.0));
+ }
+}
diff --git a/crates/ui/src/highlighter/highlighter.rs b/crates/ui/src/highlighter/highlighter.rs
index 077bf393..7c429646 100644
--- a/crates/ui/src/highlighter/highlighter.rs
+++ b/crates/ui/src/highlighter/highlighter.rs
@@ -38,12 +38,24 @@ pub struct SyntaxHighlighter {
parser: Parser,
/// The last parsed tree.
tree: Option,
+
+ /// Parsed injection trees (language → tree with ranges).
+ /// These are built once in update() and queried multiple times in match_styles().
+ injection_layers: HashMap,
+}
+
+/// A parsed injection layer.
+/// Stores the parsed tree and the ranges it covers.
+struct InjectionLayer {
+ tree: Tree,
}
struct TextProvider<'a>(&'a Rope);
struct ByteChunks<'a> {
cursor: ChunkCursor<'a>,
- end: usize,
+ node_start: usize,
+ node_end: usize,
+ at_first: bool,
}
impl<'a> tree_sitter::TextProvider<&'a [u8]> for TextProvider<'a> {
type I = ByteChunks<'a>;
@@ -54,7 +66,9 @@ impl<'a> tree_sitter::TextProvider<&'a [u8]> for TextProvider<'a> {
ByteChunks {
cursor,
- end: range.end,
+ node_start: range.start,
+ node_end: range.end,
+ at_first: true,
}
}
}
@@ -63,14 +77,29 @@ impl<'a> Iterator for ByteChunks<'a> {
type Item = &'a [u8];
fn next(&mut self) -> Option {
- let cursor = &mut self.cursor;
- let end = self.end;
+ if !self.at_first {
+ if !self.cursor.next() {
+ return None;
+ }
+ }
+ self.at_first = false;
- if cursor.next() && cursor.byte_offset() < end {
- Some(cursor.chunk().as_bytes())
- } else {
- None
+ let chunk_byte_start = self.cursor.byte_offset();
+ if chunk_byte_start >= self.node_end {
+ return None;
+ }
+
+ let chunk = self.cursor.chunk().as_bytes();
+
+ // Slice the chunk to only include bytes within the node's range.
+ let start_in_chunk = self.node_start.saturating_sub(chunk_byte_start);
+ let end_in_chunk = (self.node_end - chunk_byte_start).min(chunk.len());
+
+ if start_in_chunk >= end_in_chunk {
+ return None;
}
+
+ Some(&chunk[start_in_chunk..end_in_chunk])
}
}
@@ -313,6 +342,7 @@ impl SyntaxHighlighter {
text: Rope::new(),
parser,
tree: None,
+ injection_layers: HashMap::new(),
})
}
@@ -362,6 +392,112 @@ impl SyntaxHighlighter {
self.tree = Some(new_tree);
self.text = text.clone();
+ self.parse_combined_injections(self.tree.as_ref().unwrap());
+ }
+
+ /// Parse all combined injections after main tree is updated.
+ /// pattern: parse once in update, query many times in render.
+ fn parse_combined_injections(&mut self, tree: &Tree) {
+ let Some(combined_query) = &self.combined_injections_query else {
+ return;
+ };
+
+ // Note: Tree edit history is handled in update() via parser.parse_with_options(old_tree)
+
+ let root_node = tree.root_node();
+ let mut cursor = QueryCursor::new();
+ let mut matches = cursor.matches(combined_query, root_node, TextProvider(&self.text));
+
+ // Group ranges by injection language
+ let mut combined_ranges: HashMap> = HashMap::new();
+ while let Some(query_match) = matches.next() {
+ let mut language_name: Option = None;
+
+ if let Some(prop) = combined_query
+ .property_settings(query_match.pattern_index)
+ .iter()
+ .find(|prop| prop.key.as_ref() == "injection.language")
+ {
+ language_name = prop
+ .value
+ .as_ref()
+ .map(|v| SharedString::from(v.to_string()));
+ }
+
+ let Some(language_name) = language_name else {
+ continue;
+ };
+
+ for capture in query_match
+ .captures
+ .iter()
+ .filter(|cap| Some(cap.index) == self.combined_injection_content_capture_index)
+ {
+ combined_ranges
+ .entry(language_name.clone())
+ .or_default()
+ .push(capture.node.range());
+ }
+ }
+
+ // Prune injection_layers for languages no longer present in combined_ranges
+ self.injection_layers
+ .retain(|lang, _| combined_ranges.get(lang).is_some_and(|r| !r.is_empty()));
+
+ // Parse each combined language group with incremental parsing
+ for (language_name, ranges) in combined_ranges {
+ if ranges.is_empty() {
+ continue;
+ }
+
+ let Some(config) = LanguageRegistry::singleton().language(&language_name) else {
+ continue;
+ };
+
+ let mut parser = Parser::new();
+ if let Err(e) = parser.set_language(&config.language) {
+ tracing::debug!(
+ "highlighter: failed to set_language for {}: {}",
+ language_name,
+ e
+ );
+ continue;
+ }
+ if let Err(e) = parser.set_included_ranges(&ranges) {
+ tracing::debug!(
+ "highlighter: failed to set_included_ranges for {} ({} ranges): {}",
+ language_name,
+ ranges.len(),
+ e
+ );
+ continue;
+ }
+
+ // Try to reuse old tree for incremental parsing
+ let old_tree = self
+ .injection_layers
+ .get(&language_name)
+ .map(|layer| &layer.tree);
+
+ let Some(new_tree) = parser.parse_with_options(
+ &mut |offset, _| {
+ if offset >= self.text.len() {
+ ""
+ } else {
+ let (chunk, chunk_byte_ix) = self.text.chunk(offset);
+ &chunk[offset - chunk_byte_ix..]
+ }
+ },
+ old_tree,
+ None,
+ ) else {
+ continue;
+ };
+
+ // Store the parsed layer
+ self.injection_layers
+ .insert(language_name, InjectionLayer { tree: new_tree });
+ }
}
/// Match the visible ranges of nodes in the Tree for highlighting.
@@ -378,56 +514,34 @@ impl SyntaxHighlighter {
let root_node = tree.root_node();
let source = &self.text;
- // Process combined injections first.
- if let Some(combined_query) = &self.combined_injections_query {
- let mut cursor = QueryCursor::new();
- // Do NOT restrict to visible range — we need all nodes for context
- let mut matches = cursor.matches(combined_query, root_node, TextProvider(source));
-
- // Group ranges by injection language
- let mut combined_ranges: HashMap> =
- HashMap::new();
- while let Some(query_match) = matches.next() {
- // Extract language name from property settings
- let mut language_name: Option = None;
-
- if let Some(prop) = combined_query
- .property_settings(query_match.pattern_index)
- .iter()
- .find(|prop| prop.key.as_ref() == "injection.language")
- {
- language_name = prop
- .value
- .as_ref()
- .map(|v| SharedString::from(v.to_string()));
- }
+ // Query pre-parsed injection layers.
+ for (language_name, layer) in &self.injection_layers {
+ let Some(query) = self.injection_queries.get(language_name) else {
+ continue;
+ };
- let Some(language_name) = language_name else {
- continue;
- };
+ let mut query_cursor = QueryCursor::new();
+ query_cursor.set_byte_range(range.clone());
- // Collect content node ranges
- for capture in query_match
- .captures
- .iter()
- .filter(|cap| Some(cap.index) == self.combined_injection_content_capture_index)
- {
- let node = capture.node;
- combined_ranges
- .entry(language_name.clone())
- .or_default()
- .push(node.range());
- }
- }
+ let mut matches =
+ query_cursor.matches(query, layer.tree.root_node(), TextProvider(&self.text));
- // Parse each combined language group
- for (language_name, ranges) in combined_ranges {
- if ranges.is_empty() {
- continue;
- }
- let styles = self.handle_combined_injection(&language_name, &ranges, &range);
- for (node_range, highlight_name) in styles {
- highlights.push(HighlightItem::new(node_range, highlight_name));
+ let mut last_end = 0usize;
+ while let Some(m) = matches.next() {
+ for cap in m.captures {
+ let node_range = cap.node.start_byte()..cap.node.end_byte();
+
+ if node_range.start < last_end {
+ continue;
+ }
+
+ if let Some(highlight_name) = query.capture_names().get(cap.index as usize) {
+ last_end = node_range.end;
+ highlights.push(HighlightItem::new(
+ node_range,
+ SharedString::from(highlight_name.to_string()),
+ ));
+ }
}
}
}
@@ -452,14 +566,7 @@ impl SyntaxHighlighter {
let last_range = last_item.map(|item| &item.range).unwrap_or(&(0..0));
let last_highlight_name = last_item.map(|item| item.name.clone());
- if last_range.end <= node_range.start
- && last_highlight_name.as_ref() == Some(&highlight_name)
- {
- highlights.push(HighlightItem::new(
- last_range.start..node_range.end,
- highlight_name.clone(),
- ));
- } else if last_range == &node_range {
+ if last_range == &node_range {
// case:
// last_range: 213..220, last_highlight_name: Some("property")
// last_range: 213..220, last_highlight_name: Some("string")
@@ -481,79 +588,6 @@ impl SyntaxHighlighter {
highlights
}
- /// Handle combined injections by parsing all ranges as a single document.
- ///
- /// Uses `Parser::set_included_ranges` so the injection language parser
- /// only sees the combined text ranges but byte offsets in the resulting
- /// tree correspond to positions in the original document.
- ///
- /// `visible_range` limits which highlights are returned (for performance),
- /// but the parser sees all ranges for correctness.
- fn handle_combined_injection(
- &self,
- injection_language: &str,
- ranges: &[tree_sitter::Range],
- visible_range: &Range,
- ) -> Vec<(Range, String)> {
- let mut cache = vec![];
- let Some(query) = self.injection_queries.get(injection_language) else {
- return cache;
- };
- let Some(config) = LanguageRegistry::singleton().language(injection_language) else {
- return cache;
- };
-
- let mut parser = Parser::new();
- if parser.set_language(&config.language).is_err() {
- return cache;
- }
- if parser.set_included_ranges(ranges).is_err() {
- return cache;
- }
-
- // Parse the full source text — the parser will only look at the
- // included ranges but the resulting byte offsets match the original.
- let Some(tree) = parser.parse_with_options(
- &mut move |offset, _| {
- if offset >= self.text.len() {
- ""
- } else {
- let (chunk, chunk_byte_ix) = self.text.chunk(offset);
- &chunk[offset - chunk_byte_ix..]
- }
- },
- None,
- None,
- ) else {
- return cache;
- };
-
- let mut query_cursor = QueryCursor::new();
- // Only return highlights within the visible range
- query_cursor.set_byte_range(visible_range.clone());
-
- let mut matches = query_cursor.matches(query, tree.root_node(), TextProvider(&self.text));
-
- let mut last_end = 0usize;
- while let Some(m) = matches.next() {
- for cap in m.captures {
- let cap_node = cap.node;
- let node_range = cap_node.start_byte()..cap_node.end_byte();
-
- if node_range.start < last_end {
- continue;
- }
-
- if let Some(highlight_name) = query.capture_names().get(cap.index as usize) {
- last_end = node_range.end;
- cache.push((node_range, highlight_name.to_string()));
- }
- }
- }
-
- cache
- }
-
/// Returns the syntax highlight styles for a range of text.
///
/// The argument `range` is the range of bytes in the text to highlight.
diff --git a/crates/ui/src/highlighter/languages.rs b/crates/ui/src/highlighter/languages.rs
index 0ad63c3c..2f8f8547 100644
--- a/crates/ui/src/highlighter/languages.rs
+++ b/crates/ui/src/highlighter/languages.rs
@@ -29,6 +29,8 @@ pub enum Language {
Java,
JavaScript,
JsDoc,
+ Kotlin,
+ Lua,
Make,
Markdown,
MarkdownInline,
@@ -82,6 +84,8 @@ impl Language {
Self::JavaScript => "javascript",
Self::JsDoc => "jsdoc",
Self::Json => "json",
+ Self::Kotlin => "kotlin",
+ Self::Lua => "lua",
Self::Make => "make",
Self::Markdown => "markdown",
Self::MarkdownInline => "markdown_inline",
@@ -125,6 +129,8 @@ impl Language {
"javascript" | "js" => Self::JavaScript,
"jsdoc" => Self::JsDoc,
"json" | "jsonc" => Self::Json,
+ "kotlin" | "kt" | "kts" | "ktm" => Self::Kotlin,
+ "lua" => Self::Lua,
"make" | "makefile" => Self::Make,
"markdown" | "md" | "mdx" => Self::Markdown,
"markdown_inline" | "markdown-inline" => Self::MarkdownInline,
@@ -279,6 +285,18 @@ impl Language {
"",
"",
),
+ Self::Kotlin => (
+ tree_sitter_kotlin_sg::LANGUAGE,
+ include_str!("languages/kotlin/highlights.scm"),
+ "",
+ "",
+ ),
+ Self::Lua => (
+ tree_sitter_lua::LANGUAGE,
+ include_str!("languages/lua/highlights.scm"),
+ tree_sitter_lua::INJECTIONS_QUERY,
+ tree_sitter_lua::LOCALS_QUERY,
+ ),
Self::Python => (
tree_sitter_python::LANGUAGE,
tree_sitter_python::HIGHLIGHTS_QUERY,
diff --git a/crates/ui/src/highlighter/languages/kotlin/highlights.scm b/crates/ui/src/highlighter/languages/kotlin/highlights.scm
new file mode 100644
index 00000000..179ca27a
--- /dev/null
+++ b/crates/ui/src/highlighter/languages/kotlin/highlights.scm
@@ -0,0 +1,344 @@
+;; Kotlin highlights — adapted from tree-sitter-kotlin-sg (nvim-treesitter)
+;; Remapped to the highlight names used by this project's registry.
+;;
+;; IMPORTANT: Pattern order matters. This project's highlighter keeps the
+;; FIRST capture name when multiple patterns match the same range. Therefore
+;; specific patterns (function, property, constant…) must appear BEFORE the
+;; generic `(simple_identifier) @variable` catch-all at the end of this file.
+
+;;; Literals (no simple_identifier conflict — safe to place early)
+
+[
+ (line_comment)
+ (multiline_comment)
+ (shebang_line)
+] @comment
+
+(real_literal) @number
+[
+ (integer_literal)
+ (long_literal)
+ (hex_literal)
+ (bin_literal)
+ (unsigned_literal)
+] @number
+
+(null_literal) @constant.builtin
+
+(boolean_literal) @boolean
+
+(character_literal) @string
+
+(string_literal) @string
+
+(character_escape_seq) @string.escape
+
+; Regex: "pattern".toRegex()
+(call_expression
+ (navigation_expression
+ ((string_literal) @string.regex)
+ (navigation_suffix
+ ((simple_identifier) @_function
+ (#eq? @_function "toRegex")))))
+
+; Regex: Regex("pattern")
+(call_expression
+ ((simple_identifier) @_function
+ (#eq? @_function "Regex"))
+ (call_suffix
+ (value_arguments
+ (value_argument
+ (string_literal) @string.regex))))
+
+; Regex: Regex.fromLiteral("pattern")
+(call_expression
+ (navigation_expression
+ ((simple_identifier) @_class
+ (#eq? @_class "Regex"))
+ (navigation_suffix
+ ((simple_identifier) @_function
+ (#eq? @_function "fromLiteral"))))
+ (call_suffix
+ (value_arguments
+ (value_argument
+ (string_literal) @string.regex))))
+
+;;; Keywords
+
+(type_alias "typealias" @keyword)
+[
+ (class_modifier)
+ (member_modifier)
+ (function_modifier)
+ (property_modifier)
+ (platform_modifier)
+ (variance_modifier)
+ (parameter_modifier)
+ (visibility_modifier)
+ (reification_modifier)
+ (inheritance_modifier)
+] @keyword
+
+[
+ "val"
+ "var"
+ "enum"
+ "class"
+ "object"
+ "interface"
+ "companion"
+ "where"
+ "by"
+] @keyword
+
+("fun") @keyword
+
+(jump_expression) @keyword
+
+[
+ "if"
+ "else"
+ "when"
+] @keyword
+
+[
+ "for"
+ "do"
+ "while"
+] @keyword
+
+[
+ "try"
+ "catch"
+ "throw"
+ "finally"
+] @keyword
+
+;;; Annotations
+
+(annotation
+ "@" @attribute (use_site_target)? @attribute)
+(annotation
+ (user_type
+ (type_identifier) @attribute))
+(annotation
+ (constructor_invocation
+ (user_type
+ (type_identifier) @attribute)))
+
+(file_annotation
+ "@" @attribute "file" @attribute ":" @attribute)
+(file_annotation
+ (user_type
+ (type_identifier) @attribute))
+(file_annotation
+ (constructor_invocation
+ (user_type
+ (type_identifier) @attribute)))
+
+;;; Operators & Punctuation
+
+[
+ "!"
+ "!="
+ "!=="
+ "="
+ "=="
+ "==="
+ ">"
+ ">="
+ "<"
+ "<="
+ "||"
+ "&&"
+ "+"
+ "++"
+ "+="
+ "-"
+ "--"
+ "-="
+ "*"
+ "*="
+ "/"
+ "/="
+ "%"
+ "%="
+ "?."
+ "?:"
+ "!!"
+ "is"
+ "!is"
+ "in"
+ "!in"
+ "as"
+ "as?"
+ ".."
+ "->"
+] @operator
+
+[
+ "(" ")"
+ "[" "]"
+ "{" "}"
+] @punctuation.bracket
+
+[
+ "."
+ ","
+ ";"
+ ":"
+ "::"
+] @punctuation.delimiter
+
+(string_literal
+ "$" @punctuation.special
+ (interpolated_identifier) @variable)
+(string_literal
+ "${" @punctuation.special
+ (interpolated_expression)
+ "}" @punctuation.special)
+
+;;; Types
+
+(type_identifier) @type
+
+;;; Package & Imports
+
+(package_header
+ "package" @keyword
+ . (identifier) @type)
+
+(import_header
+ "import" @keyword)
+
+;;; Labels
+
+(label) @label
+
+;;; Function definitions
+
+(function_declaration
+ . (simple_identifier) @function)
+
+(getter
+ ("get") @function)
+(setter
+ ("set") @function)
+
+(primary_constructor) @constructor
+(secondary_constructor
+ ("constructor") @constructor)
+
+(constructor_invocation
+ (user_type
+ (type_identifier) @constructor))
+
+(anonymous_initializer
+ ("init") @constructor)
+
+;;; Function calls — must appear before the generic @variable catch-all
+
+; function()
+(call_expression
+ . (simple_identifier) @function)
+
+; object.function() or object.property.function()
+(call_expression
+ (navigation_expression
+ (navigation_suffix
+ (simple_identifier) @function) . ))
+
+(call_expression
+ . (simple_identifier) @function
+ (#any-of? @function
+ "arrayOf"
+ "arrayOfNulls"
+ "byteArrayOf"
+ "shortArrayOf"
+ "intArrayOf"
+ "longArrayOf"
+ "ubyteArrayOf"
+ "ushortArrayOf"
+ "uintArrayOf"
+ "ulongArrayOf"
+ "floatArrayOf"
+ "doubleArrayOf"
+ "booleanArrayOf"
+ "charArrayOf"
+ "emptyArray"
+ "mapOf"
+ "setOf"
+ "listOf"
+ "emptyMap"
+ "emptySet"
+ "emptyList"
+ "mutableMapOf"
+ "mutableSetOf"
+ "mutableListOf"
+ "print"
+ "println"
+ "error"
+ "TODO"
+ "run"
+ "runCatching"
+ "repeat"
+ "lazy"
+ "lazyOf"
+ "enumValues"
+ "enumValueOf"
+ "assert"
+ "check"
+ "checkNotNull"
+ "require"
+ "requireNotNull"
+ "with"
+ "suspend"
+ "synchronized"
+))
+
+;;; Identifiers — specific patterns before the catch-all
+
+(enum_entry
+ (simple_identifier) @constant)
+
+(class_parameter
+ (simple_identifier) @property)
+
+(class_body
+ (property_declaration
+ (variable_declaration
+ (simple_identifier) @property)))
+
+(_
+ (navigation_suffix
+ (simple_identifier) @property))
+
+(parameter
+ (simple_identifier) @variable)
+
+(parameter_with_optional_type
+ (simple_identifier) @variable)
+
+(lambda_literal
+ (lambda_parameters
+ (variable_declaration
+ (simple_identifier) @variable)))
+
+; `this` / `super` keywords
+(this_expression) @variable.special
+(super_expression) @variable.special
+
+; `it` keyword inside lambdas
+((simple_identifier) @variable.special
+(#eq? @variable.special "it"))
+
+; `field` keyword inside property getter/setter
+((simple_identifier) @variable.special
+(#eq? @variable.special "field"))
+
+; `where` parsed as identifier when grammar doesn't recognise the clause
+((simple_identifier) @keyword
+(#eq? @keyword "where"))
+
+; Generic identifier catch-all — MUST be last so specific patterns win
+(simple_identifier) @variable
diff --git a/crates/ui/src/highlighter/languages/lua/highlights.scm b/crates/ui/src/highlighter/languages/lua/highlights.scm
new file mode 100644
index 00000000..b60f22c0
--- /dev/null
+++ b/crates/ui/src/highlighter/languages/lua/highlights.scm
@@ -0,0 +1,152 @@
+;; Lua highlights
+
+;;; Keywords
+
+[
+ "do"
+ "else"
+ "elseif"
+ "end"
+ "for"
+ "function"
+ "goto"
+ "if"
+ "in"
+ "local"
+ "repeat"
+ "return"
+ "then"
+ "until"
+ "while"
+ (break_statement)
+] @keyword
+
+;;; Operators
+
+[
+ "and"
+ "not"
+ "or"
+] @keyword
+
+[
+ "+"
+ "-"
+ "*"
+ "/"
+ "%"
+ "^"
+ "#"
+ "=="
+ "~="
+ "<="
+ ">="
+ "<"
+ ">"
+ "="
+ "&"
+ "~"
+ "|"
+ "<<"
+ ">>"
+ "//"
+ ".."
+] @operator
+
+;;; Punctuations
+
+[
+ ";"
+ ":"
+ ","
+ "."
+] @punctuation.delimiter
+
+[
+ "("
+ ")"
+ "["
+ "]"
+ "{"
+ "}"
+] @punctuation.bracket
+
+;;; Constants
+
+(nil) @constant.builtin
+[
+ (false)
+ (true)
+] @boolean
+
+(vararg_expression) @constant
+
+((identifier) @constant
+ (#match? @constant "^[A-Z][A-Z_0-9]*$"))
+
+;;; Numbers
+
+(number) @number
+
+;;; Strings
+
+(string) @string
+(escape_sequence) @string.escape
+
+;;; Comments
+
+(comment) @comment
+(hash_bang_line) @comment
+
+;;; Tables
+
+(field name: (identifier) @property)
+
+(dot_index_expression field: (identifier) @property)
+
+(table_constructor
+ [
+ "{"
+ "}"
+ ] @punctuation.bracket)
+
+;;; Functions
+
+(parameters (identifier) @variable)
+
+(function_call
+ name: [
+ (identifier) @function
+ (dot_index_expression field: (identifier) @function)
+ ])
+
+(function_declaration
+ name: [
+ (identifier) @function
+ (dot_index_expression field: (identifier) @function)
+ ])
+
+(method_index_expression method: (identifier) @function)
+
+;; Built-in functions
+(function_call
+ (identifier) @function
+ (#any-of? @function
+ ;; built-in functions in Lua 5.1+
+ "assert" "collectgarbage" "dofile" "error" "getfenv" "getmetatable" "ipairs"
+ "load" "loadfile" "loadstring" "module" "next" "pairs" "pcall" "print"
+ "rawequal" "rawget" "rawset" "require" "select" "setfenv" "setmetatable"
+ "tonumber" "tostring" "type" "unpack" "xpcall"))
+
+;;; Variables
+
+(variable_list
+ attribute: (attribute
+ (identifier) @attribute))
+
+;; self reference
+((identifier) @variable.special
+ (#eq? @variable.special "self"))
+
+;; Generic identifier
+(identifier) @variable
diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs
index fef1f85e..9c612f93 100644
--- a/crates/ui/src/input/element.rs
+++ b/crates/ui/src/input/element.rs
@@ -108,9 +108,7 @@ impl TextElement {
let mut cursor_start = None;
let mut cursor_end = None;
- let min_offset_needed = cursor
- .min(selected_range.start)
- .min(selected_range.end);
+ let min_offset_needed = cursor.min(selected_range.start).min(selected_range.end);
let (start_ix, mut prev_lines_offset, mut offset_y) =
if min_offset_needed >= last_layout.visible_range_offset.start {
diff --git a/crates/ui/src/input/search.rs b/crates/ui/src/input/search.rs
index 748b07f3..864d121e 100644
--- a/crates/ui/src/input/search.rs
+++ b/crates/ui/src/input/search.rs
@@ -31,10 +31,7 @@ struct RopeReader<'a> {
impl<'a> RopeReader<'a> {
fn new(rope: &'a Rope) -> Self {
let mut chunks = rope.chunks();
- let current = chunks
- .next()
- .map(|s| s.as_bytes())
- .unwrap_or_default();
+ let current = chunks.next().map(|s| s.as_bytes()).unwrap_or_default();
Self {
chunks,
current,
diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs
index 4e649ad2..4f4d5b6f 100644
--- a/crates/ui/src/input/state.rs
+++ b/crates/ui/src/input/state.rs
@@ -105,7 +105,11 @@ 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")]
@@ -119,6 +123,7 @@ pub(crate) fn init(cx: &mut App) {
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)),
KeyBinding::new("enter", Enter { secondary: false }, Some(CONTEXT)),
+ KeyBinding::new("shift-enter", Enter { secondary: false }, Some(CONTEXT)),
KeyBinding::new("secondary-enter", Enter { secondary: true }, Some(CONTEXT)),
KeyBinding::new("escape", Escape, Some(CONTEXT)),
KeyBinding::new("up", MoveUp, Some(CONTEXT)),
@@ -1522,12 +1527,7 @@ impl InputState {
}
}
- fn push_history_with_old_text(
- &mut self,
- old_text: &str,
- range: &Range,
- new_text: &str,
- ) {
+ fn push_history_with_old_text(&mut self, old_text: &str, range: &Range, new_text: &str) {
if self.history.ignore {
return;
}
@@ -2044,7 +2044,8 @@ impl EntityInputHandler for InputState {
if self.mode.is_single_line() {
let pending_text = self.text.to_string();
if !self.is_valid_input(&pending_text, cx) {
- self.text.replace(range.start..range.start + new_text.len(), &old_slice);
+ self.text
+ .replace(range.start..range.start + new_text.len(), &old_slice);
return;
}
@@ -2111,7 +2112,8 @@ impl EntityInputHandler for InputState {
if self.mode.is_single_line() {
let pending_text = self.text.to_string();
if !self.is_valid_input(&pending_text, cx) {
- self.text.replace(range.start..range.start + new_text.len(), &old_slice);
+ self.text
+ .replace(range.start..range.start + new_text.len(), &old_slice);
return;
}
}
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index 3ab0eb6c..b801bb91 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -4,6 +4,7 @@ use std::ops::Deref;
mod anchored;
mod element_ext;
mod event;
+mod floating_sidebar;
mod focus_trap;
mod geometry;
mod global_state;
@@ -82,6 +83,7 @@ pub use crate::Disableable;
pub(crate) use anchored::*;
pub use element_ext::ElementExt;
pub use event::InteractiveElementExt;
+pub use floating_sidebar::*;
pub use focus_trap::FocusTrapElement;
pub use geometry::*;
pub use icon::*;
diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs
index 3dd58856..03d3bd3a 100644
--- a/crates/ui/src/menu/popup_menu.rs
+++ b/crates/ui/src/menu/popup_menu.rs
@@ -1128,8 +1128,8 @@ impl PopupMenu {
.p_0()
.my_0p5()
.mx_neg_1()
- .h(px(1.))
- .bg(cx.theme().border)
+ .border_b(px(2.))
+ .border_color(cx.theme().border)
.disabled(true),
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
h_flex()
diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs
index 64d2a849..68db7a2e 100644
--- a/crates/ui/src/notification.rs
+++ b/crates/ui/src/notification.rs
@@ -8,10 +8,10 @@ use std::{
};
use gpui::{
- AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
- ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
- Pixels, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
- Subscription, Window, div, prelude::FluentBuilder, px,
+ AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent, ElementId,
+ Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render,
+ SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Window, div,
+ prelude::FluentBuilder, px,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
diff --git a/crates/ui/src/plot/shape/line.rs b/crates/ui/src/plot/shape/line.rs
index c9534e6b..b8a0f396 100644
--- a/crates/ui/src/plot/shape/line.rs
+++ b/crates/ui/src/plot/shape/line.rs
@@ -5,9 +5,7 @@ use gpui::{
quad, size,
};
-use crate::{
- plot::{StrokeStyle, origin_point},
-};
+use crate::plot::{StrokeStyle, origin_point};
#[allow(clippy::type_complexity)]
pub struct Line {
diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs
index aec2a1c1..1f349bde 100644
--- a/crates/ui/src/popover.rs
+++ b/crates/ui/src/popover.rs
@@ -395,8 +395,7 @@ impl RenderOnce for Popover {
let open_duration_ms = if reduced_motion {
motion.fast_duration_ms
} else {
- spring_preset_duration_ms(&motion, SpringPreset::Medium)
- .max(motion.fast_duration_ms)
+ spring_preset_duration_ms(&motion, SpringPreset::Medium).max(motion.fast_duration_ms)
};
let presence = keyed_presence(
SharedString::from(format!("popover-presence-{}", popover_id)),
diff --git a/crates/ui/src/radio.rs b/crates/ui/src/radio.rs
index 4fb6be3f..fd58955e 100644
--- a/crates/ui/src/radio.rs
+++ b/crates/ui/src/radio.rs
@@ -204,7 +204,6 @@ impl RenderOnce for Radio {
this.child(
div()
.size_full()
- .overflow_hidden()
.line_height(relative(1.))
.when(self.disabled, |this| {
this.text_color(cx.theme().muted_foreground)
diff --git a/crates/ui/src/setting/page.rs b/crates/ui/src/setting/page.rs
index 4b76e947..8e1dfab5 100644
--- a/crates/ui/src/setting/page.rs
+++ b/crates/ui/src/setting/page.rs
@@ -1,5 +1,5 @@
use gpui::{
- App, Entity, InteractiveElement as _, IntoElement, ListAlignment, ListState,
+ AnyElement, App, Entity, InteractiveElement as _, IntoElement, ListAlignment, ListState,
ParentElement as _, SharedString, Styled, Window, div, list, prelude::FluentBuilder as _, px,
};
use rust_i18n::t;
@@ -88,6 +88,7 @@ impl SettingPage {
ix: usize,
state: &Entity,
options: &RenderOptions,
+ page_header_action: Option,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
@@ -100,6 +101,8 @@ impl SettingPage {
.cloned()
.collect::>();
let groups_count = groups.len();
+ let is_resettable = self.is_resettable(cx);
+ let has_page_header_action = page_header_action.is_some();
let list_state = window
.use_keyed_state(
@@ -131,24 +134,37 @@ impl SettingPage {
.gap_3()
.border_b_1()
.border_color(cx.theme().border)
- .child(h_flex().justify_between().child(self.title.clone()).when(
- self.is_resettable(cx),
- |this| {
- this.child(
- Button::new("reset")
- .icon(IconName::Undo2)
- .ghost()
- .small()
- .tooltip(t!("Settings.Reset All"))
- .on_click({
- let page = self.clone();
- move |_, window, cx| {
- page.reset_all(window, cx);
- }
- }),
- )
- },
- ))
+ .child(
+ h_flex()
+ .justify_between()
+ .items_center()
+ .child(self.title.clone())
+ .when(is_resettable || has_page_header_action, |this| {
+ this.child(
+ h_flex()
+ .items_center()
+ .gap_2()
+ .when(is_resettable, |this| {
+ this.child(
+ Button::new("reset")
+ .icon(IconName::Undo2)
+ .ghost()
+ .small()
+ .tooltip(t!("Settings.Reset All"))
+ .on_click({
+ let page = self.clone();
+ move |_, window, cx| {
+ page.reset_all(window, cx);
+ }
+ }),
+ )
+ })
+ .when_some(page_header_action, |this, action| {
+ this.child(action)
+ }),
+ )
+ }),
+ )
.when_some(self.description.clone(), |this, description| {
this.child(
Label::new(description)
diff --git a/crates/ui/src/setting/settings.rs b/crates/ui/src/setting/settings.rs
index 09df7255..84aa77fb 100644
--- a/crates/ui/src/setting/settings.rs
+++ b/crates/ui/src/setting/settings.rs
@@ -1,5 +1,7 @@
+use std::rc::Rc;
+
use crate::{
- IconName, Sizable, Size, StyledExt,
+ IconName, SidebarShell, Sizable, Size, StyledExt,
group_box::GroupBoxVariant,
input::{Input, InputState},
resizable::{h_resizable, resizable_panel},
@@ -7,8 +9,9 @@ use crate::{
sidebar::{Sidebar, SidebarMenu, SidebarMenuItem},
};
use gpui::{
- App, AppContext as _, Axis, ElementId, Entity, IntoElement, ParentElement as _, Pixels,
- RenderOnce, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px, relative,
+ AnyElement, App, AppContext as _, Axis, ElementId, Entity, IntoElement, ParentElement as _,
+ Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
+ prelude::FluentBuilder as _, px, relative,
};
use rust_i18n::t;
@@ -31,6 +34,11 @@ pub struct Settings {
group_variant: GroupBoxVariant,
size: Size,
sidebar_width: Pixels,
+ use_sidebar_shell: bool,
+ safe_area_top: Pixels,
+ sidebar_shell_inset: Pixels,
+ sidebar_title: Option,
+ page_header_action: Option AnyElement>>,
sidebar_style: StyleRefinement,
default_selected_index: SelectIndex,
header_style: StyleRefinement,
@@ -45,6 +53,11 @@ impl Settings {
group_variant: GroupBoxVariant::default(),
size: Size::default(),
sidebar_width: px(250.0),
+ use_sidebar_shell: false,
+ safe_area_top: px(0.0),
+ sidebar_shell_inset: px(8.0),
+ sidebar_title: None,
+ page_header_action: None,
sidebar_style: StyleRefinement::default(),
default_selected_index: SelectIndex::default(),
header_style: StyleRefinement::default(),
@@ -57,6 +70,41 @@ impl Settings {
self
}
+ /// Toggle rendering the left panel with SidebarShell.
+ pub fn use_sidebar_shell(mut self, enabled: bool) -> Self {
+ self.use_sidebar_shell = enabled;
+ self
+ }
+
+ /// Set top safe-area offset to avoid titlebar control collisions.
+ pub fn safe_area_top(mut self, inset: impl Into) -> Self {
+ self.safe_area_top = inset.into();
+ self
+ }
+
+ /// Set the sidebar shell inset when `use_sidebar_shell` is enabled.
+ ///
+ /// Default is `8px`.
+ pub fn sidebar_shell_inset(mut self, inset: impl Into) -> Self {
+ self.sidebar_shell_inset = inset.into();
+ self
+ }
+
+ /// Set an optional title displayed at the top of the sidebar.
+ pub fn sidebar_title(mut self, title: impl Into) -> Self {
+ self.sidebar_title = Some(title.into());
+ self
+ }
+
+ /// Render a custom action in the active page header.
+ pub fn page_header_action(
+ mut self,
+ action: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
+ ) -> Self {
+ self.page_header_action = Some(Rc::new(action));
+ self
+ }
+
/// Add a page to the settings.
pub fn page(mut self, page: SettingPage) -> Self {
self.pages.push(page);
@@ -140,8 +188,12 @@ impl Settings {
for (ix, page) in pages.into_iter().enumerate() {
if selected_index.page_ix == ix {
+ let page_header_action = self
+ .page_header_action
+ .as_ref()
+ .map(|action| action(window, cx));
return page
- .render(ix, state, &options, window, cx)
+ .render(ix, state, &options, page_header_action, window, cx)
.into_any_element();
}
}
@@ -158,6 +210,8 @@ impl Settings {
) -> impl IntoElement {
let selected_index = state.read(cx).selected_index;
let search_input = state.read(cx).search_input.clone();
+ let sidebar_title = self.sidebar_title.clone();
+ let sidebar_title_top_offset = (self.safe_area_top - self.sidebar_shell_inset).max(px(0.0));
Sidebar::new("settings-sidebar")
.w(relative(1.))
@@ -168,6 +222,10 @@ impl Settings {
div()
.w_full()
.refine_style(&self.header_style)
+ .when_some(sidebar_title, |this, title| {
+ this.child(div().h(sidebar_title_top_offset))
+ .child(div().px_1().pb_2().font_semibold().child(title))
+ })
.child(Input::new(&search_input).prefix(IconName::Search)),
)
.child(
@@ -279,6 +337,36 @@ impl RenderOnce for Settings {
layout: Axis::Horizontal,
};
+ if self.use_sidebar_shell {
+ let sidebar_width = self.sidebar_width;
+ let sidebar_inset = self.sidebar_shell_inset;
+
+ return div()
+ .relative()
+ .size_full()
+ .overflow_hidden()
+ .child(
+ SidebarShell::left(sidebar_width)
+ .inset(sidebar_inset)
+ .resizer_width(px(0.0))
+ .child(self.render_sidebar(&state, &filtered_pages, window, cx)),
+ )
+ .child(
+ div()
+ .size_full()
+ .pl(sidebar_width + sidebar_inset)
+ .pt(self.safe_area_top)
+ .child(self.render_active_page(
+ &state,
+ &filtered_pages,
+ &options,
+ window,
+ cx,
+ )),
+ )
+ .into_any_element();
+ }
+
h_resizable(self.id.clone())
.child(
resizable_panel()
@@ -292,5 +380,6 @@ impl RenderOnce for Settings {
window,
cx,
)))
+ .into_any_element()
}
}
diff --git a/crates/ui/src/sheet.rs b/crates/ui/src/sheet.rs
index b5110067..4b59f897 100644
--- a/crates/ui/src/sheet.rs
+++ b/crates/ui/src/sheet.rs
@@ -281,19 +281,15 @@ impl RenderOnce for Sheet {
})
.map(move |this| match slide_animation {
Some(anim) => this
- .with_animation(
- "slide",
- anim,
- move |this, delta| {
- let y = px(-100.) + delta * px(100.);
- this.map(|this| match placement {
- Placement::Top => this.top(top + y),
- Placement::Right => this.right(y),
- Placement::Bottom => this.bottom(y),
- Placement::Left => this.left(y),
- })
- },
- )
+ .with_animation("slide", anim, move |this, delta| {
+ let y = px(-100.) + delta * px(100.);
+ this.map(|this| match placement {
+ Placement::Top => this.top(top + y),
+ Placement::Right => this.right(y),
+ Placement::Bottom => this.bottom(y),
+ Placement::Left => this.left(y),
+ })
+ })
.into_any_element(),
None => this.into_any_element(),
}),
diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs
index 7eb296e2..dda1cd6c 100644
--- a/crates/ui/src/sidebar/menu.rs
+++ b/crates/ui/src/sidebar/menu.rs
@@ -360,8 +360,7 @@ impl SidebarItem for SidebarMenuItem {
let open_duration = if reduced_motion {
motion.fast_duration_ms
} else {
- spring_preset_duration_ms(&motion, SpringPreset::Mild)
- .max(motion.fast_duration_ms)
+ spring_preset_duration_ms(&motion, SpringPreset::Mild).max(motion.fast_duration_ms)
};
let submenu_presence = keyed_presence(
SharedString::from(format!("{}-submenu-presence", state_key)),
@@ -565,7 +564,7 @@ impl SidebarItem for SidebarMenuItem {
None => {
let menu_items = children.clone();
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
- build_collapsed_submenu(menu, menu_items.clone(), window, cx)
+ build_collapsed_submenu(menu, menu_items, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
diff --git a/crates/ui/src/sidebar/mod.rs b/crates/ui/src/sidebar/mod.rs
index 34811da5..0c59de84 100644
--- a/crates/ui/src/sidebar/mod.rs
+++ b/crates/ui/src/sidebar/mod.rs
@@ -28,8 +28,8 @@ pub use group::*;
pub use header::*;
pub use menu::*;
-const DEFAULT_WIDTH: Pixels = px(255.);
-const COLLAPSED_WIDTH: Pixels = px(48.);
+pub(crate) const DEFAULT_WIDTH: Pixels = px(255.);
+pub(crate) const COLLAPSED_WIDTH: Pixels = px(48.);
pub trait SidebarItem: Collapsible + Clone {
fn render(
@@ -60,6 +60,7 @@ pub struct Sidebar {
collapsible: bool,
collapsed: bool,
width: Pixels,
+ animate_width: bool,
}
impl Sidebar {
@@ -75,6 +76,7 @@ impl Sidebar {
collapsible: true,
collapsed: false,
width: DEFAULT_WIDTH,
+ animate_width: true,
}
}
@@ -104,6 +106,14 @@ impl Sidebar {
self
}
+ /// Set whether the sidebar animates its width when collapsing or expanding.
+ ///
+ /// Default is `true`.
+ pub fn animate_width(mut self, animate: bool) -> Self {
+ self.animate_width = animate;
+ self
+ }
+
/// Set the header of the sidebar.
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(SidebarSlot::Static(header.into_any_element()));
@@ -116,9 +126,9 @@ impl Sidebar {
F: Fn(bool, &mut Window, &mut App) -> H + 'static,
H: IntoElement,
{
- self.header = Some(SidebarSlot::Dynamic(Rc::new(move |collapsed, window, cx| {
- builder(collapsed, window, cx).into_any_element()
- })));
+ self.header = Some(SidebarSlot::Dynamic(Rc::new(
+ move |collapsed, window, cx| builder(collapsed, window, cx).into_any_element(),
+ )));
self
}
@@ -134,9 +144,9 @@ impl Sidebar {
F: Fn(bool, &mut Window, &mut App) -> H + 'static,
H: IntoElement,
{
- self.footer = Some(SidebarSlot::Dynamic(Rc::new(move |collapsed, window, cx| {
- builder(collapsed, window, cx).into_any_element()
- })));
+ self.footer = Some(SidebarSlot::Dynamic(Rc::new(
+ move |collapsed, window, cx| builder(collapsed, window, cx).into_any_element(),
+ )));
self
}
@@ -237,6 +247,7 @@ impl RenderOnce for Sidebar {
self.style.padding = EdgesRefinement::default();
let reduced_motion = GlobalState::global(cx).reduced_motion();
+ let animate_width = self.animate_width && !reduced_motion;
let motion = cx.theme().motion.clone();
let target_collapsed = self.collapsed;
let sidebar_id = self.id.clone();
@@ -257,7 +268,7 @@ impl RenderOnce for Sidebar {
let presence = keyed_presence(
SharedString::from(format!("{}-collapsed-presence", sidebar_id)),
!target_collapsed,
- !reduced_motion,
+ animate_width,
Duration::from_millis(u64::from(open_duration_ms)),
Duration::from_millis(u64::from(close_duration_ms)),
PresenceOptions::default(),
@@ -267,14 +278,14 @@ impl RenderOnce for Sidebar {
// Mirror close/open semantics:
// - Closing: keep expanded visuals during `Exiting`, switch to collapsed at `Exited`.
// - Opening: keep collapsed visuals during `Entering`, switch to expanded at `Entered`.
- let visual_collapsed = if reduced_motion {
+ let visual_collapsed = if !animate_width {
target_collapsed
} else if target_collapsed {
matches!(presence.phase, PresencePhase::Exited)
} else {
matches!(presence.phase, PresencePhase::Entering)
};
- let transition_active = !reduced_motion && presence.transition_active();
+ let transition_active = animate_width && presence.transition_active();
let from_width = if target_collapsed {
expanded_width
} else {
diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs
index 1abab4f2..8d7482c7 100644
--- a/crates/ui/src/tab/tab.rs
+++ b/crates/ui/src/tab/tab.rs
@@ -2,8 +2,8 @@ use std::rc::Rc;
use std::time::Duration;
use crate::{
- ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt,
- animation::fade_animation, global_state::GlobalState, h_flex,
+ ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt, animation::fade_animation,
+ global_state::GlobalState, h_flex,
};
use gpui::prelude::FluentBuilder as _;
use gpui::{
diff --git a/crates/ui/src/table/state.rs b/crates/ui/src/table/state.rs
index 92ca7b3d..367bc362 100644
--- a/crates/ui/src/table/state.rs
+++ b/crates/ui/src/table/state.rs
@@ -22,9 +22,22 @@ use gpui::{
use super::*;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-enum SelectionState {
+enum SelectionMode {
Column,
Row,
+ Cell,
+}
+
+impl SelectionMode {
+ #[inline(always)]
+ fn is_column(&self) -> bool {
+ matches!(self, SelectionMode::Column)
+ }
+
+ #[inline(always)]
+ fn is_cell(&self) -> bool {
+ matches!(self, SelectionMode::Cell)
+ }
}
/// The Table event.
@@ -36,6 +49,14 @@ pub enum TableEvent {
DoubleClickedRow(usize),
/// Selected column.
SelectColumn(usize),
+ /// Selected cell.
+ ///
+ /// The first `usize` is the row index, and the second `usize` is the column index.
+ SelectCell(usize, usize),
+ /// Double click on the cell.
+ ///
+ /// The first `usize` is the row index, and the second `usize` is the column index.
+ DoubleClickedCell(usize, usize),
/// The column widths have changed.
///
/// The `Vec` contains the new widths of all columns.
@@ -47,6 +68,12 @@ pub enum TableEvent {
MoveColumn(usize, usize),
/// The row has been right clicked.
RightClickedRow(Option),
+ /// The cell has been right clicked.
+ ///
+ /// The first `usize` is the row index, and the second `usize` is the column index.
+ RightClickedCell(usize, usize),
+ /// Clear all selections.
+ ClearSelection,
}
/// The visible range of the rows and columns.
@@ -90,6 +117,8 @@ pub struct TableState {
pub col_selectable: bool,
/// Whether the table can select row.
pub row_selectable: bool,
+ /// Whether the table can select cell.
+ pub cell_selectable: bool,
/// Whether the table can sort.
pub sortable: bool,
/// Whether the table can resize columns.
@@ -103,9 +132,11 @@ pub struct TableState {
pub horizontal_scroll_handle: VirtualListScrollHandle,
selected_row: Option,
- selection_state: SelectionState,
+ selection_mode: SelectionMode,
right_clicked_row: Option,
+ right_clicked_cell: Option<(usize, usize)>,
selected_col: Option,
+ selected_cell: Option<(usize, usize)>,
/// The column index that is being resized.
resizing_col: Option,
@@ -130,10 +161,12 @@ where
col_groups: Vec::new(),
horizontal_scroll_handle: VirtualListScrollHandle::new(),
vertical_scroll_handle: UniformListScrollHandle::new(),
- selection_state: SelectionState::Row,
+ selection_mode: SelectionMode::Row,
selected_row: None,
right_clicked_row: None,
+ right_clicked_cell: None,
selected_col: None,
+ selected_cell: None,
resizing_col: None,
bounds: Bounds::default(),
fixed_head_cols_bounds: Bounds::default(),
@@ -141,6 +174,7 @@ where
loop_selection: true,
col_selectable: true,
row_selectable: true,
+ cell_selectable: false,
sortable: true,
col_movable: true,
col_resizable: true,
@@ -199,6 +233,12 @@ where
self
}
+ /// Set to enable/disable cell selectable, default false
+ pub fn cell_selectable(mut self, cell_selectable: bool) -> Self {
+ self.cell_selectable = cell_selectable;
+ self
+ }
+
/// When we update columns or rows, we need to refresh the table.
pub fn refresh(&mut self, cx: &mut Context) {
self.prepare_col_groups(cx);
@@ -232,9 +272,12 @@ where
None => true,
};
- self.selection_state = SelectionState::Row;
+ cx.stop_propagation();
+ self.selection_mode = SelectionMode::Row;
self.right_clicked_row = None;
self.selected_row = Some(row_ix);
+ self.selected_col = None;
+ self.selected_cell = None;
if let Some(row_ix) = self.selected_row {
self.vertical_scroll_handle.scroll_to_item(
row_ix,
@@ -262,8 +305,10 @@ where
/// Sets the selected col to the given index.
pub fn set_selected_col(&mut self, col_ix: usize, cx: &mut Context) {
- self.selection_state = SelectionState::Column;
+ self.selection_mode = SelectionMode::Column;
self.selected_col = Some(col_ix);
+ self.selected_row = None;
+ self.selected_cell = None;
if let Some(col_ix) = self.selected_col {
self.scroll_to_col(col_ix, cx);
}
@@ -271,11 +316,34 @@ where
cx.notify();
}
+ /// Returns the selected cell as (row_ix, col_ix).
+ pub fn selected_cell(&self) -> Option<(usize, usize)> {
+ self.selected_cell
+ }
+
+ /// Sets the selected cell to the given row and column indices.
+ pub fn set_selected_cell(&mut self, row_ix: usize, col_ix: usize, cx: &mut Context) {
+ self.selection_mode = SelectionMode::Cell;
+ self.selected_cell = Some((row_ix, col_ix));
+ self.selected_row = None;
+ self.selected_col = None;
+
+ // Scroll to the cell
+ self.vertical_scroll_handle
+ .scroll_to_item(row_ix, ScrollStrategy::Center);
+ self.scroll_to_col(col_ix, cx);
+
+ cx.emit(TableEvent::SelectCell(row_ix, col_ix));
+ cx.notify();
+ }
+
/// Clear the selection of the table.
pub fn clear_selection(&mut self, cx: &mut Context) {
- self.selection_state = SelectionState::Row;
+ self.selection_mode = SelectionMode::Row;
self.selected_row = None;
self.selected_col = None;
+ self.selected_cell = None;
+ cx.emit(TableEvent::ClearSelection);
cx.notify();
}
@@ -395,9 +463,28 @@ where
cx: &mut Context,
) {
self.right_clicked_row = row_ix;
+ self.right_clicked_cell = None;
cx.emit(TableEvent::RightClickedRow(row_ix));
}
+ fn on_cell_right_click(
+ &mut self,
+ _: &MouseDownEvent,
+ row_ix: usize,
+ col_ix: usize,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ if !self.cell_selectable {
+ return;
+ }
+
+ cx.stop_propagation();
+ self.right_clicked_cell = Some((row_ix, col_ix));
+ self.right_clicked_row = None;
+ cx.emit(TableEvent::RightClickedCell(row_ix, col_ix));
+ }
+
fn on_row_left_click(
&mut self,
e: &ClickEvent,
@@ -416,6 +503,26 @@ where
}
}
+ fn on_cell_click(
+ &mut self,
+ e: &ClickEvent,
+ row_ix: usize,
+ col_ix: usize,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ if !self.cell_selectable {
+ return;
+ }
+
+ cx.stop_propagation();
+ self.set_selected_cell(row_ix, col_ix, cx);
+
+ if e.click_count() == 2 {
+ cx.emit(TableEvent::DoubleClickedCell(row_ix, col_ix));
+ }
+ }
+
fn on_col_head_click(&mut self, col_ix: usize, _: &mut Window, cx: &mut Context) {
if !self.col_selectable {
return;
@@ -433,7 +540,7 @@ where
}
fn has_selection(&self) -> bool {
- self.selected_row.is_some() || self.selected_col.is_some()
+ self.selected_row.is_some() || self.selected_col.is_some() || self.selected_cell.is_some()
}
pub(super) fn action_cancel(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context) {
@@ -455,6 +562,25 @@ where
return;
}
+ // Cell selection mode: move up within the same column
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, col_ix)) = self.selected_cell {
+ let new_row = if row_ix > 0 {
+ row_ix.saturating_sub(1)
+ } else if self.loop_selection {
+ rows_count.saturating_sub(1)
+ } else {
+ row_ix
+ };
+ self.set_selected_cell(new_row, col_ix, cx);
+ } else {
+ // No cell selected, select first cell
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Row selection mode
let mut selected_row = self.selected_row.unwrap_or(0);
if selected_row > 0 {
selected_row = selected_row.saturating_sub(1);
@@ -478,6 +604,25 @@ where
return;
}
+ // Cell selection mode: move down within the same column
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, col_ix)) = self.selected_cell {
+ let new_row = if row_ix < rows_count.saturating_sub(1) {
+ row_ix + 1
+ } else if self.loop_selection {
+ 0
+ } else {
+ row_ix
+ };
+ self.set_selected_cell(new_row, col_ix, cx);
+ } else {
+ // No cell selected, select first cell
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Row selection mode
let selected_row = match self.selected_row {
Some(selected_row) if selected_row < rows_count.saturating_sub(1) => selected_row + 1,
Some(selected_row) => {
@@ -499,6 +644,18 @@ where
_: &mut Window,
cx: &mut Context,
) {
+ // Cell selection mode: move to first cell in current row
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, _)) = self.selected_cell {
+ self.set_selected_cell(row_ix, 0, cx);
+ } else {
+ // No cell selected, select first cell of first row
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Column selection mode
self.set_selected_col(0, cx);
}
@@ -509,6 +666,19 @@ where
cx: &mut Context,
) {
let columns_count = self.delegate.columns_count(cx);
+
+ // Cell selection mode: move to last cell in current row
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, _)) = self.selected_cell {
+ self.set_selected_cell(row_ix, columns_count.saturating_sub(1), cx);
+ } else {
+ // No cell selected, select last cell of first row
+ self.set_selected_cell(0, columns_count.saturating_sub(1), cx);
+ }
+ return;
+ }
+
+ // Column selection mode
self.set_selected_col(columns_count.saturating_sub(1), cx);
}
@@ -519,6 +689,20 @@ where
cx: &mut Context,
) {
let step = self.page_item_count();
+
+ // Cell selection mode: move up by page within the same column
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, col_ix)) = self.selected_cell {
+ let target = row_ix.saturating_sub(step);
+ self.set_selected_cell(target, col_ix, cx);
+ } else {
+ // No cell selected, select first cell
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Row selection mode
let current = self.selected_row.unwrap_or(0);
let target = current.saturating_sub(step);
self.set_selected_row(target, cx);
@@ -536,6 +720,21 @@ where
}
let step = self.page_item_count();
+
+ // Cell selection mode: move down by page within the same column
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, col_ix)) = self.selected_cell {
+ let max_row = rows_count.saturating_sub(1);
+ let target = (row_ix + step).min(max_row);
+ self.set_selected_cell(target, col_ix, cx);
+ } else {
+ // No cell selected, select first cell
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Row selection mode
let current = self.selected_row.unwrap_or(0);
let max_row = rows_count.saturating_sub(1);
let target = (current + step).min(max_row);
@@ -548,8 +747,28 @@ where
_: &mut Window,
cx: &mut Context,
) {
- let mut selected_col = self.selected_col.unwrap_or(0);
let columns_count = self.delegate.columns_count(cx);
+
+ // Cell selection mode: move left within the same row
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, col_ix)) = self.selected_cell {
+ let new_col = if col_ix > 0 {
+ col_ix.saturating_sub(1)
+ } else if self.loop_selection {
+ columns_count.saturating_sub(1)
+ } else {
+ col_ix
+ };
+ self.set_selected_cell(row_ix, new_col, cx);
+ } else {
+ // No cell selected, select first cell
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Column selection mode
+ let mut selected_col = self.selected_col.unwrap_or(0);
if selected_col > 0 {
selected_col = selected_col.saturating_sub(1);
} else {
@@ -566,8 +785,29 @@ where
_: &mut Window,
cx: &mut Context,
) {
+ let columns_count = self.delegate.columns_count(cx);
+
+ // Cell selection mode: move right within the same row
+ if self.selection_mode.is_cell() {
+ if let Some((row_ix, col_ix)) = self.selected_cell {
+ let new_col = if col_ix < columns_count.saturating_sub(1) {
+ col_ix + 1
+ } else if self.loop_selection {
+ 0
+ } else {
+ col_ix
+ };
+ self.set_selected_cell(row_ix, new_col, cx);
+ } else {
+ // No cell selected, select first cell
+ self.set_selected_cell(0, 0, cx);
+ }
+ return;
+ }
+
+ // Column selection mode
let mut selected_col = self.selected_col.unwrap_or(0);
- if selected_col < self.delegate.columns_count(cx).saturating_sub(1) {
+ if selected_col < columns_count.saturating_sub(1) {
selected_col += 1;
} else {
if self.loop_selection {
@@ -733,13 +973,20 @@ where
}
}
- fn render_cell(&self, col_ix: usize, _window: &mut Window, _cx: &mut Context) -> Div {
+ fn render_cell(
+ &self,
+ _row_ix: Option,
+ col_ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context,
+ ) -> Div {
let Some(col_group) = self.col_groups.get(col_ix) else {
return div();
};
let col_width = col_group.width;
let col_padding = col_group.column.paddings;
+
div()
.w(col_width)
.h_full()
@@ -758,7 +1005,14 @@ where
}
/// Show Column selection style, when the column is selected and the selection state is Column.
- fn render_col_wrap(&self, col_ix: usize, _: &mut Window, cx: &mut Context) -> Div {
+ /// Note: When a cell is selected, column selection style is not shown.
+ fn render_col_wrap(
+ &self,
+ _row_ix: Option,
+ col_ix: usize,
+ _: &mut Window,
+ cx: &mut Context,
+ ) -> Div {
let el = h_flex().h_full();
let selectable = self.col_selectable
&& self
@@ -767,10 +1021,12 @@ where
.map(|col_group| col_group.column.selectable)
.unwrap_or(false);
- if selectable
- && self.selected_col == Some(col_ix)
- && self.selection_state == SelectionState::Column
- {
+ // Don't show column selection if a cell is selected
+ if self.selection_mode.is_cell() {
+ return el;
+ }
+
+ if selectable && self.selected_col == Some(col_ix) && self.selection_mode.is_column() {
el.bg(cx.theme().table_active)
} else {
el
@@ -930,7 +1186,7 @@ where
h_flex()
.h_full()
.child(
- self.render_cell(col_ix, window, cx)
+ self.render_cell(None, col_ix, window, cx)
.id(("col-header", col_ix))
.on_click(cx.listener(move |this, _, window, cx| {
this.on_col_head_click(col_ix, window, cx);
@@ -1126,11 +1382,71 @@ where
let mut items = Vec::with_capacity(left_columns_count);
(0..left_columns_count).for_each(|col_ix| {
- items.push(self.render_col_wrap(col_ix, window, cx).child(
- self.render_cell(col_ix, window, cx).child(
- self.measure_render_td(row_ix, col_ix, window, cx),
- ),
- ));
+ let is_cell_selected = self.selected_cell
+ == Some((row_ix, col_ix))
+ && self.selection_mode.is_cell();
+ let is_cell_right_clicked =
+ self.right_clicked_cell == Some((row_ix, col_ix));
+
+ items.push(
+ self.render_col_wrap(Some(row_ix), col_ix, window, cx)
+ .child(
+ self.render_cell(Some(row_ix), col_ix, window, cx)
+ .id(format!("table-cell:{}:{}", row_ix, col_ix))
+ .relative()
+ .child(self.measure_render_td(
+ row_ix, col_ix, window, cx,
+ ))
+ .when(is_cell_selected, |this| {
+ this.child(
+ div()
+ .absolute()
+ .inset_0()
+ .bg(cx.theme().table_active)
+ .border_1()
+ .border_color(
+ cx.theme().table_active_border,
+ ),
+ )
+ })
+ .when(
+ is_cell_right_clicked && !is_cell_selected,
+ |this| {
+ this.child(
+ div()
+ .absolute()
+ .inset_0()
+ .border_1()
+ .border_color(
+ cx.theme()
+ .table_active_border
+ .opacity(0.5),
+ ),
+ )
+ },
+ )
+ .when(self.cell_selectable, |this| {
+ this.on_click(cx.listener(
+ move |table, e, window, cx| {
+ table.on_cell_click(
+ e, row_ix, col_ix, window, cx,
+ );
+ },
+ ))
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(
+ move |table, e, window, cx| {
+ table.on_cell_right_click(
+ e, row_ix, col_ix, window,
+ cx,
+ );
+ },
+ ),
+ )
+ }),
+ ),
+ );
});
items
@@ -1165,19 +1481,89 @@ where
let mut row_cols =
h_flex().h_full().left(first_col_origin + scroll_offset_x);
- for col_ix in visible_col_range.clone() {
+ for col_ix in visible_col_range {
let actual_col_ix = col_ix + left_columns_count;
+
+ let is_cell_selected = self.selected_cell
+ == Some((row_ix, actual_col_ix))
+ && self.selection_mode.is_cell();
+ let is_cell_right_clicked =
+ self.right_clicked_cell == Some((row_ix, actual_col_ix));
+
row_cols = row_cols.child(
- self.render_col_wrap(actual_col_ix, window, cx).child(
- self.render_cell(actual_col_ix, window, cx).child(
- self.measure_render_td(
+ self.render_col_wrap(Some(row_ix), actual_col_ix, window, cx)
+ .child(
+ self.render_cell(
+ Some(row_ix),
+ actual_col_ix,
+ window,
+ cx,
+ )
+ .id(format!("table-cell:{}:{}", row_ix, actual_col_ix))
+ .relative()
+ .child(self.measure_render_td(
row_ix,
actual_col_ix,
window,
cx,
+ ))
+ .when(is_cell_selected, |this| {
+ this.child(
+ div()
+ .absolute()
+ .inset_0()
+ .bg(cx.theme().table_active)
+ .border_1()
+ .border_color(
+ cx.theme().table_active_border,
+ ),
+ )
+ })
+ .when(
+ is_cell_right_clicked && !is_cell_selected,
+ |this| {
+ this.child(
+ div()
+ .absolute()
+ .inset_0()
+ .border_1()
+ .border_color(
+ cx.theme()
+ .table_active_border
+ .opacity(0.5),
+ ),
+ )
+ },
+ )
+ .when(
+ self.cell_selectable,
+ |this| {
+ this.on_click(cx.listener(
+ move |table, e, window, cx| {
+ table.on_cell_click(
+ e,
+ row_ix,
+ actual_col_ix,
+ window,
+ cx,
+ );
+ },
+ ))
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(move |table, e, window, cx| {
+ table.on_cell_right_click(
+ e,
+ row_ix,
+ actual_col_ix,
+ window,
+ cx,
+ );
+ }),
+ )
+ },
),
),
- ),
);
}
@@ -1188,7 +1574,7 @@ where
// Row selected style
.when_some(self.selected_row, |this, _| {
this.when(
- is_selected && self.selection_state == SelectionState::Row,
+ is_selected && self.selection_mode == SelectionMode::Row,
|this| {
this.map(|this| {
if cx.theme().list.active_highlight {
@@ -1245,7 +1631,7 @@ where
.children((0..columns_count).map(|col_ix| {
h_flex()
.left(horizontal_scroll_handle.offset().x)
- .child(self.render_cell(col_ix, window, cx))
+ .child(self.render_cell(None, col_ix, window, cx))
}))
.child(self.delegate.render_last_empty_col(window, cx))
}
diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs
index 42443683..4d545941 100644
--- a/crates/ui/src/theme/mod.rs
+++ b/crates/ui/src/theme/mod.rs
@@ -151,6 +151,8 @@ pub struct Theme {
pub dark_theme: Rc,
pub mode: ThemeMode,
+ pub mode_preference: ThemeModePreference,
+ pub theme_set_name: SharedString,
/// The font family for the application, default is `.SystemUIFont`.
pub font_family: SharedString,
/// The base font size for the application, default is 16px.
@@ -239,14 +241,68 @@ impl Theme {
/// Sync the theme with the system appearance
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
- // Better use window.appearance() for avoid error on Linux.
- // https://github.com/longbridge/gpui-component/issues/104
let appearance = window
.as_ref()
.map(|window| window.appearance())
.unwrap_or_else(|| cx.window_appearance());
- Self::change(appearance, window, cx);
+ let mode = if cx.has_global::() {
+ match cx.global::().mode_preference {
+ ThemeModePreference::Light => ThemeMode::Light,
+ ThemeModePreference::Dark => ThemeMode::Dark,
+ ThemeModePreference::System => appearance.into(),
+ }
+ } else {
+ appearance.into()
+ };
+
+ Self::change(mode, window, cx);
+ }
+
+ /// Apply a theme set with the given mode preference.
+ pub fn apply_theme_set(
+ set: &ThemeSetEntry,
+ preference: ThemeModePreference,
+ window: Option<&mut Window>,
+ cx: &mut App,
+ ) {
+ let appearance = window
+ .as_ref()
+ .map(|w| w.appearance())
+ .unwrap_or_else(|| cx.window_appearance());
+ let system_mode: ThemeMode = appearance.into();
+
+ let Some(resolved) = ThemeRegistry::resolve_theme(set, preference, system_mode) else {
+ tracing::warn!(
+ "ThemeSetEntry '{}' has neither light nor dark variant, falling back to defaults",
+ set.name
+ );
+ return;
+ };
+
+ if !cx.has_global::() {
+ let mut theme = Theme::default();
+ theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
+ theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
+ cx.set_global(theme);
+ }
+
+ let theme = cx.global_mut::();
+ theme.theme_set_name = set.name.clone();
+ theme.mode_preference = preference;
+
+ if let Some(light) = &set.light {
+ theme.light_theme = light.clone();
+ }
+ if let Some(dark) = &set.dark {
+ theme.dark_theme = dark.clone();
+ }
+
+ theme.apply_config(resolved);
+
+ if let Some(window) = window {
+ window.refresh();
+ }
}
/// Sync the Scrollbar showing behavior with the system
@@ -295,6 +351,8 @@ impl From<&ThemeColor> for Theme {
fn from(colors: &ThemeColor) -> Self {
Theme {
mode: ThemeMode::default(),
+ mode_preference: ThemeModePreference::default(),
+ theme_set_name: "Default".into(),
transparent: Hsla::transparent_black(),
font_family: ".SystemUIFont".into(),
font_size: px(16.),
@@ -373,3 +431,12 @@ impl From for ThemeMode {
}
}
}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ThemeModePreference {
+ #[default]
+ System,
+ Light,
+ Dark,
+}
diff --git a/crates/ui/src/theme/registry.rs b/crates/ui/src/theme/registry.rs
index 2dd1eb02..6e59e77c 100644
--- a/crates/ui/src/theme/registry.rs
+++ b/crates/ui/src/theme/registry.rs
@@ -1,4 +1,7 @@
-use crate::{Theme, ThemeColor, ThemeConfig, ThemeMode, ThemeSet, highlighter::HighlightTheme};
+use crate::{
+ Theme, ThemeColor, ThemeConfig, ThemeMode, ThemeModePreference, ThemeSet,
+ highlighter::HighlightTheme,
+};
use anyhow::Result;
use gpui::{App, Global, SharedString};
use notify::Watcher as _;
@@ -10,6 +13,15 @@ use std::{
sync::{Arc, LazyLock},
};
+#[derive(Debug, Clone)]
+pub struct ThemeSetEntry {
+ pub name: SharedString,
+ pub author: Option,
+ pub url: Option,
+ pub light: Option>,
+ pub dark: Option>,
+}
+
const DEFAULT_THEME: &str = include_str!("./default-theme.json");
pub(crate) static DEFAULT_THEME_COLORS: LazyLock<
HashMap, Arc)>,
@@ -78,6 +90,7 @@ pub struct ThemeRegistry {
themes_dir: PathBuf,
default_themes: HashMap>,
themes: HashMap>,
+ theme_sets: HashMap,
has_custom_themes: bool,
}
@@ -148,6 +161,44 @@ impl ThemeRegistry {
&self.default_themes[&ThemeMode::Dark]
}
+ pub fn theme_sets(&self) -> &HashMap {
+ &self.theme_sets
+ }
+
+ pub fn sorted_theme_sets(&self) -> Vec<&ThemeSetEntry> {
+ let mut sets = self.theme_sets.values().collect::>();
+ sets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+ sets
+ }
+
+ pub fn resolve_theme(
+ set: &ThemeSetEntry,
+ preference: ThemeModePreference,
+ system_appearance: ThemeMode,
+ ) -> Option<&Rc> {
+ match preference {
+ ThemeModePreference::Light => set.light.as_ref().or(set.dark.as_ref()),
+ ThemeModePreference::Dark => set.dark.as_ref().or(set.light.as_ref()),
+ ThemeModePreference::System => {
+ if system_appearance.is_dark() {
+ set.dark.as_ref().or(set.light.as_ref())
+ } else {
+ set.light.as_ref().or(set.dark.as_ref())
+ }
+ }
+ }
+ }
+
+ fn create_default_set(&self) -> ThemeSetEntry {
+ ThemeSetEntry {
+ name: "Default".into(),
+ author: None,
+ url: None,
+ light: self.default_themes.get(&ThemeMode::Light).map(Rc::clone),
+ dark: self.default_themes.get(&ThemeMode::Dark).map(Rc::clone),
+ }
+ }
+
fn init_default_themes(&mut self) {
let default_themes: Vec = serde_json::from_str::(DEFAULT_THEME)
.expect("failed to parse default theme.")
@@ -167,6 +218,9 @@ impl ThemeRegistry {
(name, Rc::clone(theme))
})
.collect();
+
+ self.theme_sets
+ .insert("Default".into(), self.create_default_set());
}
fn _watch_themes_dir(themes_dir: PathBuf, cx: &mut App) -> anyhow::Result<()> {
@@ -219,6 +273,7 @@ impl ThemeRegistry {
/// Reload themes from the `themes_dir`.
fn reload(&mut self) -> Result<()> {
let mut themes = vec![];
+ let mut loaded_sets: Vec = vec![];
if self.themes_dir.exists() {
for entry in fs::read_dir(&self.themes_dir)? {
@@ -229,7 +284,8 @@ impl ThemeRegistry {
match serde_json::from_str::(&file_content) {
Ok(theme_set) => {
- themes.extend(theme_set.themes);
+ themes.extend(theme_set.themes.clone());
+ loaded_sets.push(theme_set);
}
Err(e) => {
tracing::error!(
@@ -264,6 +320,33 @@ impl ThemeRegistry {
.insert(theme.name.clone(), Rc::new(theme.clone()));
}
+ // Rebuild theme_sets
+ self.theme_sets.clear();
+ // Re-add default set
+ self.theme_sets
+ .insert("Default".into(), self.create_default_set());
+
+ for set in &loaded_sets {
+ let entry = self
+ .theme_sets
+ .entry(set.name.clone())
+ .or_insert_with(|| ThemeSetEntry {
+ name: set.name.clone(),
+ author: set.author.clone(),
+ url: set.url.clone(),
+ light: None,
+ dark: None,
+ });
+ for config in &set.themes {
+ let rc = Rc::new(config.clone());
+ if config.mode.is_dark() {
+ entry.dark = Some(rc);
+ } else {
+ entry.light = Some(rc);
+ }
+ }
+ }
+
Ok(())
}
}
diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs
index b59cb99e..74481d9b 100644
--- a/crates/ui/src/tree.rs
+++ b/crates/ui/src/tree.rs
@@ -393,6 +393,10 @@ impl TreeState {
}
}
+ pub fn focus(&self, window: &mut Window, cx: &mut App) {
+ self.focus_handle.focus(window, cx);
+ }
+
fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) {
if let Some(selected_ix) = self.selected_ix {
if let Some(entry) = self.entries.get(selected_ix) {
diff --git a/docs/docs/components/settings.md b/docs/docs/components/settings.md
index 0b99da6e..db78c966 100644
--- a/docs/docs/components/settings.md
+++ b/docs/docs/components/settings.md
@@ -85,6 +85,22 @@ Settings::new("app-settings")
])
```
+### Sidebar Shell Layout
+
+Use `SidebarShell` for a floating inset sidebar and optional page header actions.
+
+```rust
+Settings::new("app-settings")
+ .use_sidebar_shell(true)
+ .sidebar_shell_inset(8) // default is 8px
+ .safe_area_top(TITLE_BAR_HEIGHT)
+ .sidebar_title("Settings")
+ .page_header_action(|_window, _cx| {
+ Button::new("save-settings").label("Save").primary().into_any_element()
+ })
+ .pages(vec![...])
+```
+
### Group Variants
```rust
diff --git a/docs/docs/components/sidebar.md b/docs/docs/components/sidebar.md
index e79f7d04..952d2581 100644
--- a/docs/docs/components/sidebar.md
+++ b/docs/docs/components/sidebar.md
@@ -85,6 +85,34 @@ SidebarToggleButton::new()
})
```
+### Floating Sidebar (SidebarShell + Sidebar)
+
+`FloatingSidebar` composes `SidebarShell` with `Sidebar`, handling resize internally.
+It defaults to an 8px inset from the window edges and can be resized when expanded.
+
+```rust
+use gpui_component::{FloatingSidebar, Side};
+use gpui_component::sidebar::{SidebarGroup, SidebarMenu, SidebarMenuItem};
+
+let mut collapsed = false;
+
+FloatingSidebar::new("floating-sidebar")
+ .side(Side::Left)
+ .width(260)
+ .min_width(200)
+ .max_width(420)
+ .inset(8) // optional override (defaults to 8px)
+ .collapsed(collapsed)
+ .child(
+ SidebarGroup::new("Navigation")
+ .child(
+ SidebarMenu::new()
+ .child(SidebarMenuItem::new("Dashboard"))
+ .child(SidebarMenuItem::new("Settings"))
+ )
+ )
+```
+
### Nested Menu Items
```rust
diff --git a/docs/docs/components/table.md b/docs/docs/components/table.md
index 1e81c971..c899e37f 100644
--- a/docs/docs/components/table.md
+++ b/docs/docs/components/table.md
@@ -5,12 +5,28 @@ description: High-performance data table with virtual scrolling, sorting, filter
# Table
-A comprehensive data table component designed for handling large datasets with high performance. Features virtual scrolling, column configuration, sorting, filtering, row selection, and custom cell rendering. Perfect for displaying tabular data with thousands of rows while maintaining smooth performance.
+A comprehensive data table component designed for handling large datasets with high performance. Features virtual scrolling, column configuration, sorting, filtering, row/column/cell selection, and custom cell rendering. Perfect for displaying tabular data with thousands of rows while maintaining smooth performance.
+
+## Key Features
+
+- **Multiple Selection Modes**: Row, column, and individual cell selection
+- **Virtual Scrolling**: Handle thousands of rows with smooth performance
+- **Column Management**: Resizable, movable, and fixed columns
+- **Sorting**: Built-in column sorting support
+- **Keyboard Navigation**: Full keyboard support for all selection modes
+- **Custom Cell Rendering**: Render any content in table cells
+- **Context Menus**: Right-click support for rows and cells
+- **Infinite Loading**: Load more data as user scrolls
+- **Events**: Comprehensive event system for user interactions
## Import
```rust
-use gpui_component::table::{Table, TableState, TableDelegate, Column, ColumnSort, ColumnFixed, TableEvent};
+use gpui_component::table::{
+ Table, TableState, TableDelegate,
+ Column, ColumnSort, ColumnFixed,
+ TableEvent
+};
```
## Usage
@@ -279,6 +295,36 @@ impl TableDelegate for MyTableDelegate {
}
```
+### Selection Modes
+
+The table supports three distinct selection modes:
+
+```rust
+// Row selection mode (default)
+let state = cx.new(|cx| {
+ TableState::new(delegate, window, cx)
+ .row_selectable(true) // Enable row selection
+ .col_selectable(false)
+ .cell_selectable(false)
+});
+
+// Column selection mode
+let state = cx.new(|cx| {
+ TableState::new(delegate, window, cx)
+ .row_selectable(false)
+ .col_selectable(true) // Enable column selection
+ .cell_selectable(false)
+});
+
+// Cell selection mode
+let state = cx.new(|cx| {
+ TableState::new(delegate, window, cx)
+ .row_selectable(true) // Keep row selection for row selector column
+ .col_selectable(false)
+ .cell_selectable(true) // Enable cell selection
+});
+```
+
### Column Resizing and Moving
Enable dynamic column management:
@@ -436,9 +482,196 @@ impl UserTableDelegate {
}
```
-## Keyboard shortcuts
+### Cell Selection
+
+Enable individual cell selection for more granular control:
+
+```rust
+let state = cx.new(|cx| {
+ TableState::new(delegate, window, cx)
+ .cell_selectable(true) // Enable cell selection
+ .row_selectable(true) // Also allow row selection
+});
+
+// Listen for cell events
+cx.subscribe_in(&state, window, |view, table, event, _, cx| {
+ match event {
+ TableEvent::SelectCell(row_ix, col_ix) => {
+ println!("Selected cell: ({}, {})", row_ix, col_ix);
+ }
+ TableEvent::DoubleClickedCell(row_ix, col_ix) => {
+ // Open editor or detail view
+ open_cell_editor(row_ix, col_ix);
+ }
+ TableEvent::RightClickedCell(row_ix, col_ix) => {
+ // Show cell-specific context menu
+ show_cell_context_menu(row_ix, col_ix);
+ }
+ TableEvent::ClearSelection => {
+ println!("Selection cleared");
+ }
+ _ => {}
+ }
+}).detach();
+```
+
+#### Cell Selection Features
+
+When cell selection is enabled:
+
+- **Click to select**: Click on any cell to select it
+- **Row selector column**: A dedicated column appears on the left for selecting entire rows
+- **Keyboard navigation**: Arrow keys navigate between cells (not rows/columns)
+- **Double-click support**: Trigger actions like editing by double-clicking cells
+- **Right-click support**: Show context menus specific to cell content
+- **Visual feedback**: Selected cells show highlight with border
+
+#### Programmatic Cell Selection
+
+```rust
+// Get the currently selected cell
+if let Some((row_ix, col_ix)) = state.read(cx).selected_cell() {
+ println!("Current cell: ({}, {})", row_ix, col_ix);
+}
+
+// Select a specific cell programmatically
+state.update(cx, |state, cx| {
+ state.set_selected_cell(5, 3, cx); // Select row 5, column 3
+});
+
+// Clear all selections
+state.update(cx, |state, cx| {
+ state.clear_selection(cx);
+});
+```
+
+#### Non-selectable Columns
+
+Prevent specific columns from being selected (useful for action columns):
+
+```rust
+Column::new("actions", "Actions")
+ .width(100.)
+ .selectable(false) // This column's cells cannot be selected
+ .resizable(false)
+```
+
+#### Cell Selection with Custom Rendering
+
+```rust
+impl TableDelegate for MyTableDelegate {
+ fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context>) -> impl IntoElement {
+ let row = &self.data[row_ix];
+ let col = &self.columns[col_ix];
+
+ // Render different content based on whether cell is selected
+ let is_selected = cx.entity().read(cx).selected_cell() == Some((row_ix, col_ix));
+
+ match col.key.as_ref() {
+ "editable_field" => {
+ if is_selected {
+ // Show input when selected
+ Input::new(format!("cell-{}-{}", row_ix, col_ix))
+ .value(row.field_value.clone())
+ .into_any_element()
+ } else {
+ // Show plain text when not selected
+ div().child(row.field_value.clone()).into_any_element()
+ }
+ }
+ _ => div().child(row.get_value(col.key.as_ref())).into_any_element()
+ }
+ }
+}
+```
+
+## Keyboard Shortcuts
+
+### Row Selection Mode (default)
- `↑/↓` - Navigate rows
- `←/→` - Navigate columns
-- `Enter/Space` - Select row/column
+- `Home` - Jump to first row/column
+- `End` - Jump to last row/column
+- `PageUp/PageDown` - Navigate by page
- `Escape` - Clear selection
+
+### Cell Selection Mode
+
+- `↑/↓` - Navigate up/down within current column
+- `←/→` - Navigate left/right within current row
+- `Tab` - Move to next cell (right, then next row)
+- `Shift+Tab` - Move to previous cell
+- `Home` - Jump to first cell in current row
+- `End` - Jump to last cell in current row
+- `PageUp/PageDown` - Navigate by page within current column
+- `Escape` - Clear selection
+
+## API Reference
+
+### Core Types
+
+- [Table] - The table component
+- [TableState] - Table state management
+- [TableDelegate] - Trait for implementing table data source
+- [Column] - Column configuration
+- [TableEvent] - Table events (selection, clicks, etc.)
+
+### Column Types
+
+- [ColumnSort] - Column sort direction enum
+- [ColumnFixed] - Column fixed position enum
+
+### Methods
+
+#### TableState
+
+- `new(delegate, window, cx)` - Create a new table state
+- `cell_selectable(bool)` - Enable/disable cell selection
+- `row_selectable(bool)` - Enable/disable row selection
+- `col_selectable(bool)` - Enable/disable column selection
+- `selected_cell()` - Get currently selected cell
+- `set_selected_cell(row_ix, col_ix, cx)` - Select a specific cell
+- `selected_row()` - Get currently selected row
+- `selected_col()` - Get currently selected column
+- `clear_selection(cx)` - Clear all selections
+- `scroll_to_row(row_ix, cx)` - Scroll to specific row
+- `scroll_to_col(col_ix, cx)` - Scroll to specific column
+
+#### Column
+
+- `new(key, name)` - Create a new column
+- `width(pixels)` - Set column width
+- `sortable()` - Make column sortable
+- `ascending()` - Set default sort to ascending
+- `descending()` - Set default sort to descending
+- `text_right()` - Right-align column text
+- `text_center()` - Center-align column text
+- `fixed(ColumnFixed)` - Pin column to left
+- `resizable(bool)` - Enable/disable column resizing
+- `movable(bool)` - Enable/disable column moving
+- `selectable(bool)` - Enable/disable column/cell selection
+- `paddings(edges)` - Set custom padding
+- `min_width(pixels)` - Set minimum width
+- `max_width(pixels)` - Set maximum width
+
+### Events
+
+- `SelectRow(usize)` - Row selected
+- `DoubleClickedRow(usize)` - Row double-clicked
+- `SelectColumn(usize)` - Column selected
+- `SelectCell(usize, usize)` - Cell selected (row_ix, col_ix)
+- `DoubleClickedCell(usize, usize)` - Cell double-clicked (row_ix, col_ix)
+- `RightClickedCell(usize, usize)` - Cell right-clicked (row_ix, col_ix)
+- `RightClickedRow(Option)` - Row right-clicked
+- `ColumnWidthsChanged(Vec)` - Column widths changed
+- `MoveColumn(usize, usize)` - Column moved (from_ix, to_ix)
+- `ClearSelection` - All selections cleared
+
+[Table]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.Table.html
+[TableState]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.TableState.html
+[TableDelegate]: https://docs.rs/gpui-component/latest/gpui_component/table/trait.TableDelegate.html
+[Column]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.Column.html
+[TableEvent]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.TableEvent.html
+[ColumnSort]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.ColumnSort.html
+[ColumnFixed]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.ColumnFixed.html
diff --git a/docs/docs/theme.md b/docs/docs/theme.md
index 497c12c6..6ee30a3a 100644
--- a/docs/docs/theme.md
+++ b/docs/docs/theme.md
@@ -47,6 +47,42 @@ pub fn init(cx: &mut App) {
}
```
+## Theme Sets
+
+Theme files contain a [ThemeSet] with one or more variants (light and/or dark). The [ThemeRegistry] groups these by theme name, allowing users to select a color theme (e.g., "Solarized") rather than individual variants like "Solarized Light" or "Solarized Dark".
+
+This design allows themes to automatically adapt to the user's appearance preference without requiring separate theme selection for light and dark modes.
+
+## Mode Preference
+
+Users can control how theme variants are applied using [ThemeModePreference]:
+
+- **`System`**: Automatically follows the OS appearance setting. When the system switches between light and dark mode, the theme updates accordingly.
+- **`Light`**: Always uses the light variant of the selected theme set.
+- **`Dark`**: Always uses the dark variant of the selected theme set.
+
+## Fallback Behavior
+
+If a theme set only provides one variant (e.g., only dark), that variant is used for both light and dark modes. This ensures themes always render correctly even if they don't provide both variants.
+
+## Usage Example
+
+```rs
+use gpui_component::{Theme, ThemeModePreference, ThemeRegistry};
+
+// Apply a theme set with System mode (auto light/dark switching)
+if let Some(set) = ThemeRegistry::global(cx).theme_sets().get("Solarized") {
+ Theme::apply_theme_set(set, ThemeModePreference::System, Some(window), cx);
+}
+
+// Or apply with a fixed mode
+if let Some(set) = ThemeRegistry::global(cx).theme_sets().get("Ayu") {
+ Theme::apply_theme_set(set, ThemeModePreference::Dark, Some(window), cx);
+}
+```
+
[ActiveTheme]: https://docs.rs/gpui-component/latest/gpui_component/theme/trait.ActiveTheme.html
[ThemeRegistry]: https://docs.rs/gpui-component/latest/gpui_component/theme/struct.ThemeRegistry.html
+[ThemeSet]: https://docs.rs/gpui-component/latest/gpui_component/theme/struct.ThemeSet.html
+[ThemeModePreference]: https://docs.rs/gpui-component/latest/gpui_component/theme/enum.ThemeModePreference.html
[App]: https://docs.rs/gpui/latest/gpui/struct.App.html
diff --git a/themes/ayu.json b/themes/ayu.json
index 6e23f425..7070a4c5 100644
--- a/themes/ayu.json
+++ b/themes/ayu.json
@@ -1,6 +1,6 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
- "name": "Ayu Light",
+ "name": "Ayu",
"author": "Zed Industries",
"url": "https://github.com/zed-industries/zed/blob/e62dd2a0e584154886ff86cc1e9e3e060558b977/assets/themes/ayu",
"themes": [