diff options
author | Yuuta Liang <yuutaw@student.cs.ubc.ca> | 2023-11-23 08:09:01 +0800 |
---|---|---|
committer | Yuuta Liang <yuutaw@student.cs.ubc.ca> | 2023-11-23 08:09:01 +0800 |
commit | 65ea6c17a0c1348aa9ef4e158102ddf173936882 (patch) | |
tree | 7615366f76b6c94f46d8039aa20091f9ccd5609a /src/main/ui/widgets | |
parent | b94b18c133f06cb176d8aa8bb40a8e24918d9ed6 (diff) | |
download | jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.tar jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.tar.gz jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.tar.bz2 jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.zip |
Add GUI
Signed-off-by: Yuuta Liang <yuutaw@student.cs.ubc.ca>
Diffstat (limited to 'src/main/ui/widgets')
-rw-r--r-- | src/main/ui/widgets/CertEditDialog.java | 108 | ||||
-rw-r--r-- | src/main/ui/widgets/CertTableModel.java | 129 | ||||
-rw-r--r-- | src/main/ui/widgets/GCBuilder.java | 213 | ||||
-rw-r--r-- | src/main/ui/widgets/LogTableModel.java | 79 | ||||
-rw-r--r-- | src/main/ui/widgets/TemplateTableModel.java | 105 | ||||
-rw-r--r-- | src/main/ui/widgets/UIUtils.java | 168 |
6 files changed, 802 insertions, 0 deletions
diff --git a/src/main/ui/widgets/CertEditDialog.java b/src/main/ui/widgets/CertEditDialog.java new file mode 100644 index 0000000..ea16b19 --- /dev/null +++ b/src/main/ui/widgets/CertEditDialog.java @@ -0,0 +1,108 @@ +package ui.widgets; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; + +import static java.awt.GridBagConstraints.HORIZONTAL; +import static java.awt.GridBagConstraints.WEST; +import static ui.widgets.UIUtils.btn; + +/** + * A common dialog for cert / template editing. It will close upon Esc or Cancel. + * ┌───────────────────────────┐ + * │ Dialog X │ + * │ │ + * │Template: (TBD) │ + * │Subject: _________ │ + * │Validity (Days): (Spinner)│ + * │ │ + * │ Button Cancel│ + * └───────────────────────────┘ + */ +public abstract class CertEditDialog<T> extends JDialog { + /** + * The result. + */ + protected T res; + + /** + * Root pane. + */ + protected JPanel contentPane = new JPanel(); + protected JButton buttonOK = btn("", this::onOK); + protected JTextField textFieldSubject = new JTextField(); + protected JSpinner spinnerValidity = + new JSpinner(new SpinnerNumberModel(60, 1, null, 0)); + + /** + * EFFECTS: Render the dialog, leaving title and OK button text blank. + */ + public CertEditDialog() { + contentPane.setLayout(new BoxLayout(contentPane, BoxLayout.PAGE_AXIS)); + contentPane.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + contentPane.add(renderForm()); + + contentPane.add(UIUtils.createActionsPane(buttonOK, btn("Cancel", this::onCancel))); + + setContentPane(contentPane); + setModal(true); + getRootPane().setDefaultButton(buttonOK); + setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); + + addWindowListener(new WindowAdapter() { + public void windowClosing(WindowEvent e) { + onCancel(null); + } + }); + + contentPane.registerKeyboardAction(this::onCancel, + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), + JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); + } + + /** + * EFFECTS: Render the form. + */ + private JPanel renderForm() { + final JPanel panelForm = new JPanel(new GridBagLayout()); + panelForm.add(new JLabel("Template: "), new GCBuilder().anchor(WEST).build()); + panelForm.add(new JLabel("Subject: "), new GCBuilder().gridY(1).anchor(WEST).build()); + panelForm.add(new JLabel("Validity (Days): "), new GCBuilder().gridY(2).anchor(WEST).build()); + panelForm.add(createTemplateComponent(), new GCBuilder().gridXY(1, 0).anchor(WEST) + .fill(HORIZONTAL).build()); + panelForm.add(textFieldSubject, new GCBuilder().gridXY(1, 1).anchor(WEST).fill(HORIZONTAL).build()); + panelForm.add(spinnerValidity, new GCBuilder().gridXY(1, 2).anchor(WEST).fill(HORIZONTAL).build()); + panelForm.add(new JPanel(), new GCBuilder().gridXY(1, 3).expandXY().fill(HORIZONTAL).build()); + return panelForm; + } + + /** + * EFFECTS: Create the component for subject. + */ + protected abstract JComponent createTemplateComponent(); + + /** + * EFFECTS: Handle OK. + */ + protected abstract void onOK(ActionEvent ev); + + /** + * EFFECTS: Handle cancel: clear result and close dialog. + * MODIFIES: this + */ + private void onCancel(ActionEvent ev) { + res = null; + dispose(); + } + + /** + * EFFECTS: Get the result. + */ + public T getRes() { + return res; + } +} diff --git a/src/main/ui/widgets/CertTableModel.java b/src/main/ui/widgets/CertTableModel.java new file mode 100644 index 0000000..0259e1b --- /dev/null +++ b/src/main/ui/widgets/CertTableModel.java @@ -0,0 +1,129 @@ +package ui.widgets; + +import model.ca.CertificationAuthority; +import model.pki.cert.Certificate; +import model.pki.crl.RevokedCertificate; + +import javax.swing.*; +import javax.swing.table.AbstractTableModel; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Table model that displays issued certificates. + */ +public class CertTableModel extends AbstractTableModel { + /** + * Valid certificate icon. + */ + private static final ImageIcon ICON_OK = + new ImageIcon(CertTableModel.class.getResource("/verified.png")); + + /** + * Revoked certificate icon (same as toolbar revoke icon). + */ + private static final ImageIcon ICON_REVOKED = + new ImageIcon(CertTableModel.class.getResource("/deletetest.png")); + + /** + * Columns + */ + private static final String[] COLS = new String[] { + "", // Icon + "Serial", + "Subject", + "Signed", + "Expires" + }; + + /** + * Pointer to {@link CertificationAuthority#getSigned()} + */ + private List<Certificate> ptrData; + + /** + * Pointer to {@link CertificationAuthority#getRevoked()} + */ + private List<RevokedCertificate> ptrRevokedData; + + /** + * EFFECTS: Set pointer to certs + * MODIFIES: this + */ + public void setPtrData(List<Certificate> ptrData) { + this.ptrData = ptrData; + } + + /** + * EFFECTS: Set pointer to revoked + * MODIFIES: this + */ + public void setPtrRevokedData(List<RevokedCertificate> ptrRevokedData) { + this.ptrRevokedData = ptrRevokedData; + } + + /** + * EFFECTS: Count rows. + */ + @Override + public int getRowCount() { + return ptrData == null ? 0 : ptrData.size(); + } + + /** + * EFFECTS: Count columns. + */ + @Override + public int getColumnCount() { + return COLS.length; + } + + /** + * EFFECTS: Get column name. + * REQUIRES: column in [9, getColumnCount()) + */ + @Override + public String getColumnName(int column) { + return COLS[column]; + } + + /** + * EFFECTS: Return the value for a cell: + * ImageIcon (Valid / Revoked) + * String (Serial number) + * String (Subject) + * String (NotBefore) + * String (NotAfter) + * Throws {@link IllegalArgumentException} if columnIndex is not in 0 ~ 4 + * REQUIRES: rowIndex must in range. + */ + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + final Certificate e = ptrData.get(rowIndex); + switch (columnIndex) { + case 0: return ptrRevokedData.stream().anyMatch(r -> + r.getSerialNumber().getLong() == e.getCertificate().getSerialNumber().getLong() + ) ? ICON_REVOKED : ICON_OK; + case 1: return e.getCertificate().getSerialNumber().getLong(); + case 2: return e.getCertificate().getSubject().toString(); + case 3: + return e.getCertificate().getValidity().getNotBefore().getTimestamp() + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + case 4: + return e.getCertificate().getValidity().getNotAfter().getTimestamp() + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + default: throw new IllegalArgumentException(); + } + } + + /** + * EFFECTS: Return ImageIcon for 0, String otherwise. + */ + @Override + public Class<?> getColumnClass(int columnIndex) { + switch (columnIndex) { + case 0: return ImageIcon.class; + default: return String.class; + } + } +} diff --git a/src/main/ui/widgets/GCBuilder.java b/src/main/ui/widgets/GCBuilder.java new file mode 100644 index 0000000..0590cc1 --- /dev/null +++ b/src/main/ui/widgets/GCBuilder.java @@ -0,0 +1,213 @@ +package ui.widgets; + +import java.awt.*; + +/** + * A builder for {@link GridBagConstraints}. + */ +public class GCBuilder { + /** + * Grix X and Y, defaults to {@link GridBagConstraints#RELATIVE} + */ + private int gridX = GridBagConstraints.RELATIVE; + + private int gridY = GridBagConstraints.RELATIVE; + + /** + * Weight X and Y, [0.0, 1.0], defaults to 0.0 + */ + private double weightX = 0.0; + + private double weightY = 0.0; + + /** + * Anchor, defaults to {@link GridBagConstraints#CENTER} + */ + private int anchor = GridBagConstraints.CENTER; + + /** + * How to stretch the component, defaults to {@link GridBagConstraints#NONE} + */ + private int fill = GridBagConstraints.NONE; + + /** + * Insects in pixels. + */ + private int insectTop = 0; + + private int insectLeft = 0; + + private int insectRight = 0; + + private int insectBottom = 0; + + /** + * EFFECTS: Build the {@link GridBagConstraints} based on parameters. + * Grid width, grid height, ipad X, ipad Y will be 1, 1, 0, 0. + */ + public GridBagConstraints build() { + return new GridBagConstraints(gridX, gridY, + 1, 1, + weightX, weightY, + anchor, fill, + new Insets(insectTop, insectLeft, insectBottom, insectRight), + 0, 0); + } + + /** + * EFFECTS: Set grid X and Y. + * REQUIRES: > 0 + * MODIFIES: this + */ + public GCBuilder gridXY(int gridX, int gridY) { + return this.gridX(gridX).gridY(gridY); + } + + /** + * EFFECTS: Set grid X. + * REQUIRES: > 0 + * MODIFIES: this + */ + public GCBuilder gridX(int gridX) { + this.gridX = gridX; + return this; + } + + /** + * EFFECTS: Set grid Y. + * REQUIRES: > 0 + * MODIFIES: this + */ + public GCBuilder gridY(int gridY) { + this.gridY = gridY; + return this; + } + + /** + * EFFECTS: Set weight X and Y. + * REQUIRES: [0.0, 1.0] + * MODIFIES: this + */ + public GCBuilder weightXY(double weightX, double weightY) { + return this.weightX(weightX).weightY(weightY); + } + + /** + * EFFECTS: Set weight X and Y to 1.0 + * MODIFIES: this + */ + public GCBuilder expandXY() { + return this.expandX().expandY(); + } + + /** + * EFFECTS: Set weight X to 1.0 + * MODIFIES: this + */ + public GCBuilder expandX() { + return this.weightX(1.0); + } + + /** + * EFFECTS: Set weight X to 1.0 + * MODIFIES: this + */ + public GCBuilder weightX(double weightX) { + this.weightX = weightX; + return this; + } + + /** + * EFFECTS: Set weight Y to 1.0 + * MODIFIES: this + */ + public GCBuilder expandY() { + return this.weightY(1.0); + } + + /** + * EFFECTS: Set weight X to 1.0 + * MODIFIES: this + */ + public GCBuilder weightY(double weightY) { + this.weightY = weightY; + return this; + } + + /** + * EFFECTS: Set anchor. + * REQUIRES: anchor in {@link GridBagConstraints} constants. + * MODIFIES: this + */ + public GCBuilder anchor(int anchor) { + this.anchor = anchor; + return this; + } + + /** + * EFFECTS: Set fill. + * REQUIRES: fill in {@link GridBagConstraints} constants. + * MODIFIES: this + */ + public GCBuilder fill(int fill) { + this.fill = fill; + return this; + } + + /** + * EFFECTS: Set insects to be 8, 8, 8, 8 + * MODIFIES: this + */ + public GCBuilder marginInsects() { + return this.insects(8, 8, 8, 8); + } + + /** + * EFFECTS: Set insects. + * REQUIRES: >= 0 + * MODIFIES: this + */ + public GCBuilder insects(int top, int left, int bottom, int right) { + return this.insectTop(top).insectLeft(left).insectBottom(bottom).insectRight(right); + } + + /** + * EFFECTS: Set top insect + * REQUIRES: >= 0 + * MODIFIES: this + */ + public GCBuilder insectTop(int insectTop) { + this.insectTop = insectTop; + return this; + } + + /** + * EFFECTS: Set left insect + * REQUIRES: >= 0 + * MODIFIES: this + */ + public GCBuilder insectLeft(int insectLeft) { + this.insectLeft = insectLeft; + return this; + } + + /** + * EFFECTS: Set right insect + * REQUIRES: >= 0 + * MODIFIES: this + */ + public GCBuilder insectRight(int insectRight) { + this.insectRight = insectRight; + return this; + } + + /** + * EFFECTS: Set bottom insect + * REQUIRES: >= 0 + * MODIFIES: this + */ + public GCBuilder insectBottom(int insectBottom) { + this.insectBottom = insectBottom; + return this; + } +} diff --git a/src/main/ui/widgets/LogTableModel.java b/src/main/ui/widgets/LogTableModel.java new file mode 100644 index 0000000..dc7dcbd --- /dev/null +++ b/src/main/ui/widgets/LogTableModel.java @@ -0,0 +1,79 @@ +package ui.widgets; + +import model.ca.AuditLogEntry; +import model.ca.CertificationAuthority; + +import javax.swing.table.AbstractTableModel; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Table model that displays audit logs. + */ +public class LogTableModel extends AbstractTableModel { + /** + * Columns + */ + private static final String[] COLS = new String[] { + "Time", + "Operator", + "Action" + }; + + /** + * Pointer to the {@link CertificationAuthority#getLogs()}. + */ + private List<AuditLogEntry> ptrData; + + /** + * EFFECTS: Set the pointer to templates + * MODIFIES: this + */ + public void setPtrData(List<AuditLogEntry> ptrData) { + this.ptrData = ptrData; + } + + /** + * EFFECT: Return number of rows. + */ + @Override + public int getRowCount() { + return ptrData == null ? 0 : ptrData.size(); + } + + /** + * EFFECT: Return number of columns. + */ + @Override + public int getColumnCount() { + return COLS.length; + } + + /** + * EFFECTS: Get column name. + * REQUIRES: column in [9, getColumnCount()) + */ + @Override + public String getColumnName(int column) { + return COLS[column]; + } + + /** + * EFFECTS: Return the value for a cell: + * String (Time) + * String (Operator) + * String (Action) + * Throws {@link IllegalArgumentException} if columnIndex is not in 0 ~ 2 + * REQUIRES: rowIndex must in range. + */ + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + final AuditLogEntry e = ptrData.get(rowIndex); + switch (columnIndex) { + case 0: return e.getTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + case 1: return e.getUser(); + case 2: return e.getAction(); + default: throw new IllegalArgumentException(); + } + } +} diff --git a/src/main/ui/widgets/TemplateTableModel.java b/src/main/ui/widgets/TemplateTableModel.java new file mode 100644 index 0000000..da4557f --- /dev/null +++ b/src/main/ui/widgets/TemplateTableModel.java @@ -0,0 +1,105 @@ +package ui.widgets; + +import model.ca.CertificationAuthority; +import model.ca.Template; + +import javax.swing.*; +import javax.swing.table.AbstractTableModel; +import java.util.List; + +/** + * Table model that displays templates. + */ +public class TemplateTableModel extends AbstractTableModel { + /** + * Template enabled icon, same as toolbar enable icon. + */ + private static final ImageIcon ICON_ENABLED = + new ImageIcon(TemplateTableModel.class.getResource("/enable.png")); + + /** + * Template disbled icon, same as toolbar enable icon. + */ + private static final ImageIcon ICON_DISABLED = + new ImageIcon(TemplateTableModel.class.getResource("/disable.png")); + + /** + * Columns + */ + private static final String[] COLS = new String[] { + "", // Icon + "Name", + "Subject", + "Validity" + }; + + /** + * Pointer to the {@link CertificationAuthority#getTemplates()}. + */ + private List<Template> ptrData; + + /** + * EFFECTS: Set the pointer to templates + * MODIFIES: this + */ + public void setPtrData(List<Template> ptrData) { + this.ptrData = ptrData; + } + + /** + * EFFECT: Return number of rows. + * REQUIRES: column in [9, getColumnCount()) + */ + @Override + public int getRowCount() { + return ptrData == null ? 0 : ptrData.size(); + } + + /** + * EFFECT: Return number of rows. + */ + @Override + public int getColumnCount() { + return COLS.length; + } + + /** + * EFFECTS: Get column name. + */ + @Override + public String getColumnName(int column) { + return COLS[column]; + } + + /** + * EFFECTS: Return the value for a cell: + * ImageIcon (Enabled / Disabled) + * String (Name) + * String (Subject or Not Set) + * String (xx days) + * Throws {@link IllegalArgumentException} if columnIndex is not in 0 ~ 3 + * REQUIRES: rowIndex must in range. + */ + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + final Template e = ptrData.get(rowIndex); + switch (columnIndex) { + case 0: return e.isEnabled() ? ICON_ENABLED : ICON_DISABLED; + case 1: return e.getName(); + case 2: return e.getSubject() == null ? "<Not Set>" : e.getSubject().toString(); + case 3: return String.format("%d days", e.getValidity()); + default: throw new IllegalArgumentException(); + } + } + + /** + * EFFECTS: Return ImageIcon for 0, String otherwise. + */ + @Override + public Class<?> getColumnClass(int columnIndex) { + switch (columnIndex) { + case 0: return ImageIcon.class; + default: return String.class; + } + } +} diff --git a/src/main/ui/widgets/UIUtils.java b/src/main/ui/widgets/UIUtils.java new file mode 100644 index 0000000..4442be3 --- /dev/null +++ b/src/main/ui/widgets/UIUtils.java @@ -0,0 +1,168 @@ +package ui.widgets; + +import model.asn1.exceptions.ParseException; +import ui.Utils; + +import javax.swing.*; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.*; +import java.awt.event.ActionListener; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.stream.IntStream; + +import static java.awt.GridBagConstraints.BOTH; +import static java.awt.GridBagConstraints.HORIZONTAL; +import static javax.swing.JOptionPane.*; + +/** + * Useful utilities for building GUI. + */ +public class UIUtils { + /** + * EFFECTS: Create a horizontal actions pane: + * ----------------------------------------------------------- + * | | Button1 | Button2 | Button3 | ButtonN | + * ----------------------------------------------------------- + * REQUIRES: buttons != null + */ + public static JPanel createActionsPane(JButton... buttons) { + final JPanel panelAct = new JPanel(); + panelAct.setLayout(new GridBagLayout()); + IntStream.range(0, buttons.length) + .forEach(i -> panelAct.add(buttons[i], + new GCBuilder().gridXY(i + 1, 1).fill(HORIZONTAL).build())); + panelAct.add(new JPanel(), new GCBuilder().expandXY().fill(BOTH).build()); + return panelAct; + } + + /** + * EFFECTS: Show / hide default text for a card layout container. + * MODIFIES: cardLayoutPanel + * REQUIRES: cardLayoutPanel must have a card layout; it must have CardContent and CardDefault cards. + */ + public static void setContentVisible(Container cardLayoutPanel, boolean showContent) { + switchTo(cardLayoutPanel, showContent ? "CardContent" : "CardDefault"); + } + + /** + * EFFECTS: Switch to the card for a card layout panel. + * MODIFIES: cardLayoutPanel + * REQUIRES: cardLayoutPanel must have a card layout; it must have "card" card. + */ + public static void switchTo(Container cardLayoutPanel, String card) { + ((CardLayout) cardLayoutPanel.getLayout()).show(cardLayoutPanel, card); + } + + /** + * EFFECTS: Show an error message based on {@link Throwable#getMessage()} + * REQUIRES: component must have a frame. + */ + public static void alert(Component component, String title, Throwable e) { + alert(component, title, e.getMessage()); + } + + /** + * EFFECTS: Show an error message. + * REQUIRES: component must have a frame. + */ + public static void alert(Component component, String title, String message) { + showMessageDialog(getFrameForComponent(component), + message, + title, + ERROR_MESSAGE); + } + + /** + * EFFECTS: Show a file chooser with the given filter title and list of extensions. Starting from cwd. + * Return null if cancelled. + */ + public static Path chooseFile(Component component, String filterTitle, String... extensions) { + final JFileChooser fc = new JFileChooser(); + fc.setFileFilter(new FileNameExtensionFilter(filterTitle, extensions)); + fc.setCurrentDirectory(Paths.get("").toAbsolutePath().toFile()); + if (fc.showOpenDialog(getFrameForComponent(component)) == JFileChooser.APPROVE_OPTION) { + return fc.getSelectedFile().toPath(); + } + return null; + } + + /** + * EFFECTS: Create a JPanel with CardLayout that has CardDefault set to component and CardContent set to a JLabel + * with the default text. + */ + public static JPanel defView(Component component, String defaultText) { + final JPanel panel = new JPanel(); + panel.setLayout(new CardLayout(0, 0)); + + JLabel labelDefault = new JLabel(defaultText); + labelDefault.setHorizontalAlignment(0); + panel.add(labelDefault, "CardDefault"); + panel.add(component, "CardContent"); + + return panel; + } + + /** + * EFFECTS: Create a JScrollPane-wrapped JTable. + * MODIFIES: table + */ + public static JScrollPane scrTbl(JTable table) { + final JScrollPane scrollPane = new JScrollPane(); + table.setFillsViewportHeight(true); + scrollPane.setViewportView(table); + return scrollPane; + } + + /** + * EFFECTS: Parse the given path and automatically determine if it is a DER binary or a PEM. Automatically decode + * PEM. + * Throws {@link IOException} if it cannot be read. + * Throws {@link ParseException} if the PEM is invalid. + */ + public static Byte[] openDERorPEM(Path path, String tag) throws IOException, ParseException { + final InputStream fd = Files.newInputStream(path, StandardOpenOption.READ); + Byte[] bs = Utils.byteToByte(fd.readAllBytes()); + fd.close(); + if (bs.length < 1) { + throw new ParseException("Invalid file: too short"); + } + if (bs[0] == '-') { + bs = Utils.parsePEM(bs, tag); + } + return bs; + } + + /** + * EFFECTS: Create a button with the given label and click listener. + */ + public static JButton btn(String string, ActionListener onClick) { + final JButton btn = new JButton(string); + btn.addActionListener(onClick); + return btn; + } + + /** + * EFFECTS: Create a button with the given label, mnemonic, and click listener. + */ + public static JButton btn(String string, char m, ActionListener onClick) { + final JButton btn = new JButton(string); + btn.setMnemonic(m); + btn.setDisplayedMnemonicIndex(0); + btn.addActionListener(onClick); + return btn; + } + + /** + * EFFECTS: Create a button with the given label, mnemonic, icon, and click listener. + */ + public static JButton btn(String string, String icon, ActionListener onClick) { + final JButton btn = new JButton(string, new ImageIcon(UIUtils.class.getResource("/" + icon))); + btn.addActionListener(onClick); + return btn; + } +} |