diff --git a/src/ipe-web/src/dialogs.ts b/src/ipe-web/src/dialogs.ts index 7621e69..edc2fa0 100644 --- a/src/ipe-web/src/dialogs.ts +++ b/src/ipe-web/src/dialogs.ts @@ -21,6 +21,7 @@ type DialogElement = | "textedit" | "list" | "checkbox" + | "image" | "button"; export interface ElementOptions { @@ -35,6 +36,8 @@ export interface ElementOptions { col: number; rowspan: number; colspan: number; + width: number; + height: number; } interface ButtonOptions { @@ -111,6 +114,9 @@ export function setElement(w: ElementOptions): void { const el = document.getElementById(`dialog-element-${w.name}`); if (el == null) return; switch (w.type) { + case "image": + drawImagePreview(el as HTMLCanvasElement, w.text); + break; case "checkbox": (el as HTMLInputElement).checked = w.value !== 0; break; @@ -132,6 +138,65 @@ export function setElement(w: ElementOptions): void { } } +function previewNumber(value: string, fallback: number): number { + const n = Number.parseFloat(value); + return Number.isFinite(n) ? n : fallback; +} + +function drawImagePreview(canvas: HTMLCanvasElement, spec: string): void { + const [kind, value = "", zoomText = "1"] = spec.split("|"); + const zoom = Math.max(0.1, Math.min(100, previewNumber(zoomText, 1))); + const ctx = canvas.getContext("2d"); + if (ctx == null) return; + canvas.dataset.previewSpec = spec; + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "rgb(255,255,220)"; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = "rgb(160,160,130)"; + ctx.strokeRect(0.5, 0.5, w - 1, h - 1); + const left = 18; + const top = 16; + const right = w - 18; + const bottom = h - 16; + const cx = (left + right) / 2; + const cy = (top + bottom) / 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + if (kind === "imagefile") { + const image = new Image(); + image.addEventListener("load", () => { + if (canvas.dataset.previewSpec !== spec) return; + const logicalWidth = image.width / zoom; + const logicalHeight = image.height / zoom; + const scale = Math.min( + 1, + (right - left) / logicalWidth, + (bottom - top) / logicalHeight, + ); + const width = logicalWidth * scale; + const height = logicalHeight * scale; + ctx.drawImage(image, cx - width / 2, cy - height / 2, width, height); + }); + image.addEventListener("error", () => { + if (canvas.dataset.previewSpec !== spec) return; + ctx.fillStyle = "rgb(90,90,90)"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Preview unavailable", cx, cy); + }); + image.src = value; + return; + } + ctx.fillStyle = "rgb(90,90,90)"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(value || "Preview unavailable", cx, cy); +} + export function setupElements( ipe: Ipe, body: HTMLDivElement, @@ -198,6 +263,15 @@ export function setupElements( el = el1; break; } + case "image": { + const el1 = document.createElement("canvas"); + el1.id = `dialog-element-${w.name}`; + el1.width = w.width; + el1.height = w.height; + drawImagePreview(el1, w.text); + el = el1; + break; + } case "list": { const el1 = document.createElement("div"); el1.id = `dialog-element-${w.name}`; @@ -233,6 +307,7 @@ export function setupElements( case "input": case "button": case "combo": + case "image": el.style.justifySelf = "stretch"; el.style.alignSelf = "center"; break; diff --git a/src/ipe/lua/actions.lua b/src/ipe/lua/actions.lua index 2e057e0..60d44b9 100644 --- a/src/ipe/lua/actions.lua +++ b/src/ipe/lua/actions.lua @@ -2642,6 +2642,43 @@ local function sheets_namelist(list) return r end +local function sheets_unique_name(list, base) + local used = {} + for _,s in ipairs(list) do + local name = s:name() + if name then used[name] = true end + end + if not used[base] then return base end + local n = 2 + while used[base .. " " .. n] do n = n + 1 end + return base .. " " .. n +end + +local function sheets_new(d, dd) + local i = d:get("list") + if not i then i = 1 end + local name = sheets_unique_name(dd.list, "new") + while true do + name = dd.model:getString("Name of new stylesheet", "New stylesheet", name) + if not name then return end + name = name:match("^%s*(.-)%s*$") + if name == "" then + dd.model:warning("Cannot create stylesheet", "The stylesheet name cannot be empty") + elseif name ~= sheets_unique_name(dd.list, name) then + dd.model:warning("Cannot create stylesheet", + "A stylesheet with this name already exists") + else + break + end + end + local sheet = ipe.Sheet() + sheet:setName(name) + table.insert(dd.list, i, sheet) + d:set("list", sheets_namelist(dd.list)) + d:set("list", i) + dd.modified = true +end + local function sheets_add(d, dd) local i = d:get("list") if not i then i = 1 end @@ -2659,6 +2696,387 @@ local function sheets_add(d, dd) dd.modified = true end +local visual_style_categories = { + { label="Colors", kind="color", color=true, default="#000000", + help="Color values are #rrggbb or three numbers between 0 and 1." }, + { label="Pen widths", kind="pen", default="1", + help="Pen widths are numbers in Ipe points." }, + { label="Dash styles", kind="dashstyle", default="[4] 0", + help="Dash styles use Ipe syntax, for example [4 2] 0." }, + { label="Text sizes", kind="textsize", default="\\large", + help="Text sizes are numbers or LaTeX size commands such as \\large." }, + { label="Symbol sizes", kind="symbolsize", default="3", + help="Symbol sizes are numbers in Ipe points." }, + { label="Arrow sizes", kind="arrowsize", default="7", + help="Arrow sizes are numbers in Ipe points." }, + { label="Opacity", kind="opacity", default="1", + help="Opacity values are numbers between 0 and 1." }, + { label="Grid sizes", kind="gridsize", default="8", + help="Grid sizes are numbers in Ipe points." }, + { label="Angle sizes", kind="anglesize", default="45", + help="Angle sizes are numbers in degrees." }, +} + +local function visual_category_labels() + local r = {} + for i,c in ipairs(visual_style_categories) do r[i] = c.label end + return r +end + +local function visual_entry_names(entries) + local r = {} + for i,e in ipairs(entries) do r[i] = e.name end + return r +end + +local function visual_unique_name(entries, base) + local used = {} + for _,e in ipairs(entries) do used[e.name] = true end + if not used[base] then return base end + local n = 2 + while used[base .. " " .. n] do n = n + 1 end + return base .. " " .. n +end + +local function visual_rgb_to_hex(value) + local rgb = {} + for s in value:gmatch("[^%s]+") do rgb[#rgb + 1] = tonumber(s) end + if #rgb == 1 then rgb[2], rgb[3] = rgb[1], rgb[1] end + if #rgb ~= 3 or not rgb[1] or not rgb[2] or not rgb[3] then return value end + for i = 1,3 do + rgb[i] = math.max(0, math.min(255, math.floor(255 * rgb[i] + 0.5))) + end + return string.format("#%02x%02x%02x", rgb[1], rgb[2], rgb[3]) +end + +local function visual_hex_to_rgb(value) + value = value:match("^%s*(.-)%s*$") + local function hex_digit(c) + local b = string.byte(c) + if string.byte("0") <= b and b <= string.byte("9") then + return b - string.byte("0") + elseif string.byte("a") <= b and b <= string.byte("f") then + return b - string.byte("a") + 10 + elseif string.byte("A") <= b and b <= string.byte("F") then + return b - string.byte("A") + 10 + end + end + local function hex_byte(s) + return 16 * hex_digit(s:sub(1, 1)) + hex_digit(s:sub(2, 2)) + end + local hex = value:match("^#(%x%x%x%x%x%x)$") + if hex then + local r = hex_byte(hex:sub(1, 2)) + local g = hex_byte(hex:sub(3, 4)) + local b = hex_byte(hex:sub(5, 6)) + return string.format("%.6g %.6g %.6g", r / 255, g / 255, b / 255) + end + local r1, g1, b1 = value:match("^#(%x)(%x)(%x)$") + if r1 then + local r = hex_byte(r1 .. r1) + local g = hex_byte(g1 .. g1) + local b = hex_byte(b1 .. b1) + return string.format("%.6g %.6g %.6g", r / 255, g / 255, b / 255) + end + return value +end + +local visual_preview_width = 300 +local visual_preview_height = 130 +local visual_preview_scale = 4 +local visual_preview_serial = 0 + +local function visual_number(value, fallback) + return tonumber(value) or fallback +end + +local function visual_segment_shape(v1, v2) + return { type="curve", closed=false; { type="segment"; v1, v2 } } +end + +local function visual_box_shape(v1, v2) + return { type="curve", closed=true; + { type="segment"; v1, ipe.Vector(v1.x, v2.y) }, + { type="segment"; ipe.Vector(v1.x, v2.y), v2 }, + { type="segment"; v2, ipe.Vector(v2.x, v1.y) } } +end + +local function visual_arc_shape(center, radius, alpha, beta) + local arc = ipe.Arc(ipe.Matrix(radius, 0, 0, radius, center.x, center.y), + alpha, beta) + return { type="curve", closed=false; + { type="arc", arc=arc; + center + radius * ipe.Direction(alpha), + center + radius * ipe.Direction(beta) } } +end + +local function visual_add_object(page, obj) + page:insert(nil, obj, 1, "alpha") +end + +local function visual_add_preview_objects(page, c, preview_name, value) + local V = ipe.Vector + if c.kind == "color" then + visual_add_object(page, ipe.Path({ stroke="black", fill=preview_name, + pathmode="strokedfilled" }, + { visual_box_shape(V(0, 0), V(130, 65)) })) + elseif c.kind == "pen" then + visual_add_object(page, ipe.Path({ stroke="black", pen=preview_name }, + { visual_segment_shape(V(0, 0), V(180, 0)) })) + elseif c.kind == "dashstyle" then + visual_add_object(page, ipe.Path({ stroke="black", pen="fat", dashstyle=preview_name }, + { visual_segment_shape(V(0, 0), V(180, 0)) })) + elseif c.kind == "textsize" then + visual_add_object(page, ipe.Text({ stroke="black", textsize=preview_name }, + "Sample", V(0, 0))) + elseif c.kind == "symbolsize" then + visual_add_object(page, ipe.Reference({ stroke="black", fill="red", + symbolsize=preview_name }, + "mark/disk(sx)", V(0, 0))) + elseif c.kind == "arrowsize" then + visual_add_object(page, ipe.Path({ stroke="black", pen="fat", farrow=true, + farrowsize=preview_name, + farrowshape="arrow/normal(spx)" }, + { visual_segment_shape(V(0, 0), V(170, 0)) }, true)) + elseif c.kind == "opacity" then + visual_add_object(page, ipe.Path({ stroke="black", fill="blue", + pathmode="strokedfilled" }, + { visual_box_shape(V(0, 0), V(80, 60)) })) + visual_add_object(page, ipe.Path({ stroke="black", fill="red", opacity=preview_name, + pathmode="strokedfilled" }, + { visual_box_shape(V(45, 10), V(125, 70)) })) + elseif c.kind == "gridsize" then + local step = math.max(1, visual_number(value, 8)) + for x = 0,160,step do + visual_add_object(page, ipe.Path({ stroke="0.7", pen="normal" }, + { visual_segment_shape(V(x, 0), V(x, 80)) })) + end + for y = 0,80,step do + visual_add_object(page, ipe.Path({ stroke="0.7", pen="normal" }, + { visual_segment_shape(V(0, y), V(160, y)) })) + end + visual_add_object(page, ipe.Path({ stroke="black", pen="fat" }, + { visual_segment_shape(V(0, 0), V(160, 80)) })) + elseif c.kind == "anglesize" then + local alpha = math.rad(visual_number(value, 45)) + local origin = V(0, 0) + local radius = 42 + visual_add_object(page, ipe.Path({ stroke="black", pen="normal" }, + { visual_segment_shape(origin, V(150, 0)) })) + visual_add_object(page, ipe.Path({ stroke="red", pen="fat" }, + { visual_segment_shape(origin, + radius * 3 * ipe.Direction(alpha)) })) + visual_add_object(page, ipe.Path({ stroke="black", pen="normal" }, + { visual_arc_shape(origin, radius, 0, alpha) })) + end +end + +local function visual_preview_spec(dd, c, value) + local preview_name = "__preview_textsize" + if c.kind ~= "textsize" then preview_name = "__preview_" .. c.kind end + local doc = ipe.Document() + local sheets = ipe.Sheets() + local sheet = ipe.Sheet() + sheet:setName("__preview") + local ok = pcall(function () sheet:setAttribute(c.kind, preview_name, value) end) + if not ok then return nil end + sheets:insert(1, sheet) + for i,s in ipairs(dd.list) do sheets:insert(i + 1, s:clone()) end + doc:replaceSheets(sheets) + local p = doc[1] + visual_add_preview_objects(p, c, preview_name, value) + if c.kind == "textsize" then + ok = doc:runLatex(dd.model.file_name) + if not ok then return nil end + end + visual_preview_serial = visual_preview_serial + 1 + local png = ipe.folder("latex", string.format("style-preview-%d.png", + visual_preview_serial)) + dd.model.ui:renderPage(doc, 1, 1, "png", png, + dd.model.ui:zoom() * visual_preview_scale, true, false) + return string.format("imagefile|%s|%g", png, visual_preview_scale) +end + +local function visual_set_preview(d, dd, c, value) + d:set("preview", visual_preview_spec(dd, c, value) or "unavailable|Preview unavailable") +end + +local function visual_load_sheet(sheet) + local data = {} + for ci,c in ipairs(visual_style_categories) do + data[ci] = {} + for _,name in ipairs(sheet:allNames(c.kind)) do + data[ci][#data[ci] + 1] = { name=name, value=sheet:find(c.kind, name) } + end + end + return data +end + +local function visual_set_fields(d, dd, st) + local c = visual_style_categories[st.cat] + local entries = st.data[st.cat] + local names = visual_entry_names(entries) + st.updating = true + d:set("items", names) + if #entries == 0 then + st.current = nil + d:set("name", "") + d:set("value", c.color and (c.default or "#000000") or "") + visual_set_preview(d, dd, c, c.color and visual_hex_to_rgb(c.default or "#000000") or c.default) + else + st.current = math.max(1, math.min(st.current or 1, #entries)) + d:set("items", st.current) + d:set("name", entries[st.current].name) + if c.color then + d:set("value", visual_rgb_to_hex(entries[st.current].value)) + else + d:set("value", entries[st.current].value) + end + visual_set_preview(d, dd, c, entries[st.current].value) + end + d:set("value_label", c.color and "Color" or "Value") + d:set("help", c.help) + d:setEnabled("value", true) + st.updating = false +end + +local function visual_apply_current(d, dd, st) + local c = visual_style_categories[st.cat] + local entries = st.data[st.cat] + local name = d:get("name") + if name == "" and not st.current and #entries == 0 then return true end + if name == "" then + dd.model:warning("Cannot update stylesheet", "The symbolic name cannot be empty") + return false + end + local value = c.color and visual_hex_to_rgb(d:get("value")) or d:get("value") + if value == "" then + dd.model:warning("Cannot update stylesheet", "The value cannot be empty") + return false + end + local current = st.current or (#entries + 1) + for i = #entries,1,-1 do + if entries[i].name == name and i ~= current then + table.remove(entries, i) + if i < current then current = current - 1 end + end + end + entries[current] = { name=name, value=value } + st.current = current + st.updating = true + d:set("items", visual_entry_names(entries)) + d:set("items", st.current) + if c.color then d:set("value", visual_rgb_to_hex(value)) end + visual_set_preview(d, dd, c, value) + st.updating = false + return true +end + +local function visual_apply_to_sheet(d, dd, st, sheet) + if not visual_apply_current(d, dd, st) then return nil end + local nsheet = sheet:clone() + for ci,c in ipairs(visual_style_categories) do + for _,name in ipairs(nsheet:allNames(c.kind)) do + nsheet:remove(c.kind, name) + end + for _,entry in ipairs(st.data[ci]) do + local ok, msg = pcall(function () + nsheet:setAttribute(c.kind, entry.name, entry.value) + end) + if not ok then + dd.model:warning("Cannot update stylesheet", + string.format("%s '%s': %s", c.label, entry.name, msg)) + return nil + end + end + end + local parsed, msg = ipe.Sheet(nil, nsheet:xml(true)) + if not parsed then + dd.model:warning("Cannot update stylesheet", msg) + return nil + end + return nsheet +end + +local function sheets_visual_edit(d0, dd) + local i = d0:get("list") + if not i or dd.list[i]:isStandard() then return end + + local cats = visual_category_labels() + local st = { cat=1, current=1, data=visual_load_sheet(dd.list[i]) } + cats.action = function (d) + if st.updating then return end + if not visual_apply_current(d, dd, st) then return end + st.cat = d:get("category") + st.current = 1 + visual_set_fields(d, dd, st) + end + local first_names = visual_entry_names(st.data[1]) + first_names.action = function (d) + if st.updating then return end + st.current = d:get("items") + visual_set_fields(d, dd, st) + end + + local d = ipeui.Dialog(dd.model.ui:win(), "Visual stylesheet editor") + d:add("category_label", "label", { label="Category" }, 1, 1) + d:add("category", "combo", cats, 1, 2, 1, 3) + d:add("items", "list", first_names, 2, 1, 7, 2) + d:add("name_label", "label", { label="Name" }, 2, 3) + d:add("name", "input", { select_all=true }, 2, 4) + d:add("value_label", "label", { label="Value" }, 3, 3) + d:add("value", "input", {}, 3, 4) + d:add("help", "label", { label="" }, 5, 3, 1, 2) + d:add("preview_label", "label", { label="Preview" }, 6, 3) + d:add("preview", "image", { width=visual_preview_width, + height=visual_preview_height }, 7, 3, 2, 2) + d:add("apply", "button", { label="Apply / Preview", + action=function (d) visual_apply_current(d, dd, st) end }, 9, 3) + d:add("add", "button", { label="Add", + action=function (d) + local c = visual_style_categories[st.cat] + local entries = st.data[st.cat] + local name = d:get("name") + if st.current and entries[st.current] and name == entries[st.current].name then + name = "new" + end + if name == "" then name = "new" end + local value = c.color and visual_hex_to_rgb(d:get("value")) or d:get("value") + if value == "" then + value = c.color and visual_hex_to_rgb(c.default) or c.default + end + entries[#entries + 1] = { + name=visual_unique_name(entries, name), + value=value, + } + st.current = #entries + visual_set_fields(d, dd, st) + end }, 9, 4) + d:add("delete", "button", { label="Delete", + action=function (d) + local entries = st.data[st.cat] + if st.current and entries[st.current] then + table.remove(entries, st.current) + st.current = math.min(st.current, #entries) + visual_set_fields(d, dd, st) + end + end }, 10, 3) + d:addButton("ok", "&Ok", "accept") + d:addButton("cancel", "&Cancel", "reject") + d:setStretch("row", 2, 1) + d:setStretch("column", 2, 1) + d:setStretch("column", 4, 2) + visual_set_fields(d, dd, st) + + if not d:execute({ 680, 520 }) then return end + local nsheet = visual_apply_to_sheet(d, dd, st, dd.list[i]) + if not nsheet then return end + dd.list[i] = nsheet + d0:set("list", sheets_namelist(dd.list)) + d0:set("list", i) + dd.modified = true +end + local function sheets_edit(d, dd) if not prefs.external_editor then dd.model:warning("Cannot edit stylesheet", @@ -2776,14 +3194,18 @@ function MODEL:action_style_sheets() { label="&Up", action=function (d) sheets_up(d, dd) end }, 3, 4) d:add("down", "button", { label="&Down", action=function (d) sheets_down(d, dd) end }, 4, 4) + d:add("new", "button", + { label="&New", action=function (d) sheets_new(d, dd) end }, 5, 4) if config.toolkit ~= "htmljs" then d:add("add", "button", - { label="&Add", action=function (d) sheets_add(d, dd) end }, 5, 4) + { label="&Add", action=function (d) sheets_add(d, dd) end }, 6, 4) d:add("edit", "button", - { label="Edit", action=function (d) sheets_edit(d, dd) end }, 6, 4) + { label="Edit", action=function (d) sheets_edit(d, dd) end }, 7, 4) d:add("save", "button", - { label="&Save", action=function (d) sheets_save(d, dd) end }, 7, 4) + { label="&Save", action=function (d) sheets_save(d, dd) end }, 8, 4) end + d:add("visual", "button", + { label="Visual Edit", action=function (d) sheets_visual_edit(d, dd) end }, 9, 4) d:addButton("ok", "&Ok", "accept") d:addButton("cancel", "&Cancel", "reject") d:setStretch("column", 2, 1) diff --git a/src/ipe/lua/main.lua b/src/ipe/lua/main.lua index 2b3c673..2c3c02b 100644 --- a/src/ipe/lua/main.lua +++ b/src/ipe/lua/main.lua @@ -373,7 +373,8 @@ local home = os.getenv("HOME") local ipeletpath = os.getenv("IPELETPATH") if ipeletpath then config.ipeletDirs = {} - for w in string.gmatch(ipeletpath, prefs.fname_pattern) do + for dir in string.gmatch(ipeletpath, prefs.fname_pattern) do + local w = dir if w == "_" then w = ipe.folder("ipelets") end if w:sub(1,4) == "ipe:" then w = config.ipedrive .. w:sub(5) @@ -389,7 +390,8 @@ end local ipestyles = os.getenv("IPESTYLES") if ipestyles then config.styleDirs = {} - for w in string.gmatch(ipestyles, prefs.fname_pattern) do + for dir in string.gmatch(ipestyles, prefs.fname_pattern) do + local w = dir if w == "_" then w = ipe.folder("styles") end if w:sub(1,4) == "ipe:" then w = config.ipedrive .. w:sub(5) @@ -512,7 +514,8 @@ if config.toolkit == "cocoa" then first_file = nil end if #style_sheets > 0 then prefs.styles = style_sheets end config.styleList = {} -for _,w in ipairs(prefs.styles) do +for _,style in ipairs(prefs.styles) do + local w = style if w:sub(-4) ~= ".isy" then w = w .. ".isy" end if not w:find(prefs.fsep) then w = findStyle(w) end config.styleList[#config.styleList + 1] = w diff --git a/src/ipelua/ipeluastyle.cpp b/src/ipelua/ipeluastyle.cpp index c3129e4..0d293c0 100644 --- a/src/ipelua/ipeluastyle.cpp +++ b/src/ipelua/ipeluastyle.cpp @@ -219,6 +219,77 @@ static int sheet_remove(lua_State * L) { return 0; } +static Attribute check_absolute_string_attribute(Kind kind, lua_State * L, int i) { + size_t len; + const char * data = luaL_checklstring(L, i, &len); + String str(data, len); + Attribute value; + switch (kind) { + case EPen: + case ESymbolSize: + case EArrowSize: + case ETextStretch: + case EGridSize: + case EAngleSize: + case EOpacity: + value = Attribute::makeScalar(str, Attribute::NORMAL()); + luaL_argcheck(L, !value.isSymbolic(), i, "value is not absolute"); + break; + case EColor: + value = Attribute::makeColor(str, Attribute::NORMAL()); + luaL_argcheck(L, value.isColor(), i, "value is not an absolute color"); + break; + case EDashStyle: + value = Attribute::makeDashStyle(str); + luaL_argcheck(L, !value.isSymbolic(), i, "dashstyle is not absolute"); + break; + case ETextSize: + value = Attribute::makeTextSize(str); + luaL_argcheck(L, !value.isSymbolic(), i, "textsize is not absolute"); + break; + default: luaL_argerror(L, 2, "cannot set string value of this kind"); break; + } + return value; +} + +static int sheet_allNames(lua_State * L) { + StyleSheet * s = check_sheet(L, 1)->sheet; + Kind kind = Kind(luaL_checkoption(L, 2, nullptr, kind_names)); + AttributeSeq seq; + s->allNames(kind, seq); + lua_createtable(L, seq.size(), 0); + for (int i = 0; i < size(seq); ++i) { + push_string(L, seq[i].string()); + lua_rawseti(L, -2, i + 1); + } + return 1; +} + +static int sheet_find(lua_State * L) { + StyleSheet * s = check_sheet(L, 1)->sheet; + Kind kind = Kind(luaL_checkoption(L, 2, nullptr, kind_names)); + if (kind == ESymbol || kind == EGradient || kind == ETiling || kind == EEffect) + luaL_argerror(L, 2, "this kind has no simple attribute value"); + const char * name = luaL_checklstring(L, 3, nullptr); + Attribute sym(true, name); + if (!s->has(kind, sym)) { + lua_pushnil(L); + return 1; + } + push_string(L, s->find(kind, sym).string()); + return 1; +} + +static int sheet_setAttribute(lua_State * L) { + StyleSheet * s = check_sheet(L, 1)->sheet; + Kind kind = Kind(luaL_checkoption(L, 2, nullptr, kind_names)); + const char * name = luaL_checklstring(L, 3, nullptr); + Attribute sym(true, name); + Attribute value = check_absolute_string_attribute(kind, L, 4); + s->add(kind, sym, value); + return 0; +} + static int sheet_isStandard(lua_State * L) { SSheet * p = check_sheet(L, 1); lua_pushboolean(L, p->sheet->isStandard()); @@ -282,6 +353,9 @@ static const struct luaL_Reg sheet_methods[] = {{"__gc", sheet_destructor}, {"add", sheet_add}, {"addFrom", sheet_addfrom}, {"remove", sheet_remove}, + {"allNames", sheet_allNames}, + {"find", sheet_find}, + {"setAttribute", sheet_setAttribute}, {"set", sheet_set}, {"isStandard", sheet_isStandard}, {"name", sheet_name}, diff --git a/src/ipeui/ipeui_cocoa.cpp b/src/ipeui/ipeui_cocoa.cpp index ca2ec6c..c1f4278 100644 --- a/src/ipeui/ipeui_cocoa.cpp +++ b/src/ipeui/ipeui_cocoa.cpp @@ -35,6 +35,10 @@ #include "ipeuilayout_cocoa.h" +#include +#include +#include + #define COLORICONSIZE 12 inline const char * N2C(NSString * aStr) { return aStr.UTF8String; } @@ -218,6 +222,94 @@ class PDialog : public Dialog { // -------------------------------------------------------------------- +static double previewNumber(const std::string & value, double fallback) { + std::istringstream stream(value); + double v; + if (stream >> v) return v; + return fallback; +} + +@interface IpeDialogImage : NSView + +- (instancetype)initWithWidth:(int)width height:(int)height spec:(const std::string &)spec; +- (void)setSpec:(const std::string &)spec; + +@end + +@implementation IpeDialogImage { + std::string iSpec; + int iWidth; + int iHeight; +} + +- (instancetype)initWithWidth:(int)width height:(int)height spec:(const std::string &)spec { + self = [super initWithFrame:NSMakeRect(0., 0., width, height)]; + if (self) { + iWidth = width; + iHeight = height; + iSpec = spec; + } + return self; +} + +- (BOOL)isFlipped { return YES; } + +- (NSSize)intrinsicContentSize { return NSMakeSize(iWidth, iHeight); } + +- (void)setSpec:(const std::string &)spec { + iSpec = spec; + [self setNeedsDisplay:YES]; +} + +- (void)drawRect:(NSRect)dirtyRect { + (void)dirtyRect; + NSRect bounds = [self bounds]; + [[NSColor colorWithCalibratedRed:1.0 green:1.0 blue:0.86 alpha:1.0] setFill]; + NSRectFill(bounds); + [[NSColor colorWithCalibratedRed:0.62 green:0.62 blue:0.50 alpha:1.0] setStroke]; + NSFrameRect(bounds); + + size_t sep = iSpec.find('|'); + size_t sep2 = sep == std::string::npos ? std::string::npos : iSpec.find('|', sep + 1); + std::string kind = sep == std::string::npos ? iSpec : iSpec.substr(0, sep); + std::string value = sep == std::string::npos + ? std::string() + : iSpec.substr(sep + 1, sep2 - sep - 1); + double zoom = sep2 == std::string::npos + ? 1.0 + : std::clamp(previewNumber(iSpec.substr(sep2 + 1), 1.0), 0.1, 100.0); + NSRect body = NSInsetRect(bounds, 18., 16.); + + if (kind == "imagefile") { + NSImage * image = [[NSImage alloc] initWithContentsOfFile:S2N(value)]; + if (image) { + NSSize size = image.size; + size.width /= zoom; + size.height /= zoom; + double scale = std::min(body.size.width / size.width, + body.size.height / size.height); + scale = std::min(1.0, scale); + NSSize scaled = NSMakeSize(size.width * scale, size.height * scale); + NSRect target = NSMakeRect(NSMidX(body) - scaled.width / 2., + NSMidY(body) - scaled.height / 2., + scaled.width, scaled.height); + [[NSGraphicsContext currentContext] setImageInterpolation:NSImageInterpolationHigh]; + [image drawInRect:target]; + return; + } + } + NSDictionary * attrs = @{ + NSFontAttributeName : [NSFont systemFontOfSize:12.], + NSForegroundColorAttributeName : [NSColor secondaryLabelColor] + }; + NSString * message = (kind == "imagefile" || value.empty()) ? @"Preview unavailable" : S2N(value); + [message drawInRect:body withAttributes:attrs]; +} + +@end + +// -------------------------------------------------------------------- + PDialog::PDialog(lua_State * L0, WINID parent, const char * caption, const char * language) : Dialog(L0, parent, caption, language) { @@ -257,6 +349,7 @@ void PDialog::setMapped(lua_State * L, int idx) { case ELabel: case EInput: [((NSTextField *)ctrl) setStringValue:S2N(m.text)]; break; case ETextEdit: setTextView((NSTextView *)ctrl, m.text); break; + case EImage: [(IpeDialogImage *)ctrl setSpec:m.text]; break; case ECheckBox: [((NSButton *)ctrl) setState:m.value]; break; case EList: // listbox gets items directly from items array @@ -289,7 +382,8 @@ void PDialog::retrieveValues() { } void PDialog::enableItem(int idx, bool value) { - if (iElements[idx].type != ETextEdit) [((NSControl *)iViews[idx]) setEnabled:value]; + if (iElements[idx].type != ETextEdit && iElements[idx].type != EImage) + [((NSControl *)iViews[idx]) setEnabled:value]; } void PDialog::fillComboBox(NSPopUpButton * cb, int idx) { @@ -386,6 +480,10 @@ void PDialog::layoutControls() { layout(w, cols[m.col], "l=l"); layout(w, cols[m.col + m.colspan - 1], "r=r"); if (m.type == EInput || m.type == ETextEdit) layout(w, nil, "w>0", 100); + if (m.type == EImage) { + layout(w, nil, "w>0", m.minWidth); + layout(w, nil, "h>0", m.minHeight); + } // does it have stretch? BOOL rowStretch = NO; for (int r = m.row; r < m.row + m.rowspan; ++r) @@ -480,6 +578,11 @@ Dialog::Result PDialog::buildAndRun(int w, int h) { if (m.flags & ESelectAll) [t selectText:content]; ctrl = t; } break; + case EImage: { + view = [[IpeDialogImage alloc] initWithWidth:m.minWidth + height:m.minHeight + spec:m.text]; + } break; case ETextEdit: { scroll = [[NSScrollView alloc] initWithFrame:NSZeroRect]; NSTextView * tv = [[NSTextView alloc] initWithFrame:NSZeroRect]; diff --git a/src/ipeui/ipeui_common.cpp b/src/ipeui/ipeui_common.cpp index c9aa10c..d7ecfce 100644 --- a/src/ipeui/ipeui_common.cpp +++ b/src/ipeui/ipeui_common.cpp @@ -117,8 +117,9 @@ int Dialog::addButton(lua_State * L) { } int Dialog::add(lua_State * L) { - static const char * const typenames[] = {"button", "text", "list", "label", - "combo", "checkbox", "input", nullptr}; + static const char * const typenames[] = {"button", "text", "list", + "label", "combo", "checkbox", + "input", "image", nullptr}; SElement m; m.name = checkstring(L, 2); @@ -144,6 +145,7 @@ int Dialog::add(lua_State * L) { case ECombo: addCombo(L, m); break; case ECheckBox: addCheckbox(L, m); break; case EInput: addInput(L, m); break; + case EImage: addImage(L, m); break; default: break; } iElements.push_back(m); @@ -223,6 +225,18 @@ void Dialog::addInput(lua_State * L, SElement & m) { lua_pop(L, 3); } +void Dialog::addImage(lua_State * L, SElement & m) { + m.minWidth = 180; + m.minHeight = 80; + lua_getfield(L, 4, "value"); + if (lua_isstring(L, -1)) m.text = tostring(L, -1); + lua_getfield(L, 4, "width"); + if (lua_isnumber(L, -1)) m.minWidth = luaL_checkinteger(L, -1); + lua_getfield(L, 4, "height"); + if (lua_isnumber(L, -1)) m.minHeight = luaL_checkinteger(L, -1); + lua_pop(L, 3); // height, width, value +} + void Dialog::addTextEdit(lua_State * L, SElement & m) { lua_getfield(L, 4, "read_only"); if (lua_toboolean(L, -1)) m.flags |= EReadOnly; @@ -313,7 +327,8 @@ void Dialog::setUnmapped(lua_State * L, int idx) { switch (m.type) { case ELabel: case ETextEdit: - case EInput: m.text = checkstring(L, 3); break; + case EInput: + case EImage: m.text = checkstring(L, 3); break; case EList: case ECombo: if (lua_isnumber(L, 3)) { diff --git a/src/ipeui/ipeui_common.h b/src/ipeui/ipeui_common.h index 76b78f6..e5624e0 100644 --- a/src/ipeui/ipeui_common.h +++ b/src/ipeui/ipeui_common.h @@ -127,7 +127,16 @@ class Dialog { ESpellCheck = 0x200, EColorPicker = 0x400, }; - enum TType { EButton = 0, ETextEdit, EList, ELabel, ECombo, ECheckBox, EInput }; + enum TType { + EButton = 0, + ETextEdit, + EList, + ELabel, + ECombo, + ECheckBox, + EInput, + EImage + }; struct SElement { std::string name; @@ -158,6 +167,7 @@ class Dialog { void addCombo(lua_State * L, SElement & m); void addCheckbox(lua_State * L, SElement & m); void addInput(lua_State * L, SElement & m); + void addImage(lua_State * L, SElement & m); void setListItems(lua_State * L, int index, SElement & m); diff --git a/src/ipeui/ipeui_gtk.cpp b/src/ipeui/ipeui_gtk.cpp index 1c6d5bd..6d9a48d 100644 --- a/src/ipeui/ipeui_gtk.cpp +++ b/src/ipeui/ipeui_gtk.cpp @@ -29,6 +29,10 @@ */ #include "ipeui_common.h" + +#include +#include + using String = std::string; // -------------------------------------------------------------------- @@ -64,6 +68,81 @@ PDialog::~PDialog() { // } +static double previewNumber(const std::string & value, double fallback) { + std::istringstream stream(value); + double v; + if (stream >> v) return v; + return fallback; +} + +static void drawImagePreview(cairo_t * cr, int width, int height, const std::string & spec) { + size_t sep = spec.find('|'); + size_t sep2 = sep == std::string::npos ? std::string::npos : spec.find('|', sep + 1); + std::string kind = sep == std::string::npos ? spec : spec.substr(0, sep); + std::string value = sep == std::string::npos + ? std::string() + : spec.substr(sep + 1, sep2 - sep - 1); + double zoom = sep2 == std::string::npos + ? 1.0 + : std::clamp(previewNumber(spec.substr(sep2 + 1), 1.0), 0.1, 100.0); + + cairo_set_source_rgb(cr, 1.0, 1.0, 0.86); + cairo_rectangle(cr, 0.5, 0.5, width - 1.0, height - 1.0); + cairo_fill_preserve(cr); + cairo_set_source_rgb(cr, 0.62, 0.62, 0.50); + cairo_set_line_width(cr, 1.0); + cairo_stroke(cr); + + double left = 18.0, top = 16.0, right = width - 18.0, bottom = height - 16.0; + double cx = 0.5 * (left + right); + double cy = 0.5 * (top + bottom); + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND); + + if (kind == "imagefile") { + cairo_surface_t * image = cairo_image_surface_create_from_png(value.c_str()); + if (cairo_surface_status(image) == CAIRO_STATUS_SUCCESS) { + double iw = cairo_image_surface_get_width(image); + double ih = cairo_image_surface_get_height(image); + double scale = std::min((right - left) / (iw / zoom), + (bottom - top) / (ih / zoom)); + scale = std::min(1.0, scale) / zoom; + double x = cx - 0.5 * iw * scale; + double y = cy - 0.5 * ih * scale; + cairo_save(cr); + cairo_translate(cr, x, y); + cairo_scale(cr, scale, scale); + cairo_set_source_surface(cr, image, 0.0, 0.0); + cairo_paint(cr); + cairo_restore(cr); + cairo_surface_destroy(image); + return; + } + cairo_surface_destroy(image); + } + const char * message = (kind == "imagefile" || value.empty()) + ? "Preview unavailable" + : value.c_str(); + cairo_select_font_face(cr, "sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); + cairo_set_font_size(cr, 12.0); + cairo_set_source_rgb(cr, 0.35, 0.35, 0.35); + cairo_text_extents_t ext; + cairo_text_extents(cr, message, &ext); + cairo_move_to(cr, cx - ext.width / 2.0 - ext.x_bearing, + cy - ext.height / 2.0 - ext.y_bearing); + cairo_show_text(cr, message); +} + +static gboolean imageExpose(GtkWidget * widget, GdkEventExpose *, gpointer) { + GtkAllocation allocation; + gtk_widget_get_allocation(widget, &allocation); + const char * spec = (const char *)g_object_get_data(G_OBJECT(widget), "ipe-dialog-image-spec"); + cairo_t * cr = gdk_cairo_create(gtk_widget_get_window(widget)); + drawImagePreview(cr, allocation.width, allocation.height, spec ? spec : ""); + cairo_destroy(cr); + return FALSE; +} + void PDialog::acceptDialog(lua_State * L) { int accept = lua_toboolean(L, 2); (void)accept; // TODO @@ -90,6 +169,11 @@ void PDialog::setMapped(lua_State * L, int idx) { m.text.c_str(), -1); break; case EInput: gtk_entry_set_text(GTK_ENTRY(w), m.text.c_str()); break; + case EImage: + g_object_set_data_full(G_OBJECT(w), "ipe-dialog-image-spec", + g_strdup(m.text.c_str()), g_free); + gtk_widget_queue_draw(w); + break; case EList: if (lua_istable(L, 3)) { GtkTreeModel * mod = gtk_tree_view_get_model(GTK_TREE_VIEW(w)); @@ -272,6 +356,15 @@ Dialog::Result PDialog::buildAndRun(int w, int h) { gtk_entry_set_activates_default(GTK_ENTRY(w), TRUE); xOptions |= GTK_FILL; break; + case EImage: + w = gtk_drawing_area_new(); + gtk_widget_set_size_request(w, m.minWidth, m.minHeight); + g_object_set_data_full(G_OBJECT(w), "ipe-dialog-image-spec", + g_strdup(m.text.c_str()), g_free); + g_signal_connect(w, "expose-event", G_CALLBACK(imageExpose), nullptr); + xOptions |= GTK_FILL; + yOptions |= GTK_FILL; + break; case ETextEdit: w = gtk_text_view_new(); gtk_text_view_set_editable(GTK_TEXT_VIEW(w), !(m.flags & EReadOnly)); diff --git a/src/ipeui/ipeui_js.cpp b/src/ipeui/ipeui_js.cpp index 67a7436..52efccc 100644 --- a/src/ipeui/ipeui_js.cpp +++ b/src/ipeui/ipeui_js.cpp @@ -64,6 +64,7 @@ class PDialog : public Dialog { static const char * typenames[] = { "button", "textedit", "list", "label", "combo", "checkbox", "input", + "image", }; val PDialog::element(const SElement & m) { @@ -84,6 +85,8 @@ val PDialog::element(const SElement & m) { w.set("col", m.col); w.set("rowspan", m.rowspan); w.set("colspan", m.colspan); + w.set("width", m.minWidth); + w.set("height", m.minHeight); return w; } diff --git a/src/ipeui/ipeui_qt.cpp b/src/ipeui/ipeui_qt.cpp index bfb9332..106107d 100644 --- a/src/ipeui/ipeui_qt.cpp +++ b/src/ipeui/ipeui_qt.cpp @@ -45,6 +45,8 @@ #include #include #include +#include +#include #include #include #include @@ -55,6 +57,8 @@ #include #include #include +#include +#include #ifdef IPE_SPELLCHECK #pragma GCC diagnostic push @@ -135,6 +139,72 @@ class LatexHighlighter : public QSyntaxHighlighter { virtual void highlightBlock(const QString & text); }; +// -------------------------------------------------------------------- + +static double previewNumber(const QString & value, double fallback) { + std::string s = value.toStdString(); + std::istringstream stream(s); + double v; + if (stream >> v) return v; + return fallback; +} + +class DialogImage : public QWidget { +public: + DialogImage(int width, int height, QWidget * parent = nullptr) + : QWidget(parent) { + setMinimumSize(width, height); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + void setSpec(const std::string & spec) { + iSpec = QString::fromUtf8(spec.c_str()); + update(); + } + +protected: + void paintEvent(QPaintEvent *) override; + +private: + QString iSpec; +}; + +void DialogImage::paintEvent(QPaintEvent *) { + QString kind = iSpec.section(QLatin1Char('|'), 0, 0); + QString value = iSpec.section(QLatin1Char('|'), 1, 1); + double zoom = std::clamp(previewNumber(iSpec.section(QLatin1Char('|'), 2, 2), 1.0), + 0.1, 100.0); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + QRectF r = rect().adjusted(0.5, 0.5, -0.5, -0.5); + painter.fillRect(r, QColor(255, 255, 220)); + painter.setPen(QPen(QColor(160, 160, 130), 1)); + painter.drawRect(r); + + QRectF body = r.adjusted(18, 16, -18, -16); + if (kind == QLatin1String("imagefile")) { + QImage image(value); + if (!image.isNull()) { + QSizeF scaled(image.width() / zoom, image.height() / zoom); + double fit = std::min(body.width() / scaled.width(), + body.height() / scaled.height()); + if (fit < 1.0) scaled *= fit; + QRectF target(QPointF(body.center().x() - scaled.width() / 2.0, + body.center().y() - scaled.height() / 2.0), + scaled); + painter.drawImage(target, image); + return; + } + } + painter.setPen(QColor(60, 60, 60)); + painter.drawText(body, Qt::AlignCenter, + (kind == QLatin1String("imagefile") || value.isEmpty()) + ? QStringLiteral("Preview unavailable") + : value); +} + void LatexHighlighter::applyFormat(const QString & text, QRegularExpression & exp, const QTextCharFormat & format) { QRegularExpressionMatch match; @@ -282,6 +352,9 @@ void PDialog::setMapped(lua_State * L, int idx) { case EInput: (qobject_cast(w))->setText(QString::fromUtf8(m.text.c_str())); break; + case EImage: + (qobject_cast(w))->setSpec(m.text); + break; case EList: { QListWidget * l = qobject_cast(w); if (!lua_isnumber(L, 3)) { @@ -376,6 +449,11 @@ Dialog::Result PDialog::buildAndRun(int w, int h) { if (m.flags & ESelectAll) t->selectAll(); w = t; } break; + case EImage: { + DialogImage * image = new DialogImage(m.minWidth, m.minHeight, qDialog); + image->setSpec(m.text); + w = image; + } break; case ECombo: { QComboBox * b = new QComboBox(qDialog); for (int k = 0; k < int(m.items.size()); ++k) diff --git a/src/ipeui/ipeui_win.cpp b/src/ipeui/ipeui_win.cpp index f4f235d..ef1daac 100644 --- a/src/ipeui/ipeui_win.cpp +++ b/src/ipeui/ipeui_win.cpp @@ -76,6 +76,32 @@ static std::string wideToUtf8(const wchar_t * wbuf) { return std::string(multi.data()); } +static void fillRect(HDC dc, const RECT & r, COLORREF color) { + HBRUSH b = CreateSolidBrush(color); + FillRect(dc, &r, b); + DeleteObject(b); +} + +static void drawImagePreview(HDC dc, RECT rc, const std::string & spec) { + size_t sep = spec.find('|'); + std::string kind = sep == std::string::npos ? spec : spec.substr(0, sep); + std::string value = sep == std::string::npos + ? std::string() + : spec.substr(sep + 1, spec.find('|', sep + 1) - sep - 1); + + fillRect(dc, rc, RGB(255, 255, 220)); + HBRUSH frame = CreateSolidBrush(RGB(160, 160, 130)); + FrameRect(dc, &rc, frame); + DeleteObject(frame); + + RECT body = rc; + InflateRect(&body, -18, -16); + (void)kind; + SetBkMode(dc, TRANSPARENT); + DrawTextA(dc, kind == "imagefile" || value.empty() ? "Preview unavailable" : value.c_str(), + -1, &body, DT_CENTER | DT_VCENTER | DT_SINGLELINE); +} + void buildFlags(std::vector & t, DWORD flags) { union { DWORD dw; @@ -157,6 +183,7 @@ void PDialog::setMapped(lua_State * L, int idx) { case ETextEdit: case EInput: case ELabel: setWindowText(h, m.text.c_str()); break; + case EImage: InvalidateRect(h, nullptr, TRUE); break; case EList: if (!lua_isnumber(L, 3)) { ListBox_ResetContent(h); @@ -383,6 +410,16 @@ BOOL CALLBACK PDialog::dialogProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM case WM_SIZE: if (d) return d->handleResize(); return FALSE; + case WM_DRAWITEM: + if (d && wParam >= IDBASE && wParam < IDBASE + d->iElements.size()) { + SElement & m = d->iElements[wParam - IDBASE]; + if (m.type == EImage) { + DRAWITEMSTRUCT * dis = (DRAWITEMSTRUCT *)lParam; + drawImagePreview(dis->hDC, dis->rcItem, m.text); + return TRUE; + } + } + return FALSE; case WM_DESTROY: // Remove the subclasses from text edits for (int i = 0; i < int(d->iElements.size()); ++i) { @@ -422,6 +459,11 @@ void PDialog::buildElements(std::vector & t) { buildDimensions(t, m, id); buildControl(t, 0x0082, m.text.c_str()); // static text break; + case EImage: + buildFlags(t, flags | SS_OWNERDRAW); + buildDimensions(t, m, id); + buildControl(t, 0x0082, nullptr); // static frame + break; case EInput: buildFlags(t, flags | ES_LEFT | WS_TABSTOP | WS_BORDER | ES_AUTOHSCROLL); buildDimensions(t, m, id);