feat: 预设管理页与发布面板内联变量绑定(占位符提取、快捷改值、渲染预览)
This commit is contained in:
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||||||
use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariant, ButtonVariants as _};
|
use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariant, ButtonVariants as _};
|
||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||||
use gpui_kit::component::select::{Select, SelectState};
|
use gpui_kit::component::select::{Select, SelectState};
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
@@ -19,13 +19,13 @@ use crate::ui::IconName;
|
|||||||
use gpui_kit::{
|
use gpui_kit::{
|
||||||
div, hsla, px, App, AppContext as _, ClipboardItem, Context, Entity, Hsla,
|
div, hsla, px, App, AppContext as _, ClipboardItem, Context, Entity, Hsla,
|
||||||
InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
|
InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
|
||||||
StatefulInteractiveElement as _, Styled as _, Window,
|
StatefulInteractiveElement as _, Styled as _, Subscription as InputSubscription, Window,
|
||||||
};
|
};
|
||||||
use gpui_kit::component::IndexPath;
|
use gpui_kit::component::IndexPath;
|
||||||
|
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
render_template, render_will_templates, ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord,
|
render_template, render_will_templates, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable,
|
||||||
PayloadFormat, PublishParams, SubscribeOptions, Subscription,
|
LogLevel, MqttRecord, PayloadFormat, PublishParams, SubscribeOptions, Subscription,
|
||||||
};
|
};
|
||||||
use crate::mqtt::MqttEngine;
|
use crate::mqtt::MqttEngine;
|
||||||
use crate::ui::app::MqttXApp;
|
use crate::ui::app::MqttXApp;
|
||||||
@@ -176,6 +176,12 @@ pub struct ConnectionView {
|
|||||||
|
|
||||||
// 预设
|
// 预设
|
||||||
preset_name: Entity<InputState>,
|
preset_name: Entity<InputState>,
|
||||||
|
|
||||||
|
// 发布面板内联变量绑定
|
||||||
|
/// 「变量」小节展开状态(仅在提取到占位符时渲染整节)
|
||||||
|
show_vars: bool,
|
||||||
|
/// 提取到的 `{{key}}` → 值输入框与变更订阅,按提取顺序排列
|
||||||
|
var_rows: Vec<(String, Entity<InputState>, InputSubscription)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionView {
|
impl ConnectionView {
|
||||||
@@ -244,6 +250,8 @@ impl ConnectionView {
|
|||||||
msg_dir: DirFilter::All,
|
msg_dir: DirFilter::All,
|
||||||
detail_format: DetailFormat::Auto,
|
detail_format: DetailFormat::Auto,
|
||||||
preset_name,
|
preset_name,
|
||||||
|
show_vars: true,
|
||||||
|
var_rows: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1862,11 +1870,18 @@ impl ConnectionView {
|
|||||||
let user_props = self.user_props.clone();
|
let user_props = self.user_props.clone();
|
||||||
let retain = self.retain;
|
let retain = self.retain;
|
||||||
let show_props = self.show_props;
|
let show_props = self.show_props;
|
||||||
|
let show_vars = self.show_vars;
|
||||||
let is_v5 = self.is_v5(cx);
|
let is_v5 = self.is_v5(cx);
|
||||||
|
|
||||||
// 发布预设
|
// 发布预设
|
||||||
let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default();
|
let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default();
|
||||||
let connected = self.engine.is_connected(&self.conn_id);
|
let connected = self.engine.is_connected(&self.conn_id);
|
||||||
|
// 当前提取到的占位符数量(仅用于提示,行构建在 sync_var_rows 完成)
|
||||||
|
let var_count = extract_placeholder_keys(
|
||||||
|
&self.pub_topic.read(cx).value(),
|
||||||
|
&self.payload.read(cx).value(),
|
||||||
|
)
|
||||||
|
.len();
|
||||||
|
|
||||||
let mut card = v_flex()
|
let mut card = v_flex()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1900,6 +1915,28 @@ impl ConnectionView {
|
|||||||
.child(div().w(px(92.)).child(Select::new(&pub_qos).small()))
|
.child(div().w(px(92.)).child(Select::new(&pub_qos).small()))
|
||||||
.child(div().w(px(124.)).child(Select::new(&payload_format).small()))
|
.child(div().w(px(124.)).child(Select::new(&payload_format).small()))
|
||||||
.child(self.render_preset_menu(presets, cx))
|
.child(self.render_preset_menu(presets, cx))
|
||||||
|
.child({
|
||||||
|
// 变量注入开关:展开/收起内联绑定面板
|
||||||
|
let count = var_count;
|
||||||
|
Button::new("vars-toggle")
|
||||||
|
.icon(IconName::Variable)
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.when(show_vars, |b| b.selected(true))
|
||||||
|
.tooltip(if count > 0 {
|
||||||
|
format!("变量注入({count} 个占位符)")
|
||||||
|
} else {
|
||||||
|
"变量注入".to_string()
|
||||||
|
})
|
||||||
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
|
if this.show_vars {
|
||||||
|
this.show_vars = false;
|
||||||
|
cx.notify();
|
||||||
|
} else {
|
||||||
|
this.sync_var_rows(window, cx);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
@@ -1960,6 +1997,11 @@ impl ConnectionView {
|
|||||||
.child(Textarea::new(&payload)),
|
.child(Textarea::new(&payload)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 内联变量绑定面板:仅在展开且已提取到占位符时渲染
|
||||||
|
if show_vars && !self.var_rows.is_empty() {
|
||||||
|
card = card.child(self.render_vars_panel(cx));
|
||||||
|
}
|
||||||
|
|
||||||
if show_props && is_v5 {
|
if show_props && is_v5 {
|
||||||
card = card.child(
|
card = card.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -2000,14 +2042,160 @@ impl ConnectionView {
|
|||||||
card
|
card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按当前 topic/payload 重新提取 `{{key}}`,增删变量输入行。
|
||||||
|
/// 输入框创建需要 Window,因此只能在按钮回调里调用(无法在 render 中同步)。
|
||||||
|
fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let topic = self.pub_topic.read(cx).value().to_string();
|
||||||
|
let payload = self.payload.read(cx).value().to_string();
|
||||||
|
let keys = extract_placeholder_keys(&topic, &payload);
|
||||||
|
if keys.is_empty() {
|
||||||
|
self.show_vars = false;
|
||||||
|
window.push_notification(
|
||||||
|
Notification::warning("主题/负载中没有 {{变量}} 引用"),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default();
|
||||||
|
// 移除已不存在的 key(保留仍存在的行,避免丢未保存的输入)
|
||||||
|
self.var_rows.retain(|(k, _, _)| keys.contains(k));
|
||||||
|
for key in keys {
|
||||||
|
if self.var_rows.iter().any(|(k, ..)| k == &key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let initial = vars
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.key == key)
|
||||||
|
.map(|v| v.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let input = cx.new(|cx| {
|
||||||
|
let mut s = InputState::new(window, cx).placeholder("变量值");
|
||||||
|
// set_value 不会触发 InputEvent::Change,不会误写回
|
||||||
|
if !initial.is_empty() {
|
||||||
|
s.set_value(initial.as_str(), window, cx);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
});
|
||||||
|
// 编辑即写回全局变量(含持久化),预览与发布随之生效
|
||||||
|
let key_for_sub = key.clone();
|
||||||
|
let sub = cx.subscribe(&input, move |this, entity, ev: &InputEvent, cx| {
|
||||||
|
if !matches!(ev, InputEvent::Change) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let val = entity.read(cx).value().to_string();
|
||||||
|
let key = key_for_sub.clone();
|
||||||
|
let Some(app) = this.app.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
app.update(cx, |a, cx| {
|
||||||
|
match a.variables.iter_mut().find(|v| v.key == key) {
|
||||||
|
Some(v) => {
|
||||||
|
if v.value == val {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
v.value = val;
|
||||||
|
}
|
||||||
|
None => a.variables.push(GlobalVariable { key, value: val }),
|
||||||
|
}
|
||||||
|
let snapshot = a.variables.clone();
|
||||||
|
a.save_variables(snapshot);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
self.var_rows.push((key, input, sub));
|
||||||
|
}
|
||||||
|
self.show_vars = true;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 变量绑定面板:逐 key 编辑 + 实时渲染预览。
|
||||||
|
fn render_vars_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default();
|
||||||
|
let topic_raw = self.pub_topic.read(cx).value().to_string();
|
||||||
|
let payload_raw = self.payload.read(cx).value().to_string();
|
||||||
|
let topic_rendered = render_template(&topic_raw, &vars);
|
||||||
|
let payload_rendered = render_template(&payload_raw, &vars);
|
||||||
|
let payload_preview: String = payload_rendered.chars().take(80).collect();
|
||||||
|
let muted = cx.theme().muted_foreground;
|
||||||
|
|
||||||
|
let mut rows = v_flex().gap_1();
|
||||||
|
for (key, input, _) in &self.var_rows {
|
||||||
|
rows = rows.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(160.))
|
||||||
|
.min_w(px(0.))
|
||||||
|
.text_xs()
|
||||||
|
.text_color(muted)
|
||||||
|
.child(format!("{{{{{key}}}}}")),
|
||||||
|
)
|
||||||
|
.child(div().flex_1().min_w(px(0.)).child(Input::new(input).small())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child(format!("变量({})", self.var_rows.len())),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
Button::new("vars-refresh")
|
||||||
|
.icon(IconName::RefreshCw)
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.tooltip("重新提取占位符")
|
||||||
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
|
this.sync_var_rows(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("vars-collapse")
|
||||||
|
.icon(IconName::ChevronUp)
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.tooltip("收起")
|
||||||
|
.on_click(cx.listener(|this, _, _, cx| {
|
||||||
|
this.show_vars = false;
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(rows)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(muted)
|
||||||
|
.child("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(muted)
|
||||||
|
.child(format!("预览:{topic_rendered} | {payload_preview}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 发布预设下拉:应用已有预设、保存当前、删除预设。
|
/// 发布预设下拉:应用已有预设、保存当前、删除预设。
|
||||||
fn render_preset_menu(
|
fn render_preset_menu( &self,
|
||||||
&self,
|
|
||||||
presets: Vec<crate::model::PublishPreset>,
|
presets: Vec<crate::model::PublishPreset>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let weak_view = cx.weak_entity();
|
let weak_view = cx.weak_entity();
|
||||||
let preset_name = self.preset_name.clone();
|
let preset_name = self.preset_name.clone();
|
||||||
|
let app_weak = self.app.clone();
|
||||||
let has = !presets.is_empty();
|
let has = !presets.is_empty();
|
||||||
Button::new(SharedString::from(format!("preset-menu-{}", self.conn_id)))
|
Button::new(SharedString::from(format!("preset-menu-{}", self.conn_id)))
|
||||||
.icon(IconName::Bookmark)
|
.icon(IconName::Bookmark)
|
||||||
@@ -2046,6 +2234,15 @@ impl ConnectionView {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
// 打开预设管理对话框(新建 / 编辑 / 删除)
|
||||||
|
let manage_app = app_weak.clone();
|
||||||
|
menu = menu.item(PopupMenuItem::new("管理预设…").on_click(
|
||||||
|
move |_ev, window, cx| {
|
||||||
|
if let Some(app) = manage_app.upgrade() {
|
||||||
|
crate::ui::presets_dialog::open(app, window, cx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
// 删除子菜单
|
// 删除子菜单
|
||||||
if has {
|
if has {
|
||||||
let weak_del = weak_view.clone();
|
let weak_del = weak_view.clone();
|
||||||
@@ -2306,6 +2503,34 @@ fn split_topic_list(raw: &str) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从发布主题与负载中提取 `{{key}}` 占位符的 key 列表:
|
||||||
|
/// - key 两侧空白去除(trim);
|
||||||
|
/// - `$` 开头的内置变量(`$ts` / `$ts_ms` / `$uuid`)不提取;
|
||||||
|
/// - 重复 key 去重,保持首次出现顺序(主题在前、负载在后)。
|
||||||
|
fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for text in [topic, payload] {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 1 < bytes.len() {
|
||||||
|
if bytes[i] == b'{' && bytes[i + 1] == b'{' {
|
||||||
|
if let Some(rel_end) = text[i + 2..].find("}}") {
|
||||||
|
let key = text[i + 2..i + 2 + rel_end].trim();
|
||||||
|
if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) {
|
||||||
|
out.push(key.to_string());
|
||||||
|
}
|
||||||
|
i = i + 2 + rel_end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按字符推进,避免把多字节 UTF-8 拆开
|
||||||
|
let ch = text[i..].chars().next().unwrap();
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// MQTT 主题过滤器匹配:`+` 匹配单层,`#` 匹配本层及以下(可匹配父级本身)。
|
/// MQTT 主题过滤器匹配:`+` 匹配单层,`#` 匹配本层及以下(可匹配父级本身)。
|
||||||
fn topic_matches(filter: &str, topic: &str) -> bool {
|
fn topic_matches(filter: &str, topic: &str) -> bool {
|
||||||
let f: Vec<&str> = filter.split('/').collect();
|
let f: Vec<&str> = filter.split('/').collect();
|
||||||
@@ -2434,3 +2659,30 @@ fn contains_ignore_case(hay: &str, needle: &str) -> bool {
|
|||||||
}
|
}
|
||||||
hay.to_lowercase().contains(needle)
|
hay.to_lowercase().contains(needle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::extract_placeholder_keys;
|
||||||
|
|
||||||
|
// 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序
|
||||||
|
#[test]
|
||||||
|
fn extract_keys_trims_excludes_builtin_and_dedups() {
|
||||||
|
let keys = extract_placeholder_keys(
|
||||||
|
"sensor/{{ device }}/state",
|
||||||
|
"{\"v\":{{value}},\"t\":{{ $ts }},\"u\":{{uuid}}}",
|
||||||
|
);
|
||||||
|
assert_eq!(keys, vec!["device", "value", "uuid"], "实际: {keys:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_keys_empty_and_multibyte_safe() {
|
||||||
|
assert!(extract_placeholder_keys("", "").is_empty());
|
||||||
|
assert!(extract_placeholder_keys("no placeholders here", "纯文本").is_empty());
|
||||||
|
// 未闭合的 {{ 不提取、不越界
|
||||||
|
assert!(extract_placeholder_keys("a{{b", "c}}d").is_empty());
|
||||||
|
let keys = extract_placeholder_keys("温度{{名称}}", "");
|
||||||
|
assert_eq!(keys, vec!["名称"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod app;
|
|||||||
pub mod connection_form;
|
pub mod connection_form;
|
||||||
pub mod connection_view;
|
pub mod connection_view;
|
||||||
pub mod logo;
|
pub mod logo;
|
||||||
|
pub mod presets_dialog;
|
||||||
pub mod settings_dialog;
|
pub mod settings_dialog;
|
||||||
pub mod variables_dialog;
|
pub mod variables_dialog;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
//! 发布预设管理对话框:列出全部 `PublishPreset`,支持新建 / 选中编辑 / 删除(二次确认)。
|
||||||
|
//! 保存按 **id** 更新,name 与其他预设冲突时报错不落盘;模板存原文,发送时才渲染。
|
||||||
|
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _};
|
||||||
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
|
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
||||||
|
use gpui_kit::component::select::{Select, SelectState};
|
||||||
|
use gpui_kit::component::switch::Switch;
|
||||||
|
use gpui_kit::component::{
|
||||||
|
h_flex, notification::Notification, v_flex, ActiveTheme as _, Disableable as _,
|
||||||
|
Sizable as _, StyledExt as _, WindowExt as _,
|
||||||
|
};
|
||||||
|
use gpui_kit::prelude::FluentBuilder as _;
|
||||||
|
use crate::ui::IconName;
|
||||||
|
use gpui_kit::{
|
||||||
|
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||||
|
ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::model::{PayloadFormat, PublishParams, PublishPreset};
|
||||||
|
use crate::ui::app::MqttXApp;
|
||||||
|
use crate::ui::widgets::{field, make_select, KvEditor, OptionDelegate};
|
||||||
|
|
||||||
|
const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"];
|
||||||
|
const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"];
|
||||||
|
/// 列表里 payload 预览的最大字符数(压平换行后截断)
|
||||||
|
const LIST_PAYLOAD_PREVIEW_CHARS: usize = 24;
|
||||||
|
|
||||||
|
struct PresetsDialog {
|
||||||
|
app: Entity<MqttXApp>,
|
||||||
|
/// 与 `app.presets` 同步的本地工作副本;所有变更经 `persist` 落盘
|
||||||
|
presets: Vec<PublishPreset>,
|
||||||
|
/// 当前编辑项下标;None = 未选中(保存/删除禁用)
|
||||||
|
selected: Option<usize>,
|
||||||
|
|
||||||
|
name: Entity<InputState>,
|
||||||
|
topic: Entity<InputState>,
|
||||||
|
payload: Entity<TextareaState>,
|
||||||
|
qos: Entity<SelectState<OptionDelegate>>,
|
||||||
|
retain: bool,
|
||||||
|
payload_format: Entity<SelectState<OptionDelegate>>,
|
||||||
|
content_type: Entity<InputState>,
|
||||||
|
msg_expiry: Entity<InputState>,
|
||||||
|
response_topic: Entity<InputState>,
|
||||||
|
correlation_data: Entity<InputState>,
|
||||||
|
user_props: Entity<KvEditor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表 payload 预览:换行/制表符压平后截取前 N 字符。
|
||||||
|
fn payload_preview(payload: &str) -> String {
|
||||||
|
let flat: String = payload
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'\n' | '\r' | '\t' => ' ',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut out: String = flat.chars().take(LIST_PAYLOAD_PREVIEW_CHARS).collect();
|
||||||
|
if flat.chars().count() > LIST_PAYLOAD_PREVIEW_CHARS {
|
||||||
|
out.push('…');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetsDialog {
|
||||||
|
fn new(
|
||||||
|
app: Entity<MqttXApp>,
|
||||||
|
presets: Vec<PublishPreset>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
// 有预设时默认选中第一项并回填表单,避免打开即空白
|
||||||
|
let first = presets.first().cloned();
|
||||||
|
let selected = if presets.is_empty() { None } else { Some(0) };
|
||||||
|
let params = first
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.params.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let name_value = first.as_ref().map(|p| p.name.clone()).unwrap_or_default();
|
||||||
|
let name = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("预设名称,如 温度上报")
|
||||||
|
.default_value(name_value)
|
||||||
|
});
|
||||||
|
let topic = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("发布主题,支持 {{变量名}}")
|
||||||
|
.default_value(params.topic.clone())
|
||||||
|
});
|
||||||
|
let payload = cx.new(|cx| {
|
||||||
|
TextareaState::new(window, cx)
|
||||||
|
.placeholder("消息负载,支持 {{变量名}}")
|
||||||
|
.default_value(params.payload.clone())
|
||||||
|
});
|
||||||
|
let qos = make_select(&QOS, params.qos.min(2) as usize, window, cx);
|
||||||
|
let fmt_idx = PayloadFormat::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|f| *f == params.payload_format)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let payload_format = make_select(&PAYLOAD_FORMATS, fmt_idx, window, cx);
|
||||||
|
let content_type = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Content-Type(可选)")
|
||||||
|
.default_value(params.content_type.clone().unwrap_or_default())
|
||||||
|
});
|
||||||
|
let msg_expiry = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx).placeholder("秒,如 60").default_value(
|
||||||
|
params
|
||||||
|
.message_expiry_interval
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let response_topic = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Response Topic(可选)")
|
||||||
|
.default_value(params.response_topic.clone().unwrap_or_default())
|
||||||
|
});
|
||||||
|
let correlation_data = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Correlation Data(可选)")
|
||||||
|
.default_value(params.correlation_data.clone().unwrap_or_default())
|
||||||
|
});
|
||||||
|
let user_props =
|
||||||
|
cx.new(|cx| KvEditor::new("preset-props", ¶ms.user_properties, window, cx));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
app,
|
||||||
|
presets,
|
||||||
|
selected,
|
||||||
|
name,
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
qos,
|
||||||
|
retain: params.retain,
|
||||||
|
payload_format,
|
||||||
|
content_type,
|
||||||
|
msg_expiry,
|
||||||
|
response_topic,
|
||||||
|
correlation_data,
|
||||||
|
user_props,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把本地工作副本写回 `app.presets` 并落盘(内部调 `save_presets`)。
|
||||||
|
fn persist(&self, cx: &mut Context<Self>) {
|
||||||
|
let presets = self.presets.clone();
|
||||||
|
self.app.update(cx, |a, cx| {
|
||||||
|
a.save_presets(presets);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取表单当前值为发布参数(不渲染模板,存原文)。
|
||||||
|
fn collect(&self, cx: &App) -> PublishParams {
|
||||||
|
let qos = self
|
||||||
|
.qos
|
||||||
|
.read(cx)
|
||||||
|
.selected_value()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0) as u8;
|
||||||
|
let format = PayloadFormat::ALL
|
||||||
|
[self.payload_format.read(cx).selected_value().copied().unwrap_or(0)];
|
||||||
|
let opt = |s: String| {
|
||||||
|
let t = s.trim();
|
||||||
|
(!t.is_empty()).then(|| t.to_string())
|
||||||
|
};
|
||||||
|
PublishParams {
|
||||||
|
topic: self.topic.read(cx).value().to_string(),
|
||||||
|
payload: self.payload.read(cx).value().to_string(),
|
||||||
|
payload_format: format,
|
||||||
|
qos,
|
||||||
|
retain: self.retain,
|
||||||
|
user_properties: self.user_props.read(cx).pairs(cx),
|
||||||
|
content_type: opt(self.content_type.read(cx).value().to_string()),
|
||||||
|
message_expiry_interval: {
|
||||||
|
let t = self.msg_expiry.read(cx).value().to_string();
|
||||||
|
let t = t.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
t.parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response_topic: opt(self.response_topic.read(cx).value().to_string()),
|
||||||
|
correlation_data: opt(self.correlation_data.read(cx).value().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中项回填表单,进入编辑态。
|
||||||
|
fn load(&mut self, idx: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(p) = self.presets.get(idx).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.selected = Some(idx);
|
||||||
|
let params = p.params.clone();
|
||||||
|
self.name
|
||||||
|
.update(cx, |s, cx| s.set_value(p.name, window, cx));
|
||||||
|
self.topic
|
||||||
|
.update(cx, |s, cx| s.set_value(params.topic.clone(), window, cx));
|
||||||
|
self.payload
|
||||||
|
.update(cx, |s, cx| s.set_value(params.payload.clone(), window, cx));
|
||||||
|
let qos_idx = params.qos.min(2) as usize;
|
||||||
|
self.qos.update(cx, |s, cx| {
|
||||||
|
s.set_selected_index(Some(gpui_kit::component::IndexPath::new(qos_idx)), window, cx)
|
||||||
|
});
|
||||||
|
let fmt_idx = PayloadFormat::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|f| *f == params.payload_format)
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.payload_format.update(cx, |s, cx| {
|
||||||
|
s.set_selected_index(
|
||||||
|
Some(gpui_kit::component::IndexPath::new(fmt_idx)),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
self.retain = params.retain;
|
||||||
|
let ct = params.content_type.clone().unwrap_or_default();
|
||||||
|
self.content_type
|
||||||
|
.update(cx, |s, cx| s.set_value(ct, window, cx));
|
||||||
|
let expiry = params
|
||||||
|
.message_expiry_interval
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.msg_expiry
|
||||||
|
.update(cx, |s, cx| s.set_value(expiry, window, cx));
|
||||||
|
let rt = params.response_topic.clone().unwrap_or_default();
|
||||||
|
self.response_topic
|
||||||
|
.update(cx, |s, cx| s.set_value(rt, window, cx));
|
||||||
|
let cd = params.correlation_data.clone().unwrap_or_default();
|
||||||
|
self.correlation_data
|
||||||
|
.update(cx, |s, cx| s.set_value(cd, window, cx));
|
||||||
|
// KvEditor 不支持整体回填,直接重建实体
|
||||||
|
self.user_props =
|
||||||
|
cx.new(|cx| KvEditor::new("preset-props", ¶ms.user_properties, window, cx));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空表单并退出编辑态(删除后无剩余预设时调用)。
|
||||||
|
fn clear_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.selected = None;
|
||||||
|
self.name.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.topic.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.payload
|
||||||
|
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.qos.update(cx, |s, cx| {
|
||||||
|
s.set_selected_index(Some(gpui_kit::component::IndexPath::new(0)), window, cx)
|
||||||
|
});
|
||||||
|
self.payload_format.update(cx, |s, cx| {
|
||||||
|
s.set_selected_index(Some(gpui_kit::component::IndexPath::new(0)), window, cx)
|
||||||
|
});
|
||||||
|
self.retain = false;
|
||||||
|
self.content_type
|
||||||
|
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.msg_expiry
|
||||||
|
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.response_topic
|
||||||
|
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.correlation_data
|
||||||
|
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||||
|
self.user_props = cx.new(|cx| KvEditor::new("preset-props", &[], window, cx));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「新建」:创建默认名称预设(重名自动加序号)并立即落盘、进入编辑。
|
||||||
|
fn create_new(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let mut name = "新预设".to_string();
|
||||||
|
let mut n = 2;
|
||||||
|
while self.presets.iter().any(|p| p.name == name) {
|
||||||
|
name = format!("新预设 {n}");
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
self.presets.push(PublishPreset::new(name, PublishParams::default()));
|
||||||
|
let idx = self.presets.len() - 1;
|
||||||
|
self.persist(cx);
|
||||||
|
self.load(idx, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「保存」:name 校验 + 冲突检查通过后按 id 更新,再落盘。
|
||||||
|
fn save(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(idx) = self.selected else {
|
||||||
|
window.push_notification(Notification::warning("请先选择或新建预设"), cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let name = self.name.read(cx).value().trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
window.push_notification(Notification::warning("请填写预设名称"), cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 与其他预设(不含自身)重名 → 报错且不落盘
|
||||||
|
let own_id = self.presets[idx].id.clone();
|
||||||
|
if self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.id != own_id && p.name == name)
|
||||||
|
{
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(format!("预设名称「{name}」已存在")),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 过期秒数输入了但解析失败 → 拒绝保存(与发布路径校验一致)
|
||||||
|
let expiry_raw = self.msg_expiry.read(cx).value().to_string();
|
||||||
|
let params = self.collect(cx);
|
||||||
|
if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::warning("消息过期须为非负整数(秒)"),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 按 id 更新:id 不随改名变化,避免并发列表错位
|
||||||
|
if let Some(p) = self.presets.iter_mut().find(|p| p.id == own_id) {
|
||||||
|
p.name = name.clone();
|
||||||
|
p.params = params;
|
||||||
|
}
|
||||||
|
self.persist(cx);
|
||||||
|
window.push_notification(Notification::success(format!("预设「{name}」已保存")), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「删除」:`open_alert_dialog` 二次确认后从列表移除并落盘。
|
||||||
|
fn ask_delete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(idx) = self.selected else {
|
||||||
|
window.push_notification(Notification::warning("请先选择要删除的预设"), cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let pid = self.presets[idx].id.clone();
|
||||||
|
let pname = self.presets[idx].name.clone();
|
||||||
|
let weak = cx.weak_entity();
|
||||||
|
// 与发布预设下拉的删除确认保持一致:不可撤销操作先弹二次确认
|
||||||
|
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||||
|
let weak = weak.clone();
|
||||||
|
let pid = pid.clone();
|
||||||
|
alert
|
||||||
|
.title("删除预设")
|
||||||
|
.description(format!("确定删除预设「{pname}」吗?此操作不可撤销。"))
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.show_cancel(true)
|
||||||
|
.cancel_text("取消")
|
||||||
|
.ok_text("删除")
|
||||||
|
.ok_variant(ButtonVariant::Danger),
|
||||||
|
)
|
||||||
|
.on_ok(move |_, window, cx| {
|
||||||
|
weak.update(cx, |d, cx| {
|
||||||
|
d.presets.retain(|p| p.id != pid);
|
||||||
|
d.persist(cx);
|
||||||
|
if d.presets.is_empty() {
|
||||||
|
d.clear_form(window, cx);
|
||||||
|
} else {
|
||||||
|
// 删除后选中项前移,取原位置(越界则取最后一项)
|
||||||
|
let next = idx.min(d.presets.len() - 1);
|
||||||
|
d.load(next, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for PresetsDialog {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let muted = cx.theme().muted_foreground;
|
||||||
|
let selected_bg = cx.theme().secondary;
|
||||||
|
let hover_bg = cx.theme().muted;
|
||||||
|
let mono_font = cx.theme().mono_font_family.clone();
|
||||||
|
|
||||||
|
// 左:预设列表(name + topic + payload 前 N 字符;空态提示)
|
||||||
|
let mut list = v_flex().gap_0p5();
|
||||||
|
if self.presets.is_empty() {
|
||||||
|
list = list.child(
|
||||||
|
v_flex()
|
||||||
|
.p_3()
|
||||||
|
.gap_1()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
gpui_kit::component::Icon::new(IconName::Bookmark)
|
||||||
|
.text_color(cx.theme().muted_foreground),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(muted)
|
||||||
|
.text_center()
|
||||||
|
.child("暂无发布预设,点击上方「新建」创建"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (i, p) in self.presets.iter().enumerate() {
|
||||||
|
let selected = self.selected == Some(i);
|
||||||
|
let name = p.name.clone();
|
||||||
|
let topic = p.params.topic.clone();
|
||||||
|
let preview = payload_preview(&p.params.payload);
|
||||||
|
list = list.child(
|
||||||
|
div()
|
||||||
|
.id(("preset-list-item", i))
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded_md()
|
||||||
|
.cursor_pointer()
|
||||||
|
.when(selected, |d| d.bg(selected_bg))
|
||||||
|
.when(!selected, |d| d.hover(move |this| this.bg(hover_bg)))
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.load(i, window, cx);
|
||||||
|
}))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(name),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(muted)
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(topic),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_family(mono_font.clone())
|
||||||
|
.text_color(muted)
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(preview),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右:编辑表单(不渲染模板,全部存原文)
|
||||||
|
let retain = self.retain;
|
||||||
|
let form = v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w(px(0.))
|
||||||
|
.gap_2()
|
||||||
|
.child(field("预设名称", Input::new(&self.name)))
|
||||||
|
.child(field("发布主题", Input::new(&self.topic)))
|
||||||
|
.child(field(
|
||||||
|
"Payload(原文保存,发送时渲染变量)",
|
||||||
|
Textarea::new(&self.payload).h(px(96.)),
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(110.))
|
||||||
|
.child(field("QoS", Select::new(&self.qos).small())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.items_center()
|
||||||
|
.pt_4()
|
||||||
|
.child(
|
||||||
|
Switch::new("preset-retain")
|
||||||
|
.checked(retain)
|
||||||
|
.on_change(cx.listener(|this, v, _, cx| {
|
||||||
|
this.retain = *v;
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(div().text_xs().child("Retain")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w(px(0.))
|
||||||
|
.child(field(
|
||||||
|
"Payload 格式",
|
||||||
|
Select::new(&self.payload_format).small(),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().h(px(1.)).bg(cx.theme().border).w_full())
|
||||||
|
.child(div().text_xs().font_semibold().child("MQTT 5 属性"))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div().flex_1().min_w(px(0.)).child(field(
|
||||||
|
"Content-Type",
|
||||||
|
Input::new(&self.content_type).small(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().w(px(120.)).child(field(
|
||||||
|
"消息过期(秒)",
|
||||||
|
Input::new(&self.msg_expiry).small(),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div().flex_1().min_w(px(0.)).child(field(
|
||||||
|
"Response Topic",
|
||||||
|
Input::new(&self.response_topic).small(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().flex_1().min_w(px(0.)).child(field(
|
||||||
|
"Correlation Data",
|
||||||
|
Input::new(&self.correlation_data).small(),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(field("用户属性", self.user_props.clone()));
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.gap_3()
|
||||||
|
.w_full()
|
||||||
|
// 左:列表 + 新建
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w(px(220.))
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.justify_between()
|
||||||
|
.items_center()
|
||||||
|
.child(div().text_sm().font_semibold().child("发布预设"))
|
||||||
|
.child(
|
||||||
|
Button::new("preset-new")
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.label("新建")
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
|
this.create_new(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(list),
|
||||||
|
)
|
||||||
|
// 右:表单
|
||||||
|
.child(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||||
|
let presets = app.read(cx).presets.clone();
|
||||||
|
let dialog_view: Entity<PresetsDialog> =
|
||||||
|
cx.new(|cx| PresetsDialog::new(app, presets, window, cx));
|
||||||
|
window.open_dialog(cx, move |dialog, _, cx| {
|
||||||
|
let body = dialog_view.clone();
|
||||||
|
let for_save = dialog_view.clone();
|
||||||
|
let for_delete = dialog_view.clone();
|
||||||
|
// 每次重绘读取选中态,控制保存 / 删除按钮禁用
|
||||||
|
let has_selection = dialog_view.read(cx).selected.is_some();
|
||||||
|
dialog
|
||||||
|
.w(px(760.))
|
||||||
|
.title("管理发布预设")
|
||||||
|
.child(body)
|
||||||
|
.footer(
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
Button::new("preset-delete")
|
||||||
|
.icon(IconName::Trash)
|
||||||
|
.label("删除")
|
||||||
|
.danger()
|
||||||
|
.outline()
|
||||||
|
.disabled(!has_selection)
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
for_delete.update(cx, |d, cx| d.ask_delete(window, cx));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
Button::new("preset-close")
|
||||||
|
.label("关闭")
|
||||||
|
.outline()
|
||||||
|
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("preset-save")
|
||||||
|
.label("保存")
|
||||||
|
.primary()
|
||||||
|
.disabled(!has_selection)
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
for_save.update(cx, |d, cx| d.save(window, cx));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user