Add client-neutral native MCP control

This commit is contained in:
Hakan Seven committed 2026-09-04 23:27:03 +03:00
1 parent 28cc473062
commit 071e224d26
17 files changed
+804 -118

No files matched your search

+2 -2
View File
@@ -1823,6 +1823,8 @@ impl OpenCADStudio {
dyn_capturing,
&self.history_content,
self.win_size.1,
self.control.enabled,
self.control_busy(),
);
let center_stack: Element<'_, Message> = if thumbnail_capture_clean {
workspace
@@ -1987,8 +1989,6 @@ impl OpenCADStudio {
self.quick_properties,
tab.scene.selection_filter_active(),
self.selection_cycling,
self.control.enabled,
self.control_busy(),
&self.statusbar_config,
status_menu_data,
)
+6 -1
View File
@@ -1,7 +1,8 @@
//! Command-line interface.
//!
//! Parsing lives here; `main` interprets the result. Three run modes split out
//! Parsing lives here; `main` interprets the result. Four run modes split out
//! of the parsed args:
//! - `--mcp` local stdio MCP server for AI clients
//! - `--serve` headless JSON automation server (see `app::serve`)
//! - `--export IN OUT` one-shot headless format conversion, then exit
//! - otherwise launch the GUI editor, configured via [`GuiConfig`]
@@ -59,6 +60,10 @@ pub struct Cli {
#[arg(long)]
pub serve: bool,
/// Expose the running desktop editor to AI clients over MCP stdio.
#[arg(long)]
pub mcp: bool,
/// TCP port for --serve (defaults to stdin/stdout).
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,
+2
View File
@@ -10,6 +10,8 @@ pub mod i18n;
pub mod io;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod network;
#[cfg(not(target_arch = "wasm32"))]
pub mod mcp;
pub mod modules;
pub mod patreon;
pub mod discussions;
+10
View File
@@ -14,6 +14,8 @@ mod i18n;
mod io;
#[cfg(not(target_arch = "wasm32"))]
mod network;
#[cfg(not(target_arch = "wasm32"))]
mod mcp;
mod modules;
mod patreon;
mod discussions;
@@ -75,6 +77,14 @@ fn main() -> iced::Result {
std::process::exit(if ok { 0 } else { 1 });
}
// MCP is a client-neutral local entry point. It uses only stdin,
// stdout and the authenticated GUI bridge, so it must run before any
// logging or graphics setup can write to the protocol stream.
if args.mcp {
mcp::run();
return Ok(());
}
// Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then
// surfaces wgpu / iced / winit diagnostics that are otherwise silent.
if let Some(level) = &args.log {
+595
View File
@@ -0,0 +1,595 @@
//! Client-neutral MCP adapter for the live desktop editor.
//!
//! `OpenCADStudio --mcp` speaks MCP over stdio. All drawing work is forwarded
//! to the authenticated GUI control bridge; this module contains no geometry.
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{self, BufRead, BufReader, Read, Write},
net::TcpStream,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread,
time::{Duration, Instant},
};
const PROTOCOL_VERSION: &str = "2025-11-25";
const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const INSTRUCTIONS: &str = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path.";
const READ_OPS: &[&str] = &[
"state",
"hello",
"query",
"entities",
"layers",
"header",
"properties",
"measure",
"history",
"commands",
"events",
"operation",
];
#[derive(Clone, Deserialize)]
struct Descriptor {
session_id: String,
port: u16,
token: String,
}
struct GuiClient {
descriptor: Descriptor,
state: Value,
client_id: String,
}
fn random_id() -> Result<String, String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|error| error.to_string())?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[cfg(unix)]
fn private_descriptor(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let Ok(file) = path.metadata() else {
return false;
};
let Some(parent) = path.parent() else {
return false;
};
let Ok(directory) = parent.metadata() else {
return false;
};
file.uid() == directory.uid() && file.mode() & 0o077 == 0
}
#[cfg(not(unix))]
fn private_descriptor(_: &Path) -> bool {
true
}
fn exchange(descriptor: &Descriptor, request: Value, timeout: Duration) -> Result<Value, String> {
let mut object = request
.as_object()
.cloned()
.ok_or_else(|| "GUI request must be an object".to_string())?;
object.insert("token".into(), Value::String(descriptor.token.clone()));
object.insert(
"session_id".into(),
Value::String(descriptor.session_id.clone()),
);
object.insert("protocol".into(), Value::from(1));
let mut wire = serde_json::to_vec(&Value::Object(object)).map_err(|e| e.to_string())?;
wire.push(b'\n');
if wire.len() > MAX_REQUEST {
return Err("Request exceeds 1 MiB".into());
}
let mut stream =
TcpStream::connect(("127.0.0.1", descriptor.port)).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(timeout))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(timeout))
.map_err(|error| error.to_string())?;
stream.write_all(&wire).map_err(|error| error.to_string())?;
let mut response = String::new();
BufReader::new(stream)
.take(MAX_RESPONSE + 1)
.read_to_string(&mut response)
.map_err(|error| error.to_string())?;
if response.is_empty() || response.len() as u64 > MAX_RESPONSE {
return Err("No valid OCS response; query request_id before retrying a mutation".into());
}
serde_json::from_str(response.trim_end()).map_err(|error| error.to_string())
}
fn descriptors() -> Result<Vec<(Descriptor, Value)>, String> {
let directory = crate::config::config_dir()
.ok_or_else(|| "No user configuration directory".to_string())?
.join("automation");
let Ok(entries) = directory.read_dir() else {
return Ok(Vec::new());
};
let mut paths: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
.collect();
paths.sort();
let mut found = Vec::new();
for path in paths {
if !private_descriptor(&path) {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(descriptor) = serde_json::from_str::<Descriptor>(&text) else {
continue;
};
let Ok(state) = exchange(&descriptor, json!({"op":"hello"}), Duration::from_secs(1)) else {
continue;
};
if state["ok"].as_bool() == Some(true)
&& state["session_id"].as_str() == Some(descriptor.session_id.as_str())
{
found.push((descriptor, state));
}
}
Ok(found)
}
fn log_file() -> Result<File, String> {
let directory = crate::config::config_dir()
.ok_or_else(|| "No user configuration directory".to_string())?
.join("automation");
std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
OpenOptions::new()
.create(true)
.append(true)
.open(directory.join("gui.log"))
.map_err(|error| error.to_string())
}
fn start_gui() -> Result<Child, String> {
let executable = std::env::current_exe().map_err(|error| error.to_string())?;
let log = log_file()?;
let stderr = log.try_clone().map_err(|error| error.to_string())?;
Command::new(executable)
.arg("--new-instance")
.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(stderr))
.spawn()
.map_err(|error| error.to_string())
}
fn sessions(launch_if_none: bool) -> Result<Vec<Value>, String> {
let mut available = descriptors()?;
if available.is_empty() && launch_if_none {
let mut child = start_gui()?;
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
return Err(format!("OpenCADStudio exited while starting ({status})"));
}
thread::sleep(Duration::from_millis(200));
available = descriptors()?;
if !available.is_empty() {
break;
}
}
if available.is_empty() {
return Err("OpenCADStudio is still starting; call ocs_sessions again".into());
}
}
Ok(available.into_iter().map(|(_, state)| state).collect())
}
fn insert_default(object: &mut Map<String, Value>, key: &str, value: Value) {
if !object.contains_key(key) {
object.insert(key.into(), value);
}
}
impl GuiClient {
fn connect(session_id: &str) -> Result<Self, String> {
let mut matching: Vec<_> = descriptors()?
.into_iter()
.filter(|(descriptor, _)| descriptor.session_id == session_id)
.collect();
if matching.len() != 1 {
return Err(format!(
"Choose session_id from ocs_sessions; found {} matching sessions",
matching.len()
));
}
let (descriptor, state) = matching.remove(0);
Ok(Self {
descriptor,
state,
client_id: random_id()?,
})
}
fn request(&mut self, request: Value, wait_seconds: f64) -> Result<Value, String> {
let mut object = request
.as_object()
.cloned()
.ok_or_else(|| "request must be an object".to_string())?;
let op = object
.get("op")
.and_then(Value::as_str)
.ok_or_else(|| "request must contain op".to_string())?
.to_string();
if !READ_OPS.contains(&op.as_str()) {
insert_default(&mut object, "request_id", Value::String(random_id()?));
insert_default(
&mut object,
"client_id",
Value::String(self.client_id.clone()),
);
insert_default(
&mut object,
"document_id",
self.state["document_id"].clone(),
);
insert_default(&mut object, "revision", self.state["revision"].clone());
if ["input", "property", "run", "action", "save", "undo", "redo"].contains(&op.as_str())
{
insert_default(&mut object, "selection", self.state["selection"].clone());
}
}
let request_id = object.get("request_id").cloned();
let mut response = exchange(
&self.descriptor,
Value::Object(object),
Duration::from_secs(15),
)?;
let wait = wait_seconds.clamp(0.0, 60.0);
let deadline = Instant::now() + Duration::from_secs_f64(wait);
while matches!(response["status"].as_str(), Some("accepted" | "running"))
&& Instant::now() < deadline
{
let Some(request_id) = request_id.clone() else {
break;
};
thread::sleep(Duration::from_millis(50));
response = exchange(
&self.descriptor,
json!({"op":"operation","request_id":request_id}),
Duration::from_secs(15),
)?;
}
if response.get("state").is_some() {
self.state = response["state"].clone();
} else if matches!(op.as_str(), "hello" | "state") && response["ok"].as_bool() == Some(true)
{
self.state = response.clone();
}
Ok(response)
}
}
fn client<'a>(
clients: &'a mut HashMap<String, GuiClient>,
session_id: &str,
) -> Result<&'a mut GuiClient, String> {
if !clients.contains_key(session_id) {
clients.insert(session_id.into(), GuiClient::connect(session_id)?);
}
Ok(clients.get_mut(session_id).expect("client inserted"))
}
fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, String> {
arguments[key]
.as_str()
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("Missing {key}"))
}
fn call_tool(
name: &str,
arguments: &Value,
clients: &mut HashMap<String, GuiClient>,
) -> Result<Value, String> {
match name {
"ocs_sessions" => {
let launch = arguments["launch_if_none"].as_bool().unwrap_or(true);
Ok(Value::Array(sessions(launch)?))
}
"ocs_read" => {
let session_id = required_string(arguments, "session_id")?;
let op = arguments["op"].as_str().unwrap_or("state");
if !READ_OPS.contains(&op) {
return Err("Use ocs_execute for mutations".into());
}
let mut request = arguments["parameters"]
.as_object()
.cloned()
.unwrap_or_default();
request.insert("op".into(), Value::String(op.into()));
client(clients, session_id)?.request(Value::Object(request), 30.0)
}
"ocs_execute" => {
let session_id = required_string(arguments, "session_id")?;
let request = arguments["request"]
.as_object()
.cloned()
.map(Value::Object)
.ok_or_else(|| "Missing request object".to_string())?;
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
client(clients, session_id)?.request(request, wait)
}
"ocs_capture" => {
let session_id = required_string(arguments, "session_id")?;
let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?));
let result = client(clients, session_id)?
.request(json!({"op":"capture","path":path.to_string_lossy()}), 30.0)?;
if result["ok"].as_bool() != Some(true)
|| result["status"].as_str() != Some("completed")
{
return Err(result.to_string());
}
let bytes = std::fs::read(&path).map_err(|error| error.to_string())?;
let _ = std::fs::remove_file(path);
Ok(json!({"$image":BASE64.encode(bytes)}))
}
_ => Err(format!("Unknown tool: {name}")),
}
}
fn tool_definitions() -> Value {
json!([
{
"name":"ocs_sessions",
"description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.",
"inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true}},"additionalProperties":false},
"annotations":{"title":"List OCS sessions","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_read",
"description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object"}},"required":["session_id"],"additionalProperties":false},
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_execute",
"description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"},"request":{"type":"object"},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false},
"annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}
},
{
"name":"ocs_capture",
"description":"Capture the actual current OpenCADStudio window as PNG for visual verification.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false},
"annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
}
])
}
fn tool_result(value: Value) -> Value {
if let Some(image) = value.get("$image").and_then(Value::as_str) {
return json!({"content":[{"type":"image","data":image,"mimeType":"image/png"}]});
}
let structured = if value.is_object() {
value.clone()
} else {
json!({"result":value.clone()})
};
json!({
"content":[{"type":"text","text":value.to_string()}],
"structuredContent":structured
})
}
fn error_result(message: impl ToString) -> Value {
json!({"content":[{"type":"text","text":message.to_string()}],"isError":true})
}
fn response(id: Value, result: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"result":result})
}
fn server_info() -> Value {
json!({"name":"OpenCADStudio","title":"Open CAD Studio","version":env!("CARGO_PKG_VERSION")})
}
fn modern_request(params: &Value) -> bool {
params["_meta"]["io.modelcontextprotocol/protocolVersion"].as_str()
== Some(MODERN_PROTOCOL_VERSION)
}
fn protocol_result(mut result: Value, modern: bool) -> Value {
if modern {
let object = result
.as_object_mut()
.expect("MCP results are JSON objects");
object
.entry("resultType")
.or_insert_with(|| Value::String("complete".into()));
object.insert(
"_meta".into(),
json!({"io.modelcontextprotocol/serverInfo":server_info()}),
);
}
result
}
fn rpc_error(id: Value, code: i64, message: impl ToString) -> Value {
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.to_string()}})
}
fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> Option<Value> {
let id = message.get("id").cloned();
let method = message.get("method").and_then(Value::as_str)?;
if id.is_none() {
return None;
}
let id = id.unwrap();
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
let modern = modern_request(&params);
Some(match method {
"initialize" => {
let requested = params["protocolVersion"]
.as_str()
.unwrap_or(PROTOCOL_VERSION);
let protocol =
if ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"].contains(&requested) {
requested
} else {
PROTOCOL_VERSION
};
response(
id,
json!({
"protocolVersion":protocol,
"capabilities":{"tools":{"listChanged":false}},
"serverInfo":server_info(),
"instructions":INSTRUCTIONS
}),
)
}
"server/discover" => response(
id,
protocol_result(
json!({
"supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION],
"capabilities":{"tools":{}},
"instructions":INSTRUCTIONS
}),
true,
),
),
"ping" => response(id, protocol_result(json!({}), modern)),
"tools/list" => response(
id,
protocol_result(json!({"tools":tool_definitions()}), modern),
),
"tools/call" => {
let Some(name) = params["name"].as_str() else {
return Some(rpc_error(id, -32602, "Missing tool name"));
};
let arguments = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let result = call_tool(name, &arguments, clients)
.map(tool_result)
.unwrap_or_else(error_result);
response(id, protocol_result(result, modern))
}
_ => rpc_error(id, -32601, format!("Method not found: {method}")),
})
}
/// Run the MCP stdio loop until the client closes stdin.
pub fn run() {
let stdin = io::stdin();
let stdout = io::stdout();
let mut output = stdout.lock();
let mut clients = HashMap::new();
for line in stdin.lock().lines() {
let response = match line {
Ok(line) if !line.trim().is_empty() => match serde_json::from_str::<Value>(&line) {
Ok(message) => handle_message(message, &mut clients),
Err(error) => Some(rpc_error(Value::Null, -32700, error)),
},
Ok(_) => None,
Err(error) => {
eprintln!("MCP input error: {error}");
break;
}
};
if let Some(response) = response {
if writeln!(output, "{response}")
.and_then(|_| output.flush())
.is_err()
{
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advertises_the_shared_tools() {
let tools = tool_definitions();
let names: Vec<_> = tools
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"]
);
}
#[test]
fn negotiates_and_lists_tools() {
let mut clients = HashMap::new();
let initialized = handle_message(
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}),
&mut clients,
)
.unwrap();
assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25");
assert!(initialized["result"]["instructions"]
.as_str()
.unwrap()
.contains("geometry kernel"));
let listed = handle_message(
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
&mut clients,
)
.unwrap();
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
}
#[test]
fn supports_modern_stateless_discovery() {
let mut clients = HashMap::new();
let discovered = handle_message(
json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}),
&mut clients,
)
.unwrap();
assert_eq!(discovered["result"]["resultType"], "complete");
assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28");
assert_eq!(
discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
"OpenCADStudio"
);
}
#[test]
fn read_tool_rejects_mutations_before_connecting() {
let mut clients = HashMap::new();
let called = handle_message(
json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}),
&mut clients,
)
.unwrap();
assert_eq!(called["result"]["isError"], true);
}
}
+47 -2
View File
@@ -42,6 +42,25 @@ fn cmd_input_id() -> iced::widget::Id {
iced::widget::Id::new(CMD_INPUT_ID)
}
fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) {
if !enabled {
(
"MCP control is off",
Color::from_rgb(0.90, 0.35, 0.35),
)
} else if busy {
(
"MCP is handling a request",
Color::from_rgb(0.95, 0.72, 0.25),
)
} else {
(
"MCP control is ready",
Color::from_rgb(0.35, 0.85, 0.55),
)
}
}
/// Drop a prompt's "[A / B / …]" option listing — used for the pinned line
/// when the same options render as clickable buttons beside it. Collapses the
/// leftover double spaces and the orphaned " :" the removal leaves behind.
@@ -506,6 +525,8 @@ impl CommandLine {
dyn_capturing: bool,
history_content: &'a text_editor::Content,
window_height: f32,
control_enabled: bool,
control_busy: bool,
) -> Element<'a, Message> {
// Only the most recent entries pushed within the last few
// seconds show on the overlay. The dropdown button keeps the
@@ -715,7 +736,24 @@ impl CommandLine {
.align_y(iced::alignment::Vertical::Center),
]
.width(Length::Fill);
let input_row = row![prompt, literal_btn, input_with_history]
let (mcp_tooltip, mcp_color) = mcp_status(control_enabled, control_busy);
let mcp_btn = button(text("MCP").size(11))
.on_press(Message::ControlToggle)
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
style.background = Some(Background::Color(mcp_color));
style.text_color = Color::from_rgb(0.08, 0.08, 0.10);
style.border.color = mcp_color;
style.border.width = 1.0;
style
})
.padding([2, 8])
.width(Length::Fixed(50.0));
let mcp_tip = container(text(mcp_tooltip).size(11))
.padding([3, 6])
.style(container::bordered_box);
let mcp_btn = tooltip(mcp_btn, mcp_tip, tooltip::Position::Top).gap(4);
let input_row = row![prompt, literal_btn, input_with_history, mcp_btn]
.spacing(4)
.align_y(iced::Center);
@@ -975,7 +1013,7 @@ fn history_highlight_format(
#[cfg(test)]
mod tests {
use super::{ranked_matches, CommandLine};
use super::{mcp_status, ranked_matches, CommandLine};
use crate::t;
use rustc_hash::FxHashMap;
@@ -986,6 +1024,13 @@ mod tests {
.collect()
}
#[test]
fn mcp_status_distinguishes_off_ready_and_busy() {
assert_eq!(mcp_status(false, false).0, "MCP control is off");
assert_eq!(mcp_status(true, false).0, "MCP control is ready");
assert_eq!(mcp_status(true, true).0, "MCP is handling a request");
}
#[test]
fn dynamic_plugin_commands_surface_in_autocomplete() {
let none = FxHashMap::default();
-31
View File
@@ -122,8 +122,6 @@ impl StatusBar {
selection_filter_active: bool,
// Whether selection cycling is enabled.
selection_cycling: bool,
control_enabled: bool,
control_busy: bool,
// Which pills the user has chosen to show on the bar.
config: &'a StatusBarConfig,
menu_data: StatusMenuData<'a>,
@@ -495,35 +493,6 @@ impl StatusBar {
// use the remaining space on the final tab row when they fit; otherwise
// WrapBar adds another right-aligned row.
let mut left: Vec<Element<'_, Message>> = Vec::new();
let control_label = if !control_enabled {
"Control: off"
} else if control_busy {
"Control: busy"
} else {
"Control: ready"
};
let control_color = if !control_enabled {
Color::from_rgb(0.90, 0.35, 0.35)
} else if control_busy {
Color::from_rgb(0.95, 0.72, 0.25)
} else {
Color::from_rgb(0.35, 0.85, 0.55)
};
left.push(
button(text(control_label).size(11))
.on_press(Message::ControlToggle)
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
style.background = Some(Background::Color(control_color));
style.text_color = Color::from_rgb(0.08, 0.08, 0.10);
style.border.color = control_color;
style.border.width = 1.0;
style
})
.padding([4, 8])
.width(Length::Fixed(100.0))
.into(),
);
left.push(menu_btn);
if show_layout_tabs {
let reorderable_layouts: Arc<[String]> = reorderable_layouts.into();