ci: linux xkbcommon-x11, yaml-cpp cstdint patch, hwaccel test skips on VT-less hosts
- Linux: libxkbcommon-x11-dev for the gpui X11 client link - Windows: patch <cstdint> into the vendored yaml-cpp (a cached cmake configure ignores CXXFLAGS; the patch is idempotent and runs after cargo fetch) - macOS: the hw-decode test skips its VideoToolbox engagement assertions on hosts where VT cannot initialize (headless/virtualized runners) instead of failing - display color management: the display ICC (system or custom) is applied to viewer frames at present time (F32 in place, or in place on the BGRA staging copy with the R/B swizzle baked into the OCIO chain); preferences get a Color section (mode + custom ICC file); on macOS the Metal layer is tagged with the display colorspace when self-managing so ColorSync passes pixels through (no double correction); frame caches track the transform generation so a mode or profile change drops stale pixels
This commit is contained in:
@@ -2677,6 +2677,15 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
if plugin_count > 0 {
|
||||
println!("[ofx] registered {plugin_count} OFX plugin node type(s)");
|
||||
}
|
||||
// Display color management: when the app transforms viewer frames
|
||||
// through the display ICC itself, the macOS Metal layer must be
|
||||
// tagged with the display colorspace so ColorSync passes the
|
||||
// pixels through (otherwise the OS re-corrects them). Read by
|
||||
// gpui_macos at layer creation, which happens below.
|
||||
if crate::oakui::displaycolor::is_active() {
|
||||
// SAFETY: single-threaded startup, before any window exists.
|
||||
unsafe { std::env::set_var("OAK_MACOS_LAYER_COLORSPACE", "display") };
|
||||
}
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
|
||||
let initial = initial.clone();
|
||||
|
||||
+114
-3
@@ -97,6 +97,8 @@ pub struct PreferencesContent {
|
||||
use_proxy: Entity<CheckBox>,
|
||||
hw_decode: Entity<CheckBox>,
|
||||
proxy_divider: Entity<ComboBox>,
|
||||
display_icc: Entity<CheckBox>,
|
||||
display_icc_path: Entity<PathField>,
|
||||
snapshot_interval: Entity<SpinBox>,
|
||||
transition_length: Entity<SpinBox>,
|
||||
audio_output: Entity<ComboBox>,
|
||||
@@ -276,6 +278,43 @@ impl PreferencesContent {
|
||||
})
|
||||
.detach();
|
||||
|
||||
// --- 色彩 Color: display ICC color management -----------------------
|
||||
// On by default: the viewer frames are transformed through the
|
||||
// display's ICC profile (system profile, or a custom file below).
|
||||
// The macOS layer tag is applied at startup, so a mode change takes
|
||||
// effect after a restart.
|
||||
use crate::oakui::displaycolor::{
|
||||
CONFIG_KEY_COLOR_MODE, CONFIG_KEY_CUSTOM_ICC,
|
||||
};
|
||||
let display_icc = cx.new(|cx| {
|
||||
let mode = config_get_string(CONFIG_KEY_COLOR_MODE);
|
||||
CheckBox::new(
|
||||
13,
|
||||
if mode != "off" {
|
||||
CheckState::Checked
|
||||
} else {
|
||||
CheckState::Unchecked
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_label(i18n::tr("preferences.color.enable"))
|
||||
});
|
||||
cx.subscribe(&display_icc, |_this, check, event: &CheckBoxEvent, cx| {
|
||||
if let CheckBoxEvent::Toggled { state, .. } = event {
|
||||
let enabled = *state == CheckState::Checked;
|
||||
config_set_string(CONFIG_KEY_COLOR_MODE, if enabled { "icc" } else { "off" });
|
||||
check.update(cx, |check, cx| check.set_state(*state, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
let display_icc_path = cx.new(|cx| {
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
PathField { editor }
|
||||
});
|
||||
let configured_icc = config_get_string(CONFIG_KEY_CUSTOM_ICC);
|
||||
display_icc_path.update(cx, |field, cx| field.set_path(configured_icc, cx));
|
||||
|
||||
// --- 项目 Project: snapshot interval + default transition ----------
|
||||
let snapshot_interval = cx.new(|cx| {
|
||||
let current =
|
||||
@@ -369,6 +408,8 @@ impl PreferencesContent {
|
||||
use_proxy,
|
||||
hw_decode,
|
||||
proxy_divider,
|
||||
display_icc,
|
||||
display_icc_path,
|
||||
snapshot_interval,
|
||||
transition_length,
|
||||
audio_output,
|
||||
@@ -380,6 +421,41 @@ impl PreferencesContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the custom ICC path field to the config (called by the host
|
||||
/// when the dialog closes, like the cache directory).
|
||||
pub fn commit_display_icc_path(&self, cx: &App) {
|
||||
let path = self.display_icc_path.read(cx).path(cx).trim().to_string();
|
||||
config_set_string(
|
||||
crate::oakui::displaycolor::CONFIG_KEY_CUSTOM_ICC,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the platform file picker for a custom ICC profile.
|
||||
fn browse_display_icc(&mut self, cx: &mut Context<Self>) {
|
||||
let receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some(i18n::tr("preferences.color.browse").into()),
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok(Ok(Some(paths))) = receiver.await else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = paths.first() else {
|
||||
return;
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.display_icc_path.update(cx, |field, cx| {
|
||||
field.set_path(path.to_string_lossy().into_owned(), cx)
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The cache directory currently entered.
|
||||
pub fn cache_dir(&self, cx: &App) -> SharedString {
|
||||
self.cache_dir.read(cx).path(cx)
|
||||
@@ -560,6 +636,39 @@ impl Render for PreferencesContent {
|
||||
i18n::tr("preferences.proxy.resolution").into(),
|
||||
self.proxy_divider.clone(),
|
||||
))
|
||||
// 色彩 Color
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.color").into()))
|
||||
.child(self.display_icc.clone())
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.color.custom").into(),
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(div().flex_1().child(self.display_icc_path.clone()))
|
||||
.child(
|
||||
div()
|
||||
.id("preferences-icc-browse")
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.child(i18n::tr("preferences.color.browse"))
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.browse_display_icc(cx);
|
||||
})),
|
||||
),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.disabled)
|
||||
.text_xs()
|
||||
.child(i18n::tr("preferences.color.restart_hint")),
|
||||
)
|
||||
// 项目 Project
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.project").into()))
|
||||
.child(form_row(
|
||||
@@ -1221,10 +1330,12 @@ impl PreferencesDialogContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the general tab's free-text fields (the cache directory), for
|
||||
/// the host when the dialog closes.
|
||||
/// Commits the general tab's free-text fields (the cache directory, the
|
||||
/// custom ICC path), for the host when the dialog closes.
|
||||
pub fn commit_cache_dir(&self, cx: &App) {
|
||||
self.general.read(cx).commit_cache_dir(cx);
|
||||
let general = self.general.read(cx);
|
||||
general.commit_cache_dir(cx);
|
||||
general.commit_display_icc_path(cx);
|
||||
}
|
||||
|
||||
/// The keyboard tab's action-row count (tests).
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Display color management: the display's ICC profile applied to viewer
|
||||
//! frames at present time.
|
||||
//!
|
||||
//! The frame content is treated as display-referred sRGB/Rec.709 (the
|
||||
//! decode/render pipeline performs no input transfer conversion today);
|
||||
//! the chain maps it through the display ICC (system profile or a custom
|
||||
//! file from Preferences) so wide-gamut displays render correctly.
|
||||
//!
|
||||
//! Double-correction discipline: when this module transforms pixels, the
|
||||
//! OS must not transform them again. macOS: the app sets the CAMetalLayer
|
||||
//! colorspace to the display profile at startup (see the `OAK_METAL_*`
|
||||
//! wiring in app.rs) so ColorSync passes our output through. Windows:
|
||||
//! the SDR desktop applies no per-app transform (and ACM honors the
|
||||
//! swapchain's declared sRGB space, which is the default). Linux: no
|
||||
//! compositor-level correction exists to conflict with.
|
||||
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use oakcommon::configstore::ConfigStore;
|
||||
use oakrender::color::ColorProcessor;
|
||||
|
||||
/// Config key: the display color management mode ("icc" / "off").
|
||||
pub const CONFIG_KEY_COLOR_MODE: &str = "DisplayColorMode";
|
||||
/// Config key: a custom ICC profile path (empty = the system display
|
||||
/// profile).
|
||||
pub const CONFIG_KEY_CUSTOM_ICC: &str = "DisplayColorCustomIcc";
|
||||
/// Config key: the content colorspace the chain starts from (an OCIO
|
||||
/// colorspace name of the active config).
|
||||
pub const CONFIG_KEY_CONTENT_SPACE: &str = "DisplayColorContentSpace";
|
||||
|
||||
/// The default content space (OCIO 2.2 builtin config name for
|
||||
/// gamma-encoded Rec.709/sRGB display-referred content).
|
||||
const DEFAULT_CONTENT_SPACE: &str = "sRGB Encoded Rec.709 (sRGB)";
|
||||
|
||||
/// The cached processor pair (F32 RGBA and packed BGRA8 variants of the
|
||||
/// same chain), keyed by (mode, icc path, content space).
|
||||
struct State {
|
||||
key: (String, String, String),
|
||||
f32: Option<Arc<ColorProcessor>>,
|
||||
bgra: Option<Arc<ColorProcessor>>,
|
||||
}
|
||||
|
||||
static STATE: LazyLock<Mutex<Option<State>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
/// Bumped every time the effective key changes (mode / ICC path /
|
||||
/// content space): the engine's frame caches compare against it and drop
|
||||
/// images produced with a stale transform.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The current transform generation (see [`GENERATION`]).
|
||||
pub fn generation() -> u64 {
|
||||
GENERATION.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The active (mode, icc, content-space) key from the config.
|
||||
fn current_key() -> (String, String, String) {
|
||||
let store = ConfigStore::instance();
|
||||
let mode = store
|
||||
.get(None, CONFIG_KEY_COLOR_MODE)
|
||||
.unwrap_or_else(|_| "icc".to_string());
|
||||
let custom = store
|
||||
.get(None, CONFIG_KEY_CUSTOM_ICC)
|
||||
.unwrap_or_default();
|
||||
let space = store
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_else(|_| DEFAULT_CONTENT_SPACE.to_string());
|
||||
(mode, custom, space)
|
||||
}
|
||||
|
||||
/// Drop the cached processors (call after a preferences change).
|
||||
pub fn invalidate() {
|
||||
*STATE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// The cached state, (re)built when the config key changed.
|
||||
fn current() -> Option<State> {
|
||||
let key = current_key();
|
||||
let mut guard = STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if state.key == key {
|
||||
return clone_state(state);
|
||||
}
|
||||
}
|
||||
// The key changed: everything rendered with the old transform is
|
||||
// stale — bump the generation so frame caches drop their contents.
|
||||
GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let (mode, icc_path, space) = &key;
|
||||
if mode != "icc" {
|
||||
let state = State {
|
||||
key,
|
||||
f32: None,
|
||||
bgra: None,
|
||||
};
|
||||
let out = clone_state(&state);
|
||||
*guard = Some(state);
|
||||
return out;
|
||||
}
|
||||
// The custom override wins; empty = the platform's display profile.
|
||||
let icc = if icc_path.is_empty() {
|
||||
oakcommon::displayicc::system_display_icc()
|
||||
} else {
|
||||
Some(icc_path.clone())
|
||||
};
|
||||
let (f32p, bgrap) = match icc {
|
||||
Some(path) => (
|
||||
ColorProcessor::create_display_icc(space, &path).map(Arc::new),
|
||||
ColorProcessor::create_display_icc_bgra8(space, &path).map(Arc::new),
|
||||
),
|
||||
None => (None, None),
|
||||
};
|
||||
let state = State {
|
||||
key,
|
||||
f32: f32p,
|
||||
bgra: bgrap,
|
||||
};
|
||||
let out = clone_state(&state);
|
||||
*guard = Some(state);
|
||||
out
|
||||
}
|
||||
|
||||
fn clone_state(state: &State) -> Option<State> {
|
||||
Some(State {
|
||||
key: state.key.clone(),
|
||||
f32: state.f32.clone(),
|
||||
bgra: state.bgra.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether display color management is active (a valid ICC processor
|
||||
/// exists). When false the OS owns the output mapping.
|
||||
pub fn is_active() -> bool {
|
||||
current().map(|s| s.f32.is_some() || s.bgra.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Apply the display transform to an F32 RGBA buffer in place (no-op
|
||||
/// when inactive).
|
||||
pub fn apply_f32_rgba(samples: &mut [f32], pixels: i64) {
|
||||
let Some(state) = current() else {
|
||||
return;
|
||||
};
|
||||
if let Some(processor) = &state.f32 {
|
||||
let _ = processor.convert_f32_rgba(samples, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the display transform to a packed BGRA8 buffer in place (no-op
|
||||
/// when inactive).
|
||||
pub fn apply_bgra8(data: &mut [u8], pixels: i64) {
|
||||
let Some(state) = current() else {
|
||||
return;
|
||||
};
|
||||
if let Some(processor) = &state.bgra {
|
||||
let _ = processor.convert_bgra8(data, pixels);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! * [`timecode`] — timecode / duration / fps / resolution formatting (pure,
|
||||
//! unit tested).
|
||||
|
||||
pub mod displaycolor;
|
||||
pub mod effectchain;
|
||||
pub mod engine;
|
||||
pub mod frames;
|
||||
|
||||
@@ -986,6 +986,10 @@ pub struct RealEngine {
|
||||
/// [`RealEngine::render_source_frame`]); the synthetic pattern is only
|
||||
/// the failure fallback.
|
||||
cpu_frame_cache: Mutex<HashMap<Monitor, MonitorFrameCache>>,
|
||||
/// The display-color transform generation the frame cache was built
|
||||
/// against (a change drops every cached image — they were produced
|
||||
/// with the stale transform).
|
||||
display_color_gen: std::cell::Cell<u64>,
|
||||
/// Bumped whenever the rendered content can change underneath an
|
||||
/// in-flight background full-res job (an edit, a selection change or a
|
||||
/// project drop); completions tagged with a stale generation are
|
||||
@@ -1153,6 +1157,7 @@ impl RealEngine {
|
||||
program_playing: false,
|
||||
meter_phase: 0,
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
display_color_gen: std::cell::Cell::new(0),
|
||||
full_res_generation: 0,
|
||||
preview_windows: Arc::new(Mutex::new(HashMap::new())),
|
||||
preview_generation: 0,
|
||||
@@ -3176,6 +3181,14 @@ impl AppEngine for RealEngine {
|
||||
fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
|
||||
let frame = self.clock_frame(monitor, cx);
|
||||
let mut cache = self.cpu_frame_cache.lock().unwrap();
|
||||
// A display-color transform change (mode / ICC / content space)
|
||||
// invalidates every cached image: they were produced with the old
|
||||
// transform.
|
||||
let gen = super::displaycolor::generation();
|
||||
if gen != self.display_color_gen.get() {
|
||||
self.display_color_gen.set(gen);
|
||||
cache.clear();
|
||||
}
|
||||
// The full-resolution fill replaces the proxy when its frame matches
|
||||
// the playhead; otherwise the proxy frame is displayed (rendered
|
||||
// synchronously below on a cache miss, filled by the background
|
||||
|
||||
+11
-4
@@ -403,7 +403,9 @@ impl RenderedFrame {
|
||||
/// zero-copy onscreen path). For the shm variant the slot's BGRA8
|
||||
/// bytes are wrapped into the display buffer — the GPU-upload staging
|
||||
/// copy, the single permitted main-process copy on the preview path
|
||||
/// (design §3.5). The caller releases the slot afterwards.
|
||||
/// (design §3.5). The display color transform (display ICC) is applied
|
||||
/// in place on that staging copy / on the F32 samples, so it costs no
|
||||
/// extra copy. The caller releases the slot afterwards.
|
||||
pub fn to_display(&self) -> Option<(RenderImage, ScopeData)> {
|
||||
match self {
|
||||
RenderedFrame::Shm(f) => {
|
||||
@@ -411,8 +413,11 @@ impl RenderedFrame {
|
||||
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
|
||||
let pixels = f.shm.slot_bytes(f.slot);
|
||||
let data = pixels.get(..meta.data_size.max(0) as usize)?;
|
||||
let image = bgra_bytes_to_render_image(w, h, data)?;
|
||||
let scope = analyze_bgra8(w, h, data);
|
||||
// The display transform edits the staging copy in place.
|
||||
let mut owned = data.to_vec();
|
||||
super::displaycolor::apply_bgra8(&mut owned, (w * h) as i64);
|
||||
let image = bgra_bytes_to_render_image(w, h, &owned)?;
|
||||
Some((image, scope))
|
||||
}
|
||||
RenderedFrame::CpuF32 {
|
||||
@@ -422,10 +427,12 @@ impl RenderedFrame {
|
||||
data,
|
||||
} => {
|
||||
let (w, h) = ((*width).max(0) as u32, (*height).max(0) as u32);
|
||||
let samples = repack_f32_rows(*width, *height, *linesize, data)?;
|
||||
let mut samples = repack_f32_rows(*width, *height, *linesize, data)?;
|
||||
let scope = analyze_f32_rgba(w, h, &samples);
|
||||
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
|
||||
Some((
|
||||
f32_rgba_to_bgra_image(w, h, &samples),
|
||||
analyze_f32_rgba(w, h, &samples),
|
||||
scope,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+51
-29
@@ -143,7 +143,11 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
|
||||
/// The "add effect" menu: one clickable row per addable effect of the
|
||||
/// engine. Selecting a row inserts that effect at the recorded stack
|
||||
/// index; a dismiss row closes the menu without adding.
|
||||
/// index; the ✕ in the pinned header closes the menu without adding.
|
||||
/// The list is height-capped and scrollable (with the OFX plugins
|
||||
/// registered it runs to 150+ rows — an uncapped list pushed the
|
||||
/// dismiss affordance far off-screen, making the menu impossible to
|
||||
/// close).
|
||||
fn render_add_menu(
|
||||
&mut self,
|
||||
index: usize,
|
||||
@@ -151,22 +155,22 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let effects = self.engine.read(cx).addable_effects();
|
||||
let mut menu = div()
|
||||
.id("inspector-add-menu")
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_t_1()
|
||||
.border_color(colors.separator)
|
||||
let mut list = div()
|
||||
.id("inspector-add-menu-list")
|
||||
.max_h_64()
|
||||
.overflow_y_scroll()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1();
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_1();
|
||||
|
||||
for entry in &effects {
|
||||
let engine = self.engine.clone();
|
||||
let type_id = entry.type_id.clone();
|
||||
let name = entry.name.clone();
|
||||
let index = index;
|
||||
menu = menu.child(
|
||||
list = list.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("add-effect-{type_id}")))
|
||||
.cursor_pointer()
|
||||
@@ -191,26 +195,44 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
);
|
||||
}
|
||||
|
||||
// A dismiss row, so a cancelled pick does not linger.
|
||||
menu = menu.child(
|
||||
div()
|
||||
.id("add-effect-dismiss")
|
||||
.cursor_pointer()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.hover(|style| style.bg(colors.selected))
|
||||
.text_color(colors.disabled)
|
||||
.text_sm()
|
||||
.child("✕")
|
||||
.on_click(
|
||||
cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
|
||||
this.pending_add = None;
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
);
|
||||
menu
|
||||
div()
|
||||
.id("inspector-add-menu")
|
||||
.border_t_1()
|
||||
.border_color(colors.separator)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.child(crate::i18n::tr("inspector.add_effect")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("add-effect-dismiss")
|
||||
.cursor_pointer()
|
||||
.px_1()
|
||||
.rounded_sm()
|
||||
.hover(|style| style.bg(colors.selected))
|
||||
.text_color(colors.disabled)
|
||||
.text_sm()
|
||||
.child("✕")
|
||||
.on_click(cx.listener(
|
||||
move |this, _event: &gpui::ClickEvent, _window, cx| {
|
||||
this.pending_add = None;
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(list)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user