Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57dc811a6a | ||
|
|
9e327b4772 | ||
|
|
f6fcef4d5c | ||
|
|
3895cf6411 | ||
|
|
5cb74e7313 | ||
|
|
4165904bd1 | ||
|
|
1ebce934ea | ||
|
|
aaa46003d5 | ||
|
|
a9cd761d52 | ||
|
|
68267810b3 | ||
|
|
9b4ba1aa80 | ||
|
|
acc85c4147 | ||
|
|
0fbf20db51 | ||
|
|
2b66eb4800 | ||
|
|
92c7237c50 |
No files matched your search
@@ -68,6 +68,32 @@ impl DevelopSettings {
|
||||
!self.disabled_sections.iter().any(|s| s == section)
|
||||
}
|
||||
|
||||
/// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
|
||||
/// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
|
||||
/// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
|
||||
/// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
|
||||
pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
|
||||
let off = |section: &str| !self.section_enabled(section);
|
||||
if !(off("light") || off("color") || off("detail")) {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
if off("light") {
|
||||
d.reset_section(Section::Light);
|
||||
d.reset_section(Section::Curve);
|
||||
}
|
||||
if off("color") {
|
||||
for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
|
||||
d.reset_section(section);
|
||||
}
|
||||
d.point_colors.clear();
|
||||
}
|
||||
if off("detail") {
|
||||
d.reset_section(Section::Detail);
|
||||
}
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
|
||||
self.disabled_sections.retain(|s| s != section);
|
||||
if !on {
|
||||
|
||||
@@ -294,6 +294,9 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
|
||||
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// sections switched off with their eye render as if at their defaults (issue #316)
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
|
||||
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
|
||||
return None;
|
||||
|
||||
@@ -365,6 +365,9 @@ enum Src<'a> {
|
||||
}
|
||||
|
||||
fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered {
|
||||
// sections switched off with their eye render as if at their defaults (issue #316)
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
|
||||
if let Some(t) = t {
|
||||
|
||||
@@ -237,6 +237,35 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
|
||||
assert!(d <= 2, "max difference {d}");
|
||||
}
|
||||
|
||||
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
|
||||
/// and Detail, which the pipeline doesn't switch off where it applies them.
|
||||
#[test]
|
||||
fn sections_switched_off_render_as_defaults() {
|
||||
let src = Rgb32f::from_fn(32, 24, |x, y| [0.05 + x as f32 / 40.0, 0.1 + y as f32 / 30.0, 0.3]);
|
||||
let info = SourceInfo::default();
|
||||
let req = RenderRequest::fit(32, 24);
|
||||
let plain = render(&src, &info, &DevelopSettings::default(), &req).image;
|
||||
let mut s = DevelopSettings::default();
|
||||
s.light.exposure = 1.5;
|
||||
s.light.contrast = 40.0;
|
||||
s.color.saturation = -60.0;
|
||||
s.color.vibrance = 30.0;
|
||||
s.detail.sharpen_amount = 120.0;
|
||||
let edited = render(&src, &info, &s, &req).image;
|
||||
assert_ne!(edited.data, plain.data);
|
||||
for section in ["light", "color", "detail"] {
|
||||
s.set_section_enabled(section, false);
|
||||
}
|
||||
assert_eq!(render(&src, &info, &s, &req).image.data, plain.data, "every edited section off = the unedited photo");
|
||||
// one section back on brings back only its own adjustments
|
||||
s.set_section_enabled("light", true);
|
||||
let mut light_only = DevelopSettings::default();
|
||||
light_only.light.exposure = 1.5;
|
||||
light_only.light.contrast = 40.0;
|
||||
assert_eq!(render(&src, &info, &s, &req).image.data, render(&src, &info, &light_only, &req).image.data);
|
||||
assert!(matches!(DevelopSettings::default().effective(), std::borrow::Cow::Borrowed(_)), "nothing is copied when every section is on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_shifts_colours_but_keeps_greys() {
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
|
||||
@@ -1053,6 +1053,55 @@ mod tests {
|
||||
assert_ne!(h.app.session.active(), Some(first), "advanced");
|
||||
}
|
||||
|
||||
/// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc
|
||||
/// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star).
|
||||
#[test]
|
||||
fn slider_values_can_be_typed() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
|
||||
h.settle(SETTLE);
|
||||
let active = h.app.session.active().unwrap();
|
||||
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
|
||||
let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating;
|
||||
let (undo0, rating0) = (h.app.session.undo.len(), rating(&h));
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.text", json!({"text": "1,5"}), t);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
|
||||
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
|
||||
assert_eq!(rating(&h), rating0, "typing 1 set no rating");
|
||||
// Esc keeps the value
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.text", json!({"text": "-2"}), t);
|
||||
h.request("ui.key", json!({"key": "Escape"}), t);
|
||||
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
|
||||
// something that isn't a number changes nothing
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.text", json!({"text": "bright"}), t);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
|
||||
assert_eq!(h.app.session.undo.len(), undo0 + 1);
|
||||
}
|
||||
|
||||
/// The eye on a section header switches the section off and on again, one undo step each
|
||||
/// (issue #316).
|
||||
#[test]
|
||||
fn section_eye_switches_a_section_off() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
|
||||
h.settle(SETTLE);
|
||||
let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light");
|
||||
for expected in [false, true] {
|
||||
// the eye shows while the pointer is on the header
|
||||
assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true);
|
||||
assert_eq!(light_on(&h), expected);
|
||||
}
|
||||
assert!(h.app.ui.section_open("light"), "the click didn't fold the section");
|
||||
}
|
||||
|
||||
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
|
||||
#[test]
|
||||
fn return_commits_the_crop_tool() {
|
||||
|
||||
@@ -141,8 +141,26 @@ pub struct SliderOut {
|
||||
pub reset: bool,
|
||||
}
|
||||
|
||||
/// What a value typed into a slider asks for (issue #322): a number such as `0.5`, `+12`, `-1,5`
|
||||
/// (comma or point) or `5600 K`, on the control's steps and clamped to its range. `None` when it
|
||||
/// isn't a finite number, so the slider keeps its value.
|
||||
pub fn typed_value(spec: &ControlSpec, text: &str) -> Option<f64> {
|
||||
let t = text.trim().replace(',', ".");
|
||||
let t = t.trim_end_matches(|c: char| c == '%' || c == 'K' || c == 'k' || c.is_whitespace());
|
||||
let v: f64 = t.parse().ok().filter(|v: &f64| v.is_finite())?;
|
||||
let step = spec.step.max(1e-9);
|
||||
Some(((v / step).round() * step).clamp(spec.min, spec.max))
|
||||
}
|
||||
|
||||
/// A slider's value as shown next to its label.
|
||||
fn shown_value(spec: &ControlSpec, v: f64) -> String {
|
||||
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
|
||||
if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }
|
||||
}
|
||||
|
||||
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
|
||||
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment.
|
||||
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to
|
||||
/// type one: Return or clicking away applies it, Esc keeps the old one.
|
||||
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
@@ -158,12 +176,58 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));
|
||||
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
|
||||
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
|
||||
// the value: click it to type an exact one (over the label, so it takes the click)
|
||||
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() });
|
||||
register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect);
|
||||
let typing_id = id.with("typing");
|
||||
let field_id = id.with("typingField");
|
||||
// the text being typed and how many frames the field has been up: it takes the keyboard on its
|
||||
// first frames (not on the click's own, whose release would take the focus straight back)
|
||||
let mut typing: Option<(String, u8)> = ui.data(|m| m.get_temp(typing_id));
|
||||
if enabled && typing.is_none() && value_resp.clicked() {
|
||||
typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0));
|
||||
}
|
||||
let mut out = SliderOut::default();
|
||||
let span = (spec.max - spec.min).max(1e-9);
|
||||
let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width();
|
||||
let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span;
|
||||
let mut v = value;
|
||||
if resp.double_clicked() || label_resp.double_clicked() {
|
||||
if let Some((mut text, frames)) = typing.take() {
|
||||
let field = egui::TextEdit::singleline(&mut text)
|
||||
.id(field_id)
|
||||
.font(t.font(12.5))
|
||||
.horizontal_align(egui::Align::RIGHT)
|
||||
.desired_width(value_rect.width())
|
||||
.margin(egui::Margin::ZERO);
|
||||
// a child over the value, so the field never moves the rows around it
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let te = child.add(field);
|
||||
if frames < 2 {
|
||||
te.request_focus();
|
||||
// the old value is selected, so typing replaces it (set again once the field has the
|
||||
// focus, which places the cursor)
|
||||
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), field_id).unwrap_or_default();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(ui.ctx(), field_id);
|
||||
typing = Some((text.clone(), frames + 1));
|
||||
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1)));
|
||||
} else if !te.has_focus() || !enabled {
|
||||
// Esc (or the slider turning off) keeps the old value; Return or clicking away applies
|
||||
let cancelled = !enabled || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
v = nv;
|
||||
}
|
||||
ui.data_mut(|m| m.remove::<(String, u8)>(typing_id));
|
||||
} else {
|
||||
typing = Some((text.clone(), frames));
|
||||
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames)));
|
||||
}
|
||||
} else if resp.double_clicked() || label_resp.double_clicked() {
|
||||
out.reset = true;
|
||||
out.value = Some(spec.default);
|
||||
v = spec.default;
|
||||
@@ -201,7 +265,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
// ↑ / ↓ while the pointer rests on the row nudge the value (⇧: five times as much)
|
||||
if enabled && out.value.is_none() && ui.rect_contains_pointer(row) {
|
||||
if enabled && out.value.is_none() && typing.is_none() && ui.rect_contains_pointer(row) {
|
||||
let (up, down, shift) = ui.input_mut(|i| {
|
||||
let shift = i.modifiers.shift;
|
||||
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
|
||||
@@ -223,9 +287,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
let text_c = if enabled { t.text_label } else { t.text_disabled };
|
||||
let p = ui.painter();
|
||||
p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c);
|
||||
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
|
||||
let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown };
|
||||
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c);
|
||||
if typing.is_none() {
|
||||
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);
|
||||
if enabled && value_resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
}
|
||||
let ring = 7.0;
|
||||
let tx = to_x(v);
|
||||
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
|
||||
@@ -262,15 +329,18 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
|
||||
register(ui.ctx(), format!("section:{id}"), r);
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
if ui.rect_contains_pointer(r) {
|
||||
p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));
|
||||
}
|
||||
let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0));
|
||||
paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
|
||||
p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text);
|
||||
let mut toggled = None;
|
||||
// shown while the pointer is anywhere on the header: `resp.hovered()` turns false as soon as the
|
||||
// pointer is over the eye itself (the eye is on top), which hid the eye before it could be
|
||||
// clicked (issue #316)
|
||||
if let Some(on) = enabled
|
||||
&& (resp.hovered() || !on)
|
||||
&& (ui.rect_contains_pointer(r) || !on)
|
||||
{
|
||||
let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0));
|
||||
let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
|
||||
@@ -467,6 +537,23 @@ mod tests {
|
||||
assert_eq!(nudged(&spec("wb.temp", 2000.0, 50000.0, 1.0), 6500.0, 1.0), 6550.0);
|
||||
assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped");
|
||||
}
|
||||
|
||||
/// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range.
|
||||
#[test]
|
||||
fn typed_values_are_stepped_and_clamped() {
|
||||
let exposure = lightcraft_develop::controls::find("light.exposure").unwrap();
|
||||
let contrast = lightcraft_develop::controls::find("light.contrast").unwrap();
|
||||
let temp = lightcraft_develop::controls::find("wb.temp").unwrap();
|
||||
assert_eq!(typed_value(exposure, "1.5"), Some(1.5));
|
||||
assert_eq!(typed_value(exposure, " +0,25 "), Some(0.25));
|
||||
assert_eq!(typed_value(exposure, "-12"), Some(exposure.min), "clamped");
|
||||
assert_eq!(typed_value(contrast, "33.4"), Some(33.0), "on the control's steps");
|
||||
assert_eq!(typed_value(contrast, "-20 %"), Some(-20.0));
|
||||
assert_eq!(typed_value(temp, "5600 K"), Some(5600.0));
|
||||
for bad in ["", "abc", "1.2.3", "NaN", "inf", "--1"] {
|
||||
assert_eq!(typed_value(contrast, bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+2
-1
@@ -275,7 +275,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged |
|
||||
| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered |
|
||||
| LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP |
|
||||
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled` | |
|
||||
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) |
|
||||
| LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" |
|
||||
| LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | |
|
||||
|
||||
@@ -706,6 +706,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
||||
| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active |
|
||||
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | |
|
||||
| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each |
|
||||
| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322 |
|
||||
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
|
||||
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
|
||||
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
|
||||
Traditional), Japanese and Brazilian Portuguese.
|
||||
|
||||
### Editing
|
||||
- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)
|
||||
and press Return; Esc keeps the old value.
|
||||
|
||||
### Editing
|
||||
- The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,
|
||||
Optics, Geometry and Calibration (issue #316).
|
||||
|
||||
### Reliability
|
||||
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,
|
||||
labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys
|
||||
|
||||
Reference in new issue
Block a user