001/******************************************************************************* 002 * This software is provided as a supplement to the authors' textbooks on digital 003 * image processing published by Springer-Verlag in various languages and editions. 004 * Permission to use and distribute this software is granted under the BSD 2-Clause 005 * "Simplified" License (see http://opensource.org/licenses/BSD-2-Clause). 006 * Copyright (c) 2006-2023 Wilhelm Burger, Mark J. Burge. All rights reserved. 007 * Visit https://imagingbook.com for additional details. 008 ******************************************************************************/ 009package imagingbook.common.ij; 010 011import ij.IJ; 012import ij.ImagePlus; 013import ij.WindowManager; 014import ij.gui.GenericDialog; 015import ij.macro.Interpreter; 016import imagingbook.common.util.ParameterBundle; 017import imagingbook.core.resource.ImageResource; 018 019import java.lang.annotation.ElementType; 020import java.lang.annotation.Retention; 021import java.lang.annotation.RetentionPolicy; 022import java.lang.annotation.Target; 023import java.lang.reflect.Field; 024import java.util.ArrayList; 025import java.util.List; 026 027/** 028 * Utility methods and annotations related to ImageJ's {@link GenericDialog} class. 029 * 030 * @author WB 031 * @version 2022/09/14 032 */ 033public abstract class DialogUtils { 034 035 private DialogUtils() {} 036 037 /** 038 * Annotation to specify a specific 'label' (value) to be shown for following parameter fields. Default label is the 039 * variable name. Intended to be used on {@link ParameterBundle} fields. 040 */ 041 @Retention(RetentionPolicy.RUNTIME) 042 @Target({ElementType.FIELD}) 043 public static @interface DialogLabel { 044 public String value(); 045 } 046 047 /** 048 * Annotation to specify the number of digits (value) displayed when showing numeric values in dialogs. This 049 * annotation has no effect on non-floating-point fields. Intended to be used on {@link ParameterBundle} fields. 050 */ 051 @Retention(RetentionPolicy.RUNTIME) 052 @Target({ElementType.FIELD}) 053 public static @interface DialogDigits { 054 public int value(); 055 } 056 057 /** 058 * Annotation to specify the number of "columns" (value) displayed when showing string items in dialogs. This 059 * annotation has no effect on non-string fields. Intended to be used on {@link ParameterBundle} fields. 060 */ 061 @Retention(RetentionPolicy.RUNTIME) 062 @Target({ElementType.FIELD}) 063 public static @interface DialogStringColumns { 064 public int value(); 065 } 066 067 068 /** 069 * Annotation to hide the following parameter field in dialogs. Intended to be used on {@link ParameterBundle} 070 * fields. 071 */ 072 @Retention(RetentionPolicy.RUNTIME) 073 @Target({ElementType.FIELD}) 074 public static @interface DialogHide { 075 } 076 077 // Text-related methods ------------------------------------------------ 078 079 /** 080 * Splits a long string into multiple lines of the specified maximum length. The result is returned as a 081 * {@code String[]} with one element per text line. Multiple input strings are first joined into a single string 082 * using blank spaces as separators. Inspired by: https://stackoverflow.com/a/21002193 083 * 084 * @param columns the maximum number of characters per line 085 * @param input the input text to be decomposed 086 * @return a {@code String[]} with one element per text line 087 */ 088 public static String[] splitTextToLines(int columns, String input) { 089 String[] tokens = input.split("\\s+"); // SPLIT_REGEXP 090 List<String> lines = new ArrayList<>(); 091 StringBuilder lineBuf = new StringBuilder(columns); 092 for (int i = 0; i < tokens.length; i++) { 093 String word = tokens[i]; // get next token 094 if (lineBuf.length() == 0) { // always add the first word of a line 095 lineBuf.append(word); 096 } 097 else if (lineBuf.length() + 1 + word.length() < columns) { // does word fit into the current line? 098 lineBuf.append(' '); 099 lineBuf.append(word); 100 } 101 else { // no, word does not fit 102 lines.add(lineBuf.toString()); // eject existing line and start a new one 103 lineBuf = new StringBuilder(columns); // start a new line 104 lineBuf.append(word); 105 } 106 } 107 // flush remaining line (if any) 108 if (lineBuf.length() > 0) { 109 lines.add(lineBuf.toString()); 110 } 111 return lines.toArray(new String[0]); 112 } 113 114 /** 115 * Splits a long string into multiple lines of the specified maximum length and builds a new string with newline 116 * ({@literal \n}) characters separating successive lines. Multiple input strings are first joined into a single 117 * string using blank spaces as separators. Intended mainly to format message texts of plugin dialogs. 118 * 119 * @param columns the maximum number of characters per line 120 * @param textChunks one or more strings which are joined 121 * @return a new string with newline characters separating successive lines 122 */ 123 public static String formatText(int columns, String... textChunks) { 124 String[] lines = splitTextToLines(columns, String.join(" ", textChunks)); 125 return String.join("\n", lines); 126 } 127 128 /** 129 * Creates a string by formatting the supplied strings as individual text lines separated by newline. Mainly to be 130 * used with {@link GenericDialog#addMessage(String)}. 131 * 132 * @param lines a sequence of strings interpreted as text lines 133 * @return a newline-separated string 134 */ 135 public static String makeLineSeparatedString(String... lines) { 136 return String.join("\n", lines); 137 } 138 139 /** 140 * Creates a HTML string by formatting the supplied string(s) as individual text lines separated by {@literal <br>}. 141 * The complete text is wrapped by {@literal <html>...</html>}. Mainly used to format HTML-texts before being 142 * passed to {@link GenericDialog#addHelp(String)}, which requires text lines to be separated by {@literal <br>}. 143 * The supplied text lines may contain any text (including HTML formatting code), they are not processed or split. 144 * 145 * @param textLines a sequence of strings interpreted as text lines 146 * @return a HTML string with input lines separated by {@literal <br>} 147 */ 148 public static String makeHtmlString(String... textLines) { 149 return "<html>\n" + String.join("<br>\n", textLines) + "\n</html>"; 150 } 151 152 // ------------ Methods related to ParameterBundle ------------------ 153 154 static Field[] getDialogFields(ParameterBundle<?> params) { 155 Class<?> clazz = params.getClass(); 156 List<Field> dialogFields = new ArrayList<>(); 157 for (Field f : clazz.getFields()) { 158 if (isValidDialogField(f)) { 159 dialogFields.add(f); 160 } 161 } 162 return dialogFields.toArray(new Field[0]); 163 } 164 165 /** 166 * Adds all qualified fields of the given {@link ParameterBundle} to the specified {@link GenericDialog} instance, 167 * in the exact order of their definition. Qualified means that the field is of suitable type and no 168 * {@link DialogUtils.DialogHide} annotation is present. Allowed field types are {@code boolean}, {@code int}, 169 * {@code long}, {@code float}, {@code double}, {@code enum}, and {@code String}. 170 * 171 * @param params a {@link ParameterBundle} instance 172 * @param gd a generic dialog 173 */ 174 public static void addToDialog(ParameterBundle<?> params, GenericDialog gd) { 175 Field[] dialogFields = getDialogFields(params); // gets only public fields 176 for (Field f : dialogFields) { 177 if (!isValidDialogField(f) || f.isAnnotationPresent(DialogHide.class)) { 178 continue; 179 } 180 try { 181 addFieldToDialog(params, f, gd); 182 } catch (IllegalArgumentException | IllegalAccessException e) { 183 throw new RuntimeException(e.getMessage()); // TODO: refine exception handling! 184 } 185 } 186 } 187 188 /** 189 * Retrieves the field values of the specified {@link ParameterBundle} from the {@link GenericDialog} instance. The 190 * {@link ParameterBundle} is modified. Throws an exception if anything goes wrong. 191 * 192 * @param params a {@link ParameterBundle} instance 193 * @param gd a generic dialog 194 * @return true if successful 195 */ 196 public static boolean getFromDialog(ParameterBundle<?> params, GenericDialog gd) { 197 Class<?> clazz = params.getClass(); 198 Field[] fields = clazz.getFields(); // gets only public fields 199 int errorCount = 0; 200 for (Field f : fields) { 201 if (!isValidDialogField(f) || f.isAnnotationPresent(DialogHide.class)) { 202 continue; 203 } 204 try { 205 if (!getFieldFromDialog(params, f, gd)) { 206 errorCount++; 207 } 208 } catch (IllegalArgumentException | IllegalAccessException e) { 209 throw new RuntimeException(e.getMessage()); // TODO: refine exception handling! 210 } 211 } 212 return (errorCount == 0); 213 } 214 215 /** 216 * Adds the specified {@link Field} of this object as new item to the {@link GenericDialog} instance. The name of 217 * the field is used as the 'label' of the dialog item unless a {@link DialogLabel} annotation is present. Allowed 218 * field types are {@code boolean}, {@code int}, {@code float}, {@code double}, {@code enum}, and {@code String}. 219 * 220 * @param params a {@link ParameterBundle} instance 221 * @param field some field 222 * @param dialog the dialog 223 * @throws IllegalAccessException when field is accessed illegally 224 */ 225 private static void addFieldToDialog(ParameterBundle<?> params, Field field, GenericDialog dialog) 226 throws IllegalAccessException { 227 228 String name = field.getName(); 229 if (field.isAnnotationPresent(DialogLabel.class)) { 230 name = field.getAnnotation(DialogLabel.class).value(); 231 } 232 233 int digits = 2; // DefaultDialogDigits; 234 if (field.isAnnotationPresent(DialogDigits.class)) { 235 digits = field.getAnnotation(DialogDigits.class).value(); 236 digits = Math.max(0, digits); 237 } 238 239 int stringColumns = 8; 240 if (field.isAnnotationPresent(DialogStringColumns.class)) { 241 stringColumns = 242 Math.max(stringColumns, field.getAnnotation(DialogStringColumns.class).value()); 243 } 244 245 Class<?> clazz = field.getType(); 246 if (clazz.equals(boolean.class)) { 247 dialog.addCheckbox(name, field.getBoolean(params)); 248 } 249 else if (clazz.equals(int.class)) { 250 dialog.addNumericField(name, field.getInt(params), 0); 251 } 252 else if (clazz.equals(long.class)) { 253 dialog.addNumericField(name, field.getLong(params), 0); 254 } 255 else if (clazz.equals(float.class)) { 256 dialog.addNumericField(name, field.getFloat(params), digits); 257 } 258 else if (clazz.equals(double.class)) { 259 dialog.addNumericField(name, field.getDouble(params), digits); 260 } 261 else if (clazz.equals(String.class)) { 262 String str = (String) field.get(params); 263 dialog.addStringField(name, str, stringColumns); 264 } 265 else if (clazz.isEnum()) { 266 // dialog.addEnumChoice(name, (Enum<?>) field.get(params)); 267 dialog.addEnumChoice(name, (Enum) field.get(params)); 268 } 269 else { 270 // ignore this field 271 //throw new RuntimeException("cannot handle field of type " + clazz); 272 } 273 } 274 275 /** 276 * Modifies the specified {@link Field} of this object by reading the next item from the {@link GenericDialog} 277 * instance. 278 * 279 * @param params a {@link ParameterBundle} instance 280 * @param field a publicly accessible {@link Field} of this object 281 * @param gd a {@link GenericDialog} instance 282 * @return true if successful 283 * @throws IllegalAccessException illegal field access 284 */ 285 @SuppressWarnings({ "rawtypes", "unchecked" }) 286 private static boolean getFieldFromDialog(ParameterBundle params, Field field, GenericDialog gd) 287 throws IllegalAccessException { 288 Class<?> clazz = field.getType(); 289 if (clazz.equals(boolean.class)) { 290 field.setBoolean(params, gd.getNextBoolean()); 291 } 292 else if (clazz.equals(int.class)) { 293 double val = gd.getNextNumber(); 294 if (Double.isNaN(val)) { 295 return false; 296 } 297 field.setInt(params, (int) val); 298 } 299 else if (clazz.equals(long.class)) { 300 double val = gd.getNextNumber(); 301 if (Double.isNaN(val)) { 302 return false; 303 } 304 field.setLong(params, (long) val); 305 } 306 else if (clazz.equals(float.class)) { 307 double val = gd.getNextNumber(); 308 if (Double.isNaN(val)) { 309 return false; 310 } 311 field.setFloat(params, (float) val); 312 } 313 else if (clazz.equals(double.class)) { 314 double val = gd.getNextNumber(); 315 if (Double.isNaN(val)) { 316 return false; 317 } 318 field.setDouble(params, val); 319 } 320 else if (clazz.equals(String.class)) { 321 String str = gd.getNextString(); 322 if (str == null) { 323 return false; 324 } 325 field.set(params, str); 326 } 327 else if (clazz.isEnum()) { 328 Enum en = gd.getNextEnumChoice((Class<Enum>) clazz); 329 if (en == null) { 330 return false; 331 } 332 field.set(params, en); 333// field.set(instance, gd.getNextEnumChoice((Class<? extends Enum>) clazz)); // works 334// field.set(instance, gd.getNextEnumChoice((Class<Enum>) clazz)); // works 335 } 336 else { 337 // ignore this field 338 // throw new RuntimeException("cannot handle field of type " + clazz); 339 } 340 return true; 341 } 342 343 private static boolean isValidDialogField(Field f) { 344 if (!ParameterBundle.isValidParameterField(f)) { 345 return false; 346 } 347// int mod = f.getModifiers(); 348// if (Modifier.isPrivate(mod) || Modifier.isFinal(mod) || Modifier.isStatic(mod)) { 349// return false; 350// } 351 // accept only certain field types in dialogs: 352 Class<?> clazz = f.getType(); 353 return (clazz == boolean.class || clazz == int.class || clazz == long.class || 354 clazz == float.class || clazz == double.class || 355 clazz == String.class || clazz.isEnum()); 356 } 357 358 // various static methods for simple dialogs ------------------------------- 359 360// /** 361// * Values that may be returned by dialog methods, to be used in 362// * switch clauses. 363// */ 364// public enum DialogResponse { 365// Yes, No, Cancel; 366// } 367// 368// public static boolean isYes(DialogResponse response) { 369// return response.equals(DialogResponse.Yes); 370// } 371 372 /** 373 * Opens a simple dialog with the specified title and message that allows only a "Yes" or "Cancel" response. 374 * 375 * @param title the text displayed in the dialog's title bar 376 * @param message the dialog message (may be multiple lines separated by newlines) 377 * @return true if "yes" was selected, false otherwise 378 */ 379 @Deprecated 380 public static boolean askYesOrCancel(String title, String message) { 381 GenericDialog gd = new GenericDialog(title); 382 gd.addMessage(message); 383 gd.enableYesNoCancel("Yes", "Cancel"); 384 gd.hideCancelButton(); 385 gd.showDialog(); 386 if (gd.wasCanceled()) { 387 return false; 388 } 389 return gd.wasOKed(); 390 } 391 392 /** 393 * Opens a very specific dialog asking if the suggested sample image (resource) should be opened and made the active 394 * image. If the answer is YES, the suggested image is opened, otherwise not. This if typically used in the 395 * (otherwise empty) constructor of demo plugins when no (or no suitable) image is currently open. 396 * Does nothing and returns false if invoked from outside ImageJ (e.g., during testing) and in IJ batch mode. 397 * 398 * @param suggested a sample image ({@link ImageResource}) 399 * @return true if user accepted, false otherwise 400 */ 401 public static boolean askForSampleImage(ImageResource suggested) { // TODO: allow multiple sample images? 402 // System.out.println("askForSampleImage " + suggested); 403 // System.out.println("IJ.getInstance() = " + IJ.getInstance()); 404 // System.out.println("Interpreter.isBatchMode() = " + Interpreter.isBatchMode()); 405 if (IJ.getInstance() == null || Interpreter.isBatchMode()) { // skip in tests and in ImageJ batch mode 406 return false; 407 } 408 409 String title = "Open sample image"; 410 String message = "No image is currently open.\nUse a sample image?\n"; 411 GenericDialog gd = new GenericDialog(title); 412 gd.setInsets(0, 0, 0); 413 gd.addMessage(message); 414 415 if (suggested != null) { 416 gd.setInsets(10, 10, 10); 417 gd.addImage(suggested.getImageIcon()); 418 } 419 420 gd.enableYesNoCancel("Yes", "Cancel"); 421 gd.hideCancelButton(); 422 gd.showDialog(); 423 if (gd.wasCanceled()) { 424 return false; 425 } 426 427 boolean ok = gd.wasOKed(); 428 if (ok && suggested != null) { 429 ImagePlus im = suggested.getImagePlus(); 430 im.show(); 431 WindowManager.setCurrentWindow(im.getWindow()); 432 } 433 return ok; 434 } 435 436 public static boolean askForSampleImage() { 437 return askForSampleImage(null); 438 } 439 440 // ---------------------------------------------------- 441 442 // public static void main(String[] args) { 443 // String html = makeHtmlString( 444 // "Get busy living or", 445 // "get busy dying.", 446 // "--Stephen King"); 447 // System.out.println(html); 448 // System.out.println(); 449 // 450 // String lines = makeLineSeparatedString( 451 // "Get busy living or", 452 // "get busy dying.", 453 // "--Stephen King"); 454 // System.out.println(lines); 455 // } 456 457 // ------------------------------------------------------------- 458 459 // static String input1 = 460 // "THESE TERMS AND CONDITIONS OF SERVICE (the Terms) ARE A LEGAL AND BINDING " + 461 // "AGREEMENT BETWEEN YOU AND NATIONAL GEOGRAPHIC governing your use of this site, " + 462 // "www.nationalgeographic.com, which includes but is not limited to products, " + 463 // "software and services offered by way of the website such as the Video Player."; 464 // 465 // static String[] input2 = { 466 // "THESE TERMS AND CONDITIONS OF SERVICE (the Terms) ARE A LEGAL AND BINDING", 467 // "AGREEMENT BETWEEN YOU AND NATIONAL GEOGRAPHIC governing your use of this site,", 468 // "www.nationalgeographic.com, which includes but is not limited to products,", 469 // "software and services offered by way of the website such as the Video Player."}; 470 // 471 // 472 // public static void main(String[] args) { 473 // // System.out.println(splitLines(20, input1)); 474 // System.out.println(DialogUtils.formatText(20, input1)); 475 // } 476 477}