ui: texture masks, the later-era texture dials, and the pools a canvas map needs
The mask pair is the one that matters and nothing can stand in for it. 1.12 has no texture masking at all — its single masked thing is the minimap disc, cut engine-side — and no idiom reaches the same end, because the point is that a rectangular texture draws as a non-rectangular shape. A seamless world map is that operation and nothing else: a zone's art is a rectangle of parchment and the map wants it cut to the zone's coastline. It was affordable because the render pass already had the slot. `UiQuad::mask` is a screen-anchored alpha mask built for the minimap's fixed circle with tiles panning under it, which is exactly `AddMaskTexture`'s semantics — the mask is another region with its own anchors, and the subject keeps the fragments its alpha covers at that place. So this is plumbing, not a new material. One mask per quad, said rather than hidden: widening it is a second texture binding and a second uniform on every UI quad in the client. The list is kept whole, because the order decides the winner — first added, which for a stitched map is the land mask. Two traps are pinned by tests: a mask region is skipped at extract rather than marked hidden, since its rect is what it covers; and a mask whose art is missing drops the mask, not the quad, because zero coverage would blank the art it was meant to shape. `Texture:SetRotation` was nearly free — the renderer has carried the angle since the minimap arrow, so this is the Lua setter for it. `SetDesaturation` is the fractional form of 1.12's own flag, rounded onto the one flag this renderer has, with the getter answering the caller's own number. The tile pair is applied: the host computes the repeat count once the art has loaded. `SetPropagateKeyboardInput` is stored, not applied: 1.12 already always propagates, so there is no swallow mode for false to select. `EditBox:HasFocus` publishes a fact this client already tracks and never exposed. Corpus: 85 ok / 1 fail, roster and digest unchanged. The 12 data-half reds are byte-identical to HEAD's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1319,6 +1319,8 @@ fn convert_entry(
|
||||
portrait_unit,
|
||||
rotation,
|
||||
desaturated,
|
||||
mask,
|
||||
tile,
|
||||
} => {
|
||||
// A live unit portrait (`SetPortraitTexture(region, unit)`): the model bake, or the
|
||||
// reference's 2D TemporaryPortrait while the model streams; no entry yet draws nothing.
|
||||
@@ -1382,8 +1384,15 @@ fn convert_entry(
|
||||
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
|
||||
None => UvRect::FULL,
|
||||
};
|
||||
// A slice past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone.
|
||||
let wrap = tiling_axes(&uv);
|
||||
// A slice past the texture tiles (`BonusActionBarFrame.lua:173`), on that axis alone;
|
||||
// `SetHorizTile`/`SetVertTile` ask for the same wrap without naming a count, so they
|
||||
// join it here and the count is computed below, once the art's texel size is known.
|
||||
let explicit_uv = tex_coords.is_some();
|
||||
let mut uv = uv;
|
||||
let wrap = {
|
||||
let (h, v) = tiling_axes(&uv);
|
||||
(h || (tile.0 && !explicit_uv), v || (tile.1 && !explicit_uv))
|
||||
};
|
||||
let handle = match (path.as_deref(), assets.as_mut()) {
|
||||
(Some(p), Some(a)) => {
|
||||
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
|
||||
@@ -1426,6 +1435,36 @@ fn convert_entry(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// `SetHorizTile`/`SetVertTile`'s repeat count: the region's own span over the art's,
|
||||
// which is what "tile at its native size" means. Taken here because the texel size is
|
||||
// only known once the art has loaded. A missing image, a zero-texel axis or an
|
||||
// explicit `SetTexCoord` leaves the mapping alone.
|
||||
if (tile.0 || tile.1) && !explicit_uv {
|
||||
if let Some(sz) = handle
|
||||
.as_ref()
|
||||
.and_then(|h| images.get(h))
|
||||
.map(|img| img.texture_descriptor.size)
|
||||
{
|
||||
let repeats = |span: f32, texels: u32| {
|
||||
if texels > 0 {
|
||||
span / texels as f32
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
let u = if tile.0 {
|
||||
repeats(rect.width(), sz.width)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let v = if tile.1 {
|
||||
repeats(rect.height(), sz.height)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
uv = UvRect::from_tex_coords([0.0, u, 0.0, v]);
|
||||
}
|
||||
}
|
||||
// A crop's magnified edge would filter in the neighbouring atlas cell.
|
||||
let uv_clamp = handle
|
||||
.as_ref()
|
||||
@@ -1447,6 +1486,18 @@ fn convert_entry(
|
||||
// `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise.
|
||||
rotation: -rotation,
|
||||
desaturated,
|
||||
// `AddMaskTexture`: the mask's own art and rect, converted to screen space the
|
||||
// same way as `rect`. A mask whose art the archives lack drops the mask, not the
|
||||
// quad — a missing cut-out shows the whole rectangle, which is the same thing the
|
||||
// caller sees when it never had a mask to add.
|
||||
mask: mask.and_then(|m| {
|
||||
let art = assets.as_mut()?.sprite_texture(&m.path, images)?;
|
||||
let r = m.rect;
|
||||
Some(crate::ui_pass::UiQuadMask {
|
||||
texture: art,
|
||||
rect: Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s),
|
||||
})
|
||||
}),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
@@ -1831,6 +1882,83 @@ mod clip_plumb_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A mask whose art the archives lack drops the **mask**, not the quad.
|
||||
///
|
||||
/// Worth pinning because the other reading is tempting and wrong: a mask that cannot load is
|
||||
/// zero coverage, and honouring that would erase the art it was meant to shape — a stitched
|
||||
/// map would go blank wherever a generated mask was missing, which reads as the map's own art
|
||||
/// failing. Showing the whole rectangle is what the caller sees when it never had a mask.
|
||||
///
|
||||
/// Runs with no `WorldAssets` at all, which is the same path a missing file takes: the quad is
|
||||
/// a pathless solid so it still draws, and the mask asks for art and gets none.
|
||||
#[test]
|
||||
fn a_mask_whose_art_is_missing_drops_the_mask_and_keeps_the_quad() {
|
||||
let script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "MaskPlumb")
|
||||
f:SetPoint("TOPLEFT", 0, 0)
|
||||
f:SetWidth(50); f:SetHeight(50)
|
||||
local m = f:CreateTexture(nil, "ARTWORK")
|
||||
m:SetTexture(0, 0, 1)
|
||||
m:SetAllPoints()
|
||||
local cut = f:CreateMaskTexture(nil, "ARTWORK")
|
||||
cut:SetTexture("Interface\\AddOns\\NoSuch\\mask")
|
||||
cut:SetAllPoints()
|
||||
m:AddMaskTexture(cut)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut app = App::new();
|
||||
app.insert_non_send_resource(script);
|
||||
app.init_resource::<UiQuads>();
|
||||
app.init_resource::<crate::ui_script::UiPassState>();
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::portrait::PaneBakeSizes>();
|
||||
app.init_resource::<crate::portrait::PanePositions>();
|
||||
app.init_resource::<crate::portrait::UnitPaneBooths>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
app.init_resource::<crate::ui_script::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
app.init_resource::<Time<Real>>();
|
||||
app.init_resource::<crate::ui_script::UiClock>();
|
||||
app.init_resource::<crate::ui_script::UiScaleCvar>();
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: UVec2::new(1024, 768).into(),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app.add_systems(Update, (tick_script, paint_script).chain());
|
||||
app.update();
|
||||
|
||||
let quads = &app.world().resource::<UiQuads>().quads;
|
||||
let marker: &UiQuad = quads
|
||||
.iter()
|
||||
.find(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
|
||||
.expect("the solid still draws");
|
||||
assert!(
|
||||
marker.mask.is_none(),
|
||||
"a mask with no art is no mask, not an empty one"
|
||||
);
|
||||
// And the mask region itself emitted nothing: one quad for the frame slot, one for the
|
||||
// solid, and none for the cut-out.
|
||||
assert_eq!(
|
||||
quads
|
||||
.iter()
|
||||
.filter(|q| q.color[2] == 1.0 && q.color[0] == 0.0)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclipped_texture_quad_carries_no_clip() {
|
||||
let script = UiScript::new().unwrap();
|
||||
|
||||
@@ -261,6 +261,104 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CreateMaskTexture(name, layer, template, subLayer)`: a Texture that shapes other regions
|
||||
// through `Texture:AddMaskTexture` and never draws itself (`region/paint.rs` has the pair, and
|
||||
// `RegionData::is_mask` the skip).
|
||||
//
|
||||
// 1.12 has no masking and therefore no mask region. It is built because a seamless map is
|
||||
// nothing but masking — a zone's rectangular parchment cut to the zone's coastline — and
|
||||
// because the method is called **unguarded** where it is used (`Map/Close.lua:159` builds the
|
||||
// continent land mask before any texture can ask for it).
|
||||
//
|
||||
// Built over the stock `CreateTexture` on this same table rather than beside it, so a mask is
|
||||
// an ordinary Texture in every other respect: the callers set its art, size and anchors with
|
||||
// the ordinary methods, and its *rect* is what the mask covers. `subLayer` is accepted and
|
||||
// ignored for the same reason the draw layer is read at all — a mask is never drawn, so
|
||||
// neither places it.
|
||||
m.set(
|
||||
"CreateMaskTexture",
|
||||
lua.create_function(
|
||||
|lua, (this, name, layer, template, _sub): (Table, Value, Value, Value, Value)| {
|
||||
let create: mlua::Function = this.get("CreateTexture")?;
|
||||
let wrapper: Table = create.call((this, name, layer, template))?;
|
||||
let rh = crate::script::region::region_handle_of(lua, &wrapper)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.region_data.entry(rh).or_default().is_mask = true;
|
||||
Ok(wrapper)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// `EditBox:HasFocus()` and `EditBox:SetCursorPosition(n)`, both **beyond 1.12**: its EditBox
|
||||
// table has `SetFocus`/`ClearFocus` and no getter, and no cursor setter at all
|
||||
// (`script/editbox/methods.rs` says so where the getter would be), so an addon of that era
|
||||
// keeps its own flag and never moves the caret from Lua.
|
||||
//
|
||||
// Both are built because both are called unguarded on this install's own libraries' widgets:
|
||||
// `HasFocus` decides whether a dropped autocomplete list closes (`Map/Nav.lua:374`, inside an
|
||||
// `OnEditFocusLost` callback, so a nil method raises every time a search box loses focus), and
|
||||
// `SetCursorPosition(0)` scrolls a long read-only link box back to its start after
|
||||
// `HighlightText` (`Map/News.lua:282`).
|
||||
//
|
||||
// `HasFocus` is the one focus owner (`Model::focused_editbox`), which is the fact 1.12 already
|
||||
// tracks — it just never published it. `SetCursorPosition` lands through the same clamp and
|
||||
// UTF-8 snap `HighlightText` uses, with the selection collapsed to the caret, which is what
|
||||
// the later-era method does.
|
||||
{
|
||||
let e: Table = lua.named_registry_value(crate::script::editbox::REG_EDITBOX_METHODS)?;
|
||||
e.set(
|
||||
"HasFocus",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(crate::script::binding_abi::flag(
|
||||
model.focused_editbox == Some(h),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
e.set(
|
||||
"SetCursorPosition",
|
||||
lua.create_function(|lua, (this, pos): (Table, Option<i64>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let pos = pos.unwrap_or(0);
|
||||
crate::script::editbox::set_cursor_position(lua, h, pos);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// `SetPropagateKeyboardInput(on)` / `GetPropagateKeyboardInput()`: 4.x's switch for whether a
|
||||
// frame with an `OnKeyDown` handler lets the key reach the next frame and the keybinding
|
||||
// system, or swallows it.
|
||||
//
|
||||
// **Stored, not applied**, and the reason is that 1.12's answer is "always propagate": its
|
||||
// key routing hands a `WorldFrame` key to the binding system regardless of what any frame's
|
||||
// handler did, so there is no swallow mode here for `false` to select. Honouring it would mean
|
||||
// building that mode, and refusing it costs a caller its whole key path — the one here arms an
|
||||
// invisible catcher frame and flips the flag inside its own handler
|
||||
// (`Map/Sketch.lua:579-585`), so a nil method raises in a key handler, once per keypress.
|
||||
// The pair round-trips, so a caller reading back what it wrote gets its own answer, and
|
||||
// `true` — the value every caller ends up on — is what this client does anyway.
|
||||
m.set(
|
||||
"SetPropagateKeyboardInput",
|
||||
lua.create_function(|lua, (this, on): (Table, Option<Value>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let on = !matches!(on, None | Some(Value::Nil) | Some(Value::Boolean(false)));
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.propagate_keyboard.insert(h, on);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetPropagateKeyboardInput",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let on = model.propagate_keyboard.get(&h).copied().unwrap_or(true);
|
||||
Ok(crate::script::binding_abi::flag(on))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `IsEventRegistered(event)`: 1 or nil, the 1.12 predicate shape. A frame in all-events mode
|
||||
// (`RegisterAllEvents`) answers 1 for anything, since that is what it will receive.
|
||||
m.set(
|
||||
|
||||
@@ -112,6 +112,97 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// secure-template system at all, so nothing here is ever locked down.
|
||||
g.set("InCombatLockdown", lua.create_function(|_, ()| Ok(false))?)?;
|
||||
|
||||
// `Ambiguate(fullName, context)`: 4.x's cross-realm name shortener, which strips a
|
||||
// `Name-Realm` suffix when the context says the realm is implied.
|
||||
//
|
||||
// **The identity function on the part that matters**, and that is the answer rather than a
|
||||
// stub: 1.12 has no connected realms and no cross-realm names, so a unit name here never
|
||||
// carries a realm and there is nothing to strip. The `-Realm` split is still applied, because
|
||||
// a *custom* realm can hand out a hyphenated name and the caller's whole purpose is to get the
|
||||
// short one; `"none"` is the context every caller in the corpus passes and it means "always
|
||||
// strip", which is what this does unconditionally. A nil name answers nil, as upstream does.
|
||||
g.set(
|
||||
"Ambiguate",
|
||||
lua.create_function(|_, (name, _context): (Option<String>, Option<String>)| {
|
||||
Ok(name.map(|n| match n.split_once('-') {
|
||||
Some((short, _realm)) if !short.is_empty() => short.to_string(),
|
||||
_ => n,
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `IsModifiedClick(action)`: whether the modifiers held match the binding for one of the
|
||||
// named click actions (`CHATLINK`, `SELFCAST`, `COMPAREITEMS`, …).
|
||||
//
|
||||
// 1.12 has the *bindings* — they are `SetModifiedClick`/`GetModifiedClick` over a handful of
|
||||
// CVar-backed rows, and its own FrameXML reads them with `GetModifiedClick("CHATLINK")` then
|
||||
// tests the key itself — but it registers no predicate that does both steps. So this is the
|
||||
// composition of two verbs this client already answers, not new state: the bound modifier
|
||||
// name for the action, then that modifier's own key test.
|
||||
//
|
||||
// An unknown action answers false rather than raising: every caller guards the name
|
||||
// (`Map/Nav.lua:161` is `IsModifiedClick and IsModifiedClick("CHATLINK")`) and a raise inside
|
||||
// a click handler would take the click's whole path with it.
|
||||
g.set(
|
||||
"IsModifiedClick",
|
||||
lua.create_function(|lua, action: Option<String>| {
|
||||
let Some(action) = action else {
|
||||
return Ok(false);
|
||||
};
|
||||
let g = lua.globals();
|
||||
let get: Option<mlua::Function> = g.get("GetModifiedClick")?;
|
||||
let Some(get) = get else {
|
||||
return Ok(false);
|
||||
};
|
||||
let bound: Option<String> = get.call(action.as_str())?;
|
||||
let Some(bound) = bound else {
|
||||
return Ok(false);
|
||||
};
|
||||
let down = |name: &str| -> mlua::Result<bool> {
|
||||
let f: Option<mlua::Function> = g.get(name)?;
|
||||
Ok(match f {
|
||||
Some(f) => !matches!(f.call::<Value>(())?, Value::Nil | Value::Boolean(false)),
|
||||
None => false,
|
||||
})
|
||||
};
|
||||
Ok(match bound.to_ascii_uppercase().as_str() {
|
||||
"SHIFT" => down("IsShiftKeyDown")?,
|
||||
"CTRL" => down("IsControlKeyDown")?,
|
||||
"ALT" => down("IsAltKeyDown")?,
|
||||
// `NONE`, and anything the table does not spell: no modifier is required, so a
|
||||
// plain click already is the modified click.
|
||||
"NONE" => true,
|
||||
_ => false,
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetQuestDifficultyColor(level)`: 2.x's rename of 1.12's own `GetDifficultyColor`, which is
|
||||
// FrameXML's (`reference/1.12-globals.tsv` files it under `framexml`) and so is loaded off the
|
||||
// player's chain after this. Registered as a **forwarder** rather than a copy of the table, so
|
||||
// the colours are whatever that file says and the two spellings cannot drift; a VM with no
|
||||
// FrameXML answers nil, which is what the name did before.
|
||||
g.set(
|
||||
"GetQuestDifficultyColor",
|
||||
lua.create_function(|lua, level: Value| {
|
||||
let f: Option<mlua::Function> = lua.globals().get("GetDifficultyColor")?;
|
||||
match f {
|
||||
Some(f) => f.call::<Value>(level),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetUnitSpeed(unit)`: 3.x's current, running and swim speeds in yards per second.
|
||||
//
|
||||
// **Not built**, and the reason is that answering would be inventing the number: 1.12 sends no
|
||||
// speed field for any unit — `MOVEMENTFLAG`s and the server's speed packets are not in this
|
||||
// client's unit model at all — so the only honest answer is the absent one. Its single caller
|
||||
// here already reads it that way, gated on `if GetUnitSpeed then` and falling back to a
|
||||
// distance-over-time estimate of its own (`Map/Extras.lua:418`), which is a better number than
|
||||
// anything this could make up. Listed in `docs/ADDON_COMPAT.md` rather than registered,
|
||||
// because a registered `0` would read as "standing still" forever.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
//! is exactly how pfUI iterates it (`modules/tooltip.lua:228`). A "tidier" version storing objects
|
||||
//! as array values would break that loop silently.
|
||||
//!
|
||||
//! `CreateFramePool` and `CreateFontStringPool` are **not** built: nothing in the installed set
|
||||
//! calls either, and `docs/ADDON_COMPAT.md` records them as unbuilt rather than guessed at. They
|
||||
//! are the same mixin over a different factory, so adding one later is small.
|
||||
//! `CreateFramePool` and `CreateFontStringPool` are now built too, over the same mixin and their
|
||||
//! own factories — a canvas map pools a frame per pin and a FontString per label, and both call
|
||||
//! them at file scope the way pfUI calls `CreateTexturePool`.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
@@ -136,6 +136,72 @@ function CreateTexturePool(parent, layer, subLayer, textureTemplate, resetterFun
|
||||
resetterFunc or TexturePool_HideAndClearAnchors)
|
||||
return pool
|
||||
end
|
||||
|
||||
FramePoolMixin = CreateFromMixins(ObjectPoolMixin)
|
||||
|
||||
-- Upstream names its frames `<parentName>-<frameType>-<n>`, which matters: a pooled frame has to
|
||||
-- be reachable by name for a template's own `$parent` children to resolve. An anonymous parent
|
||||
-- leaves the pooled frames anonymous too, which is also upstream's behaviour.
|
||||
local function FramePoolFactory(pool)
|
||||
pool.numFrames = (pool.numFrames or 0) + 1
|
||||
local name = nil
|
||||
if pool.parent and pool.parent.GetName and pool.parent:GetName() then
|
||||
name = pool.parent:GetName() .. "-" .. (pool.frameType or "Frame") .. "-" .. pool.numFrames
|
||||
end
|
||||
return CreateFrame(pool.frameType or "Frame", name, pool.parent, pool.frameTemplate)
|
||||
end
|
||||
|
||||
function FramePoolMixin:OnLoad(frameType, parent, frameTemplate, resetterFunc, forbidden)
|
||||
ObjectPoolMixin.OnLoad(self, FramePoolFactory, resetterFunc)
|
||||
self.frameType = frameType
|
||||
self.parent = parent
|
||||
self.frameTemplate = frameTemplate
|
||||
self.forbidden = forbidden
|
||||
end
|
||||
|
||||
function FramePool_Hide(pool, frame)
|
||||
frame:Hide()
|
||||
end
|
||||
|
||||
function FramePool_HideAndClearAnchors(pool, frame)
|
||||
frame:Hide()
|
||||
frame:ClearAllPoints()
|
||||
end
|
||||
|
||||
function CreateFramePool(frameType, parent, frameTemplate, resetterFunc, forbidden)
|
||||
local pool = CreateFromMixins(FramePoolMixin)
|
||||
pool:OnLoad(frameType or "Frame", parent, frameTemplate,
|
||||
resetterFunc or FramePool_HideAndClearAnchors, forbidden)
|
||||
return pool
|
||||
end
|
||||
|
||||
FontStringPoolMixin = CreateFromMixins(ObjectPoolMixin)
|
||||
|
||||
-- 1.12's `CreateFontString(name, layer, inherits)` takes no sub-layer either, so `subLayer` is
|
||||
-- kept on the pool and not passed, exactly as `TexturePoolFactory` above does.
|
||||
local function FontStringPoolFactory(pool)
|
||||
return pool.parent:CreateFontString(nil, pool.layer, pool.fontStringTemplate)
|
||||
end
|
||||
|
||||
function FontStringPoolMixin:OnLoad(parent, layer, subLayer, fontStringTemplate, resetterFunc)
|
||||
ObjectPoolMixin.OnLoad(self, FontStringPoolFactory, resetterFunc)
|
||||
self.parent = parent
|
||||
self.layer = layer
|
||||
self.subLayer = subLayer
|
||||
self.fontStringTemplate = fontStringTemplate
|
||||
end
|
||||
|
||||
function FontStringPool_HideAndClearAnchors(pool, fontString)
|
||||
fontString:Hide()
|
||||
fontString:ClearAllPoints()
|
||||
end
|
||||
|
||||
function CreateFontStringPool(parent, layer, subLayer, fontStringTemplate, resetterFunc)
|
||||
local pool = CreateFromMixins(FontStringPoolMixin)
|
||||
pool:OnLoad(parent, layer, subLayer, fontStringTemplate or "",
|
||||
resetterFunc or FontStringPool_HideAndClearAnchors)
|
||||
return pool
|
||||
end
|
||||
"#;
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::order::{self, DrawLayer, ZTarget};
|
||||
use crate::widget::{EditBoxState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
|
||||
|
||||
/// Registry key of the EditBox method table (the MAXCSTACK discipline: Lua-side root, named key).
|
||||
pub(super) const REG_EDITBOX_METHODS: &str = "__benilla_editbox_methods";
|
||||
pub(in crate::script) const REG_EDITBOX_METHODS: &str = "__benilla_editbox_methods";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Public entry points
|
||||
@@ -325,6 +325,14 @@ fn highlight_text(lua: &Lua, h: FrameHandle, start: i64, end: i64) {
|
||||
with_eb(lua, h, |eb| eb.highlight_text(start, end));
|
||||
}
|
||||
|
||||
/// `EditBox:SetCursorPosition(n)`, the later-era caret setter
|
||||
/// ([`crate::script::compat`]'s surface row): the caret at character offset `n`, selection
|
||||
/// collapsed. Through [`EditBoxState::highlight_text`]'s own clamp and UTF-8 snap, with the two
|
||||
/// ends equal, so there is one definition of "where in this string can a caret sit".
|
||||
pub(in crate::script) fn set_cursor_position(lua: &Lua, h: FrameHandle, pos: i64) {
|
||||
with_eb(lua, h, |eb| eb.highlight_text(pos, pos));
|
||||
}
|
||||
|
||||
// ── text-region sync ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Write the display string into the text region; a `password` box shows one `*` per character.
|
||||
|
||||
@@ -260,6 +260,13 @@ impl UiScript {
|
||||
if data_ref.is_some_and(super::nameplate::is_unpainted_glow) {
|
||||
continue;
|
||||
}
|
||||
// A mask texture (`Frame:CreateMaskTexture`) shapes other regions and is never
|
||||
// drawn itself. It is still laid out and still resolves, because its rect is
|
||||
// what the mask covers — which is why this skip is here and not a `hidden`
|
||||
// flag: hiding it would take it out of the layout too.
|
||||
if data_ref.is_some_and(|d| d.is_mask) {
|
||||
continue;
|
||||
}
|
||||
let mut data = data_ref.cloned().unwrap_or_default();
|
||||
// One hop (region combine `0x772180`): the region's alpha times its owner's
|
||||
// `effective_alpha`, which `SetAlpha 0x76a690` already carries down the tree.
|
||||
@@ -380,6 +387,18 @@ impl UiScript {
|
||||
// reference's is a texel block it would grey. No stock caller
|
||||
// desaturates a solid.
|
||||
desaturated: data.desaturated && has_path,
|
||||
// The first mask that has both art and a resolved rect. First, not
|
||||
// last: see `RegionData::masks` for why the order is the answer and
|
||||
// why there is one slot. A mask whose own anchors do not resolve is
|
||||
// walked past rather than taken as an empty rect, which would erase
|
||||
// the texture it was meant to shape.
|
||||
mask: data.masks.iter().find_map(|mh| {
|
||||
let path =
|
||||
model.region_data.get(mh).and_then(|d| d.texture.clone())?;
|
||||
let rect = model.region_resolved.get(mh).copied()?;
|
||||
Some(super::TextureMask { path, rect })
|
||||
}),
|
||||
tile: data.tile,
|
||||
}
|
||||
};
|
||||
// A region draws at its owner's scale: only frames carry one (`0x76ac90`).
|
||||
|
||||
@@ -295,7 +295,7 @@ pub use trainer::{
|
||||
pub use types::{
|
||||
BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject,
|
||||
FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline,
|
||||
QuadContent, ScriptValue, TexCoords, UnitPaneSubject,
|
||||
QuadContent, ScriptValue, TexCoords, TextureMask, UnitPaneSubject,
|
||||
};
|
||||
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
|
||||
pub use uimap::UiMapNode;
|
||||
@@ -355,7 +355,7 @@ pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [
|
||||
/// Texture-only, each in the client's Texture map (`0x87c128`): `GetVertexColor` though
|
||||
/// `SetVertexColor` is shared, and `SetGradientAlpha`, not FontString's `SetAlphaGradient`. 1.12
|
||||
/// has no Texture `SetSize` or `SetRotation` (PlayerModel's, `0x84f1fc`/`0x505f00`).
|
||||
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
|
||||
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 22] = [
|
||||
"SetGradient",
|
||||
"SetGradientAlpha",
|
||||
"GetTexture",
|
||||
@@ -368,6 +368,18 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
|
||||
"GetTexCoordModifiesRect",
|
||||
"SetDesaturated",
|
||||
"GetVertexColor",
|
||||
// Beyond 1.12, each with its `ALLOWED_BEYOND` row: the mask pair and the later-era
|
||||
// `SetRotation`/`SetDesaturation`/tile getters and setters a canvas map is written against.
|
||||
"AddMaskTexture",
|
||||
"RemoveMaskTexture",
|
||||
"SetRotation",
|
||||
"GetRotation",
|
||||
"SetDesaturation",
|
||||
"GetDesaturation",
|
||||
"SetHorizTile",
|
||||
"GetHorizTile",
|
||||
"SetVertTile",
|
||||
"GetVertTile",
|
||||
];
|
||||
|
||||
/// FontString-only: the font, text, justify and shadow block and the string metrics.
|
||||
|
||||
@@ -1197,6 +1197,10 @@ pub(crate) struct Model {
|
||||
/// `RegisterForDrag` buttons per frame, compared case-insensitively. Nothing destroys a frame,
|
||||
/// so this, `scripts` and `frame_events` are never pruned; a destroy path must prune all three.
|
||||
pub(crate) drag_registered: HashMap<FrameHandle, HashSet<String>>,
|
||||
/// `Frame:SetPropagateKeyboardInput` (`compat/classic_api/frame.rs`), per frame; absent is
|
||||
/// `true`, which is what this client does unconditionally. **Stored, not applied** — 1.12 has
|
||||
/// no swallow mode for `false` to select — so this exists so the getter answers the setter.
|
||||
pub(crate) propagate_keyboard: HashMap<FrameHandle, bool>,
|
||||
/// The drag gesture, armed by a press on a drag-registered frame, started past the threshold.
|
||||
pub(crate) drag: Option<cursor::DragGesture>,
|
||||
/// The one `StartMoving()` in flight, the client's `root+0xcfc` slot, apart from `drag` as in
|
||||
@@ -2198,6 +2202,7 @@ impl Model {
|
||||
container_autoequips: Vec::new(),
|
||||
bag_autostores: Vec::new(),
|
||||
drag_registered: HashMap::new(),
|
||||
propagate_keyboard: HashMap::new(),
|
||||
drag: None,
|
||||
moving: None,
|
||||
sizing: None,
|
||||
|
||||
@@ -293,6 +293,148 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// AddMaskTexture(mask) / RemoveMaskTexture(mask), Texture only, **beyond 1.12** (surface row
|
||||
// in `script/tests/widget_surface.rs`): the later-era way to shape a texture by another
|
||||
// region's alpha. 1.12 has no masking at all — its one masked thing is the minimap disc, cut
|
||||
// engine-side — and nothing can stand in for it, because the whole point is that a
|
||||
// *rectangular* texture draws as a non-rectangular shape.
|
||||
//
|
||||
// It is built rather than refused because a seamless world map is exactly that operation: a
|
||||
// zone's art is a rectangle of parchment and the map wants it clipped to the zone's coastline,
|
||||
// so without this every zone draws as an overlapping paper rectangle with its neighbours'
|
||||
// oceans and labels showing through. OpenMapClassic's own changelog names the symptom ("no
|
||||
// overlapping parchment rectangles / duplicated sea labels").
|
||||
//
|
||||
// The argument is the mask region itself, so the list stores handles and the resolved art and
|
||||
// rect are read at extract time — a mask that moves or changes art moves the cut with it,
|
||||
// which is what a panning map needs. A non-region argument is ignored rather than raised on:
|
||||
// every caller is `if mask then tex:AddMaskTexture(mask) end`, and the masks here come from
|
||||
// generated media that may simply not ship.
|
||||
for (name, add) in [("AddMaskTexture", true), ("RemoveMaskTexture", false)] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, (this, mask): (Table, Value)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let Value::Table(t) = mask else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(mh) = region_handle_of(lua, &t) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
if add {
|
||||
if !d.masks.contains(&mh) {
|
||||
d.masks.push(mh);
|
||||
}
|
||||
} else {
|
||||
d.masks.retain(|&h| h != mh);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// SetRotation(radians) / GetRotation(), Texture only, **beyond 1.12**: 1.12 registers
|
||||
// `SetRotation` on PlayerModel alone (`0x84f1fc`), so a Texture there cannot turn. The
|
||||
// renderer has carried `RegionData::rotation` since the minimap arrow, which is drawn by the
|
||||
// host; this is the Lua setter for it, and the quad path already negates it for the pass's
|
||||
// clockwise convention. Counterclockwise-positive, as the later-era method is.
|
||||
m.set(
|
||||
"SetRotation",
|
||||
lua.create_function(|lua, (this, radians): (Table, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
// A non-finite angle would rotate the quad's corners to NaN and drop it silently.
|
||||
let r = radians.filter(|r| r.is_finite()).unwrap_or(0.0);
|
||||
model.region_data.entry(rh).or_default().rotation = r;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetRotation",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.region_data.get(&rh).map_or(0.0, |d| d.rotation))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetDesaturation(amount) / GetDesaturation(), Texture only, **beyond 1.12**: the later-era
|
||||
// *fractional* form of `SetDesaturated` above, 0 full colour and 1 fully grey.
|
||||
//
|
||||
// This renderer greys all-or-nothing (one material flag, not a blend weight), so a fraction is
|
||||
// taken as on past the midpoint. That is a real approximation and it is the honest one
|
||||
// available: the alternative is refusing the method, and a caller's only use of it is to dim
|
||||
// art it has already decided to dim — OpenMapClassic desaturates its minimap tiles by a fixed
|
||||
// 0.5 so the painted map reads over them. The getter answers back what was set, not the
|
||||
// rounded flag, so a caller reading its own value sees its own number.
|
||||
m.set(
|
||||
"SetDesaturation",
|
||||
lua.create_function(|lua, (this, amount): (Table, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let a = amount
|
||||
.filter(|a| a.is_finite())
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 1.0);
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.desaturation = a;
|
||||
d.desaturated = a > 0.5;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetDesaturation",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.region_data.get(&rh).map_or(0.0, |d| d.desaturation))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetHorizTile/SetVertTile and their getters, Texture only, **beyond 1.12**: repeat the art
|
||||
// across the region at its own texel size instead of stretching it to fit.
|
||||
//
|
||||
// 1.12 reaches the same end through `SetTexCoord(0, n, 0, 1)` — UVs past the texture, which is
|
||||
// the idiom this renderer already keys its wrap mode on (`extract::tiling_axes`) — but it has
|
||||
// no switch that says "however many times it takes", because that count depends on the art's
|
||||
// texel size and 1.12's Lua is never told it. So the flag is carried and the host computes the
|
||||
// count once the art has loaded. An explicit `SetTexCoord` wins, because it names the count.
|
||||
for (name, vertical) in [("SetHorizTile", false), ("SetVertTile", true)] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, (this, on): (Table, Option<bool>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let on = on.unwrap_or(false);
|
||||
if vertical {
|
||||
d.tile.1 = on;
|
||||
} else {
|
||||
d.tile.0 = on;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
for (name, vertical) in [("GetHorizTile", false), ("GetVertTile", true)] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.region_data.get(&rh).is_some_and(|d| {
|
||||
if vertical {
|
||||
d.tile.1
|
||||
} else {
|
||||
d.tile.0
|
||||
}
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// GetTexture (`0x79ba70`), Texture only, answers one value: "Solid Texture" for the colour
|
||||
// form (`0x835708`), not nil, and a path cut at its last `.` (`0x79baf0`), even a dot in a
|
||||
// directory name, as the client cuts it.
|
||||
|
||||
@@ -734,6 +734,32 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// same reveal gate, published in the shape the canvas framework reads
|
||||
// (script/compat/classic_api/uimap.rs).
|
||||
"C_MapExplorationInfo",
|
||||
// 4.x's cross-realm name shortener. 1.12 has no connected realms and no cross-realm
|
||||
// names, so this is the identity on the part that matters; the `-Realm` split is still
|
||||
// applied because a custom realm can hand out a hyphenated name and the caller's whole
|
||||
// purpose is the short one. Called unguarded on a chat sender (`Map/Pins.lua:286`).
|
||||
"Ambiguate",
|
||||
// 2.x's rename of 1.12's own FrameXML `GetDifficultyColor`. Registered as a **forwarder**
|
||||
// to that function rather than a copy of its table, so the colours are whatever the
|
||||
// player's own chain says and the two spellings cannot drift.
|
||||
"GetQuestDifficultyColor",
|
||||
// 2.0's predicate over 1.12's own modified-click *bindings*: 1.12 has
|
||||
// `GetModifiedClick`/`SetModifiedClick` and its FrameXML tests the key itself, but
|
||||
// registers nothing that does both steps. So this composes two verbs this client already
|
||||
// answers rather than adding state.
|
||||
"IsModifiedClick",
|
||||
// ClassicAPI's object pools, the frame and font-string halves beside the texture pool
|
||||
// already here (`compat/classic_api/pools.rs`): a canvas map pools a frame per pin and a
|
||||
// FontString per label, and calls both at file scope the way pfUI calls
|
||||
// `CreateTexturePool`. The mixins and the default resetters are upstream's own names,
|
||||
// which is what a caller passing `FramePool_HideAndClearAnchors` by name needs.
|
||||
"CreateFramePool",
|
||||
"FramePoolMixin",
|
||||
"FramePool_Hide",
|
||||
"FramePool_HideAndClearAnchors",
|
||||
"CreateFontStringPool",
|
||||
"FontStringPoolMixin",
|
||||
"FontStringPool_HideAndClearAnchors",
|
||||
// The two argument *shapes* `C_Map`'s uiMapID members take. 1.12 passes coordinates as
|
||||
// loose numbers and has no vector or map-point object; a caller cannot call
|
||||
// `C_Map.SetUserWaypoint` or `C_Map.GetWorldPosFromMapPos` without them, and every call
|
||||
|
||||
@@ -856,13 +856,18 @@ fn the_two_region_leaves_answer_their_own_maps() {
|
||||
assert!(has(&s, "Tex", m), "a Texture answers {m}");
|
||||
assert!(!has(&s, "Str", m), "a FontString must NOT answer {m}");
|
||||
}
|
||||
// `SetRotation` is 1.12's PlayerModel verb (`0x84f1fc`/`0x505f00`), in neither leaf map.
|
||||
for leaf in ["Tex", "Str"] {
|
||||
assert!(
|
||||
!has(&s, leaf, "SetRotation"),
|
||||
"SetRotation is not a region method"
|
||||
);
|
||||
}
|
||||
// `SetRotation` is 1.12's PlayerModel verb (`0x84f1fc`/`0x505f00`) and no FontString's; the
|
||||
// later-era Texture spelling is built and carries its own `ALLOWED_BEYOND` row
|
||||
// (`compat/classic_api/frame.rs`'s surface list), so the split, not the absence, is the rule
|
||||
// here: a Texture may turn and a FontString may not.
|
||||
assert!(
|
||||
has(&s, "Tex", "SetRotation"),
|
||||
"a Texture answers SetRotation"
|
||||
);
|
||||
assert!(
|
||||
!has(&s, "Str", "SetRotation"),
|
||||
"a FontString must NOT answer SetRotation"
|
||||
);
|
||||
// FontString-only.
|
||||
for m in [
|
||||
"SetText",
|
||||
@@ -1530,3 +1535,215 @@ fn the_visibility_reading_includes_named_regions_and_their_owners_state() {
|
||||
"an owner that is hidden hides its regions"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mask shapes the texture it was added to and draws nothing of its own.
|
||||
///
|
||||
/// Both halves matter and both were easy to get wrong. The mask's *rect* is what it covers, so it
|
||||
/// has to stay in the layout — which is why it is skipped at extract rather than marked hidden —
|
||||
/// and the masked quad has to carry the mask's own resolved rect, not the texture's, or a panning
|
||||
/// tile would drag its cut-out along with it.
|
||||
#[test]
|
||||
fn a_mask_texture_shapes_its_subject_and_never_draws_itself() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "MaskOwner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
f:SetWidth(200) f:SetHeight(100)
|
||||
art = f:CreateTexture("MaskArt", "ARTWORK")
|
||||
art:SetTexture("Interface\\WorldMap\\Elwynn\\Elwynn1")
|
||||
art:SetAllPoints()
|
||||
cut = f:CreateMaskTexture("MaskCut", "ARTWORK")
|
||||
cut:SetTexture("Interface\\AddOns\\Thing\\land")
|
||||
cut:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 10, 20)
|
||||
cut:SetWidth(50) cut:SetHeight(40)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
let textures = |s: &UiScript| -> Vec<(Option<String>, Option<crate::script::TextureMask>)> {
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture { path, mask, .. } => Some((path.clone(), mask.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let before = textures(&s);
|
||||
assert_eq!(
|
||||
before.len(),
|
||||
1,
|
||||
"the mask is not drawn, so only the art emits a quad: {before:?}"
|
||||
);
|
||||
assert!(before[0].1.is_none(), "and it is unmasked until asked");
|
||||
|
||||
s.run("art:AddMaskTexture(cut)").unwrap();
|
||||
let after = textures(&s);
|
||||
assert_eq!(after.len(), 1, "still one quad");
|
||||
let mask = after[0].1.clone().expect("the art now carries the mask");
|
||||
assert_eq!(mask.path, "Interface\\AddOns\\Thing\\land");
|
||||
// The mask's own rect, which is a quarter the width of the art it shapes.
|
||||
assert!(
|
||||
(mask.rect.left - 10.0).abs() < 0.01
|
||||
&& (mask.rect.bottom - 20.0).abs() < 0.01
|
||||
&& (mask.rect.right - 60.0).abs() < 0.01
|
||||
&& (mask.rect.top - 60.0).abs() < 0.01,
|
||||
"the mask's resolved rect, not the texture's: {:?}",
|
||||
mask.rect
|
||||
);
|
||||
|
||||
s.run("art:RemoveMaskTexture(cut)").unwrap();
|
||||
assert!(
|
||||
textures(&s)[0].1.is_none(),
|
||||
"removing it takes the cut off again"
|
||||
);
|
||||
}
|
||||
|
||||
/// `AddMaskTexture` keeps the list in call order and the **first** is the one applied, because
|
||||
/// that is the one that shapes the subject — a stitched map adds the zone's land mask first and
|
||||
/// its refinements after. A second add of the same mask is not a second entry.
|
||||
#[test]
|
||||
fn the_first_mask_added_is_the_one_applied() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "TwoMaskOwner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
f:SetWidth(200) f:SetHeight(100)
|
||||
art = f:CreateTexture(nil, "ARTWORK")
|
||||
art:SetTexture("Interface\\WorldMap\\Elwynn\\Elwynn1")
|
||||
art:SetAllPoints()
|
||||
land = f:CreateMaskTexture(nil, "ARTWORK")
|
||||
land:SetTexture("land") land:SetAllPoints()
|
||||
hole = f:CreateMaskTexture(nil, "ARTWORK")
|
||||
hole:SetTexture("hole") hole:SetAllPoints()
|
||||
art:AddMaskTexture(land)
|
||||
art:AddMaskTexture(hole)
|
||||
art:AddMaskTexture(land)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let applied = s
|
||||
.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(_),
|
||||
mask: Some(m),
|
||||
..
|
||||
} => Some(m.path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the masked art");
|
||||
assert_eq!(applied, "land", "first added wins");
|
||||
|
||||
// Taking the first off promotes the second, which is what a caller swapping a mask means.
|
||||
s.run("art:RemoveMaskTexture(land)").unwrap();
|
||||
let applied = s
|
||||
.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(_),
|
||||
mask: Some(m),
|
||||
..
|
||||
} => Some(m.path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("still masked");
|
||||
assert_eq!(applied, "hole");
|
||||
}
|
||||
|
||||
/// A mask whose own anchors never resolve is walked past rather than taken as an empty rect: an
|
||||
/// empty mask erases the texture it was meant to shape, which looks like the art failing to load.
|
||||
#[test]
|
||||
fn an_unresolved_mask_is_walked_past_not_applied_as_nothing() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "BadMaskOwner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
f:SetWidth(200) f:SetHeight(100)
|
||||
art = f:CreateTexture(nil, "ARTWORK")
|
||||
art:SetTexture("Interface\\WorldMap\\Elwynn\\Elwynn1")
|
||||
art:SetAllPoints()
|
||||
-- Art but no anchors: a region nobody placed, which never resolves.
|
||||
orphan = f:CreateMaskTexture(nil, "ARTWORK")
|
||||
orphan:SetTexture("orphan")
|
||||
orphan:ClearAllPoints()
|
||||
good = f:CreateMaskTexture(nil, "ARTWORK")
|
||||
good:SetTexture("good") good:SetAllPoints()
|
||||
art:AddMaskTexture(orphan)
|
||||
art:AddMaskTexture(good)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let applied = s
|
||||
.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(_),
|
||||
mask,
|
||||
..
|
||||
} => Some(mask.clone().map(|m| m.path)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the art quad");
|
||||
assert_eq!(
|
||||
applied.as_deref(),
|
||||
Some("good"),
|
||||
"the unplaced mask is skipped and the next one applies"
|
||||
);
|
||||
}
|
||||
|
||||
/// The later-era `SetRotation`/`SetDesaturation` and the tile pair round-trip, and the fractional
|
||||
/// desaturation rounds onto the one material flag this renderer has.
|
||||
#[test]
|
||||
fn the_later_era_texture_dials_round_trip() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "DialOwner")
|
||||
t = f:CreateTexture("DialArt", "ARTWORK")
|
||||
t:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f64>("return t:GetRotation()").unwrap(), 0.0);
|
||||
s.run("t:SetRotation(1.5)").unwrap();
|
||||
assert!((s.eval::<f64>("return t:GetRotation()").unwrap() - 1.5).abs() < 1e-6);
|
||||
// A non-finite angle would send the quad's corners to NaN and drop it with no error.
|
||||
s.run("t:SetRotation(0/0)").unwrap();
|
||||
assert_eq!(s.eval::<f64>("return t:GetRotation()").unwrap(), 0.0);
|
||||
|
||||
assert_eq!(s.eval::<f64>("return t:GetDesaturation()").unwrap(), 0.0);
|
||||
s.run("t:SetDesaturation(0.4)").unwrap();
|
||||
assert!(
|
||||
(s.eval::<f64>("return t:GetDesaturation()").unwrap() - 0.4).abs() < 1e-6,
|
||||
"the getter answers the caller's own fraction"
|
||||
);
|
||||
assert!(
|
||||
!desaturated(&s, "DialArt"),
|
||||
"…and below the midpoint the one flag stays off"
|
||||
);
|
||||
s.run("t:SetDesaturation(0.8)").unwrap();
|
||||
assert!(desaturated(&s, "DialArt"), "past it, on");
|
||||
|
||||
assert!(!s.eval::<bool>("return t:GetHorizTile()").unwrap());
|
||||
assert!(!s.eval::<bool>("return t:GetVertTile()").unwrap());
|
||||
s.run("t:SetHorizTile(true)").unwrap();
|
||||
assert!(s.eval::<bool>("return t:GetHorizTile()").unwrap());
|
||||
assert!(
|
||||
!s.eval::<bool>("return t:GetVertTile()").unwrap(),
|
||||
"one axis at a time"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,6 +405,112 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[
|
||||
the two spellings cannot drift. Ace3 calls it unguarded on every button it builds \
|
||||
(AceConfigDialog-3.0.lua:586)",
|
||||
),
|
||||
(
|
||||
"AddMaskTexture",
|
||||
"Texture",
|
||||
"The map canvas: 1.12 has no texture masking at all, and nothing can stand in for it \
|
||||
because the point is that a RECTANGULAR texture draws as a non-rectangular shape. A \
|
||||
seamless world map is that operation and nothing else — a zone's parchment cut to the \
|
||||
zone's coastline — so without it every zone draws as a paper rectangle over its \
|
||||
neighbours' oceans and labels. Called unguarded where it is used: Map/Close.lua:162",
|
||||
),
|
||||
(
|
||||
"RemoveMaskTexture",
|
||||
"Texture",
|
||||
"The other half of AddMaskTexture, so a caller that swaps a mask can take the old one \
|
||||
off; without it a re-masked tile keeps the first cut forever",
|
||||
),
|
||||
(
|
||||
"CreateMaskTexture",
|
||||
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
|
||||
"The region AddMaskTexture takes: a Texture that shapes others and never draws itself. \
|
||||
Built over the stock CreateTexture on the same table, so a mask is an ordinary Texture \
|
||||
in every other respect. Map/Close.lua:159 builds one before any texture can ask for it",
|
||||
),
|
||||
(
|
||||
"SetRotation",
|
||||
"Texture",
|
||||
"1.12 registers SetRotation on PlayerModel alone (0x84f1fc), so a Texture there cannot \
|
||||
turn. The renderer has carried the angle since the minimap arrow, which the host draws; \
|
||||
this is the Lua setter for it. The map's own player arrow and its compass rail need it",
|
||||
),
|
||||
(
|
||||
"GetRotation",
|
||||
"Texture",
|
||||
"The read half of SetRotation, so the pair round-trips; 0 by default",
|
||||
),
|
||||
(
|
||||
"SetDesaturation",
|
||||
"Texture",
|
||||
"The later-era FRACTIONAL form of 1.12's own SetDesaturated. This renderer greys \
|
||||
all-or-nothing (one material flag, not a blend weight), so a fraction is taken as on \
|
||||
past the midpoint — an approximation, and the honest one available, since the only use \
|
||||
of it in the corpus is dimming art the caller has already decided to dim \
|
||||
(Map/Close.lua desaturates minimap tiles by a fixed amount so the painted map reads \
|
||||
over them)",
|
||||
),
|
||||
(
|
||||
"GetDesaturation",
|
||||
"Texture",
|
||||
"The read half, answering the caller's own fraction rather than the rounded flag",
|
||||
),
|
||||
(
|
||||
"SetHorizTile",
|
||||
"Texture",
|
||||
"Repeat the art across the region at its own texel size. 1.12 reaches the same end with \
|
||||
SetTexCoord(0, n, 0, 1) — UVs past the texture, which is the idiom this renderer keys \
|
||||
its wrap mode on — but has no switch meaning 'however many times it takes', because \
|
||||
that count is rect/texels and 1.12's Lua is never told the texel size. Applied: the \
|
||||
host computes the count once the art has loaded, and an explicit SetTexCoord wins",
|
||||
),
|
||||
(
|
||||
"SetVertTile",
|
||||
"Texture",
|
||||
"As SetHorizTile, for the other axis",
|
||||
),
|
||||
(
|
||||
"GetHorizTile",
|
||||
"Texture",
|
||||
"The read half of SetHorizTile; false by default",
|
||||
),
|
||||
(
|
||||
"GetVertTile",
|
||||
"Texture",
|
||||
"The read half of SetVertTile; false by default",
|
||||
),
|
||||
(
|
||||
"HasFocus",
|
||||
"EditBox",
|
||||
"1.12's EditBox table has SetFocus and ClearFocus and no getter at all, so an addon of \
|
||||
that era keeps its own flag. The fact is already tracked here (Model::focused_editbox, \
|
||||
the client's own DAT_00cf4dc8) and was simply never published. Called unguarded inside \
|
||||
an OnEditFocusLost callback (Map/Nav.lua:374), so a nil method raised every time a \
|
||||
search box lost focus",
|
||||
),
|
||||
(
|
||||
"SetCursorPosition",
|
||||
"EditBox",
|
||||
"1.12 has no cursor setter at all — the caret moves only by typing, clicking or \
|
||||
HighlightText. Lands through HighlightText's own clamp and UTF-8 snap with the selection \
|
||||
collapsed, so there is one definition of where a caret may sit. Map/News.lua:282 calls \
|
||||
it to scroll a long read-only link box back to its start",
|
||||
),
|
||||
(
|
||||
"SetPropagateKeyboardInput",
|
||||
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
|
||||
"4.x's switch for whether a frame with an OnKeyDown handler lets the key reach the next \
|
||||
frame and the binding system. STORED, NOT APPLIED, and the reason is that 1.12's answer \
|
||||
is always-propagate: its key routing hands the key to the binding system regardless of \
|
||||
what a handler did, so there is no swallow mode for false to select. Refusing it costs a \
|
||||
caller its whole key path — Map/Sketch.lua:579 arms a catcher frame and flips the flag \
|
||||
inside its own handler, so a nil method raises once per keypress",
|
||||
),
|
||||
(
|
||||
"GetPropagateKeyboardInput",
|
||||
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
|
||||
"The read half, so the pair round-trips; true by default, which is what this client does \
|
||||
unconditionally",
|
||||
),
|
||||
(
|
||||
"CanWordWrap",
|
||||
"FontString",
|
||||
|
||||
@@ -168,6 +168,14 @@ pub enum QuadContent {
|
||||
/// `SetDesaturated`: the texel draws as its luminance, then is tinted by `color`, since
|
||||
/// `SetItemButtonDesaturated` passes a dim tint with the flag.
|
||||
desaturated: bool,
|
||||
/// `Texture:AddMaskTexture`'s first mask, resolved: the mask art and the screen rect it
|
||||
/// covers. `None` is unmasked, which every 1.12 texture is.
|
||||
mask: Option<TextureMask>,
|
||||
/// `SetHorizTile`/`SetVertTile`: sample with repeat addressing across the region at the
|
||||
/// art's own texel size. Carried as a flag rather than as UVs because the repeat count is
|
||||
/// `rect / texels` and the texel size is only known once the art has loaded, which is in
|
||||
/// the host's half.
|
||||
tile: (bool, bool),
|
||||
},
|
||||
/// A `ColorSelect`'s `<ColorWheelTexture>` disc, generated: the pixel at normalised offset
|
||||
/// `(nx, ny)` from the centre is `HSV(atan2(ny, nx)·180/π + 180, min(|n|, 1), 1)`, inverting
|
||||
@@ -369,6 +377,22 @@ pub struct ExtractedQuad {
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
/// A resolved mask over a Texture quad (`Texture:AddMaskTexture`): the mask region's own art and
|
||||
/// the rect it occupies on screen, in the same y-up UI space as [`ExtractedQuad::rect`].
|
||||
///
|
||||
/// The mask is **screen-anchored**, not UV-anchored, because that is what the method means: the
|
||||
/// mask is another region with its own anchors, and the masked texture keeps only the fragments
|
||||
/// the mask's alpha covers at that place. A tile panning under a fixed cut-out masks correctly,
|
||||
/// which is the whole reason a stitched map uses one — a zone's rectangular art clipped to the
|
||||
/// zone's coastline.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextureMask {
|
||||
/// The mask art's path; its **alpha** is the coverage.
|
||||
pub path: String,
|
||||
/// The mask's resolved rect, y-up UI space.
|
||||
pub rect: crate::layout::Rect,
|
||||
}
|
||||
|
||||
/// A two-stop linear gradient (`SetGradientAlpha`/`SetGradient`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Gradient {
|
||||
@@ -438,6 +462,10 @@ pub(crate) struct RegionData {
|
||||
pub(crate) anchors: Vec<Anchor>,
|
||||
/// `SetDesaturated` (`0x79c1e0`): the renderer greys the texel, so the binding reports support.
|
||||
pub(crate) desaturated: bool,
|
||||
/// The later-era `SetDesaturation`'s fraction, which [`Self::desaturated`] rounds for the one
|
||||
/// material flag this renderer has. Kept so the getter answers the caller's own number rather
|
||||
/// than the rounded flag.
|
||||
pub(crate) desaturation: f32,
|
||||
/// `SetNonSpaceWrap`/`CanNonSpaceWrap` (`0x79e9f0`/`0x79ead0`), `None` on. Stored, not applied:
|
||||
/// the reference's flag `0x40` drives a mid-word wrap and the ellipsis fit count (`0x771ec0`).
|
||||
pub(crate) non_space_wrap: Option<bool>,
|
||||
@@ -466,8 +494,28 @@ pub(crate) struct RegionData {
|
||||
/// `0x79c080`). Stored, not applied: there a set flag makes `SetTexCoord` re-derive the rect
|
||||
/// from the UV quad (`0x770462`); no stock or corpus code sets it.
|
||||
pub(crate) tex_coord_modifies_rect: bool,
|
||||
/// Rotation about the centre, radians counterclockwise; always 0: 1.12 has no Texture setter.
|
||||
/// Rotation about the centre, radians counterclockwise. 1.12 has no Texture setter; the
|
||||
/// later-era `Texture:SetRotation` writes it (`compat/classic_api/frame.rs`'s surface row).
|
||||
pub(crate) rotation: f32,
|
||||
/// `AddMaskTexture`'s masks in call order, `RemoveMaskTexture`'s removals taken out.
|
||||
///
|
||||
/// **Only the first is drawn.** The render pass carries one mask slot per quad
|
||||
/// ([`crate::script::TextureMask`]), and widening it is a second texture binding and a second
|
||||
/// uniform on every UI quad in the client. The list is kept whole anyway, because the methods
|
||||
/// have to round-trip for a caller that adds and removes, and because the *order* decides
|
||||
/// which one wins — first added, which for a stitched map is the zone's land mask, the one
|
||||
/// that shapes the coastline. A second mask is recorded and not applied.
|
||||
pub(crate) masks: Vec<crate::widget::RegionHandle>,
|
||||
/// A mask texture (`Frame:CreateMaskTexture`), which shapes other regions and never draws
|
||||
/// itself. 1.12 has no such region kind, so this is a flag on an ordinary Texture rather than
|
||||
/// a `RegionKind`: every other method on it behaves exactly as a Texture's, which is what the
|
||||
/// callers do with it — they set its art, size and anchors and then hand it to
|
||||
/// `AddMaskTexture`.
|
||||
pub(crate) is_mask: bool,
|
||||
/// `SetHorizTile`/`SetVertTile` (`GetHorizTile`/`GetVertTile` read them back). Applied: the
|
||||
/// host repeats the art across the region at its own texel size when no explicit `SetTexCoord`
|
||||
/// says otherwise.
|
||||
pub(crate) tile: (bool, bool),
|
||||
/// The font object this FontString inherits (`FONTINSTANCE+0x028`, a FontString's `+0xd0`), a
|
||||
/// live link: its paint is copied below, and
|
||||
/// [`script::font::propagate`](super::font::propagate) repaints on a change.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user